Compare commits

..

1 Commits

Author SHA1 Message Date
Mees Frensel 498c6ba87a chore(server): deprecate total field of asset search response 2026-05-22 00:04:36 +02:00
47 changed files with 639 additions and 1839 deletions
-3
View File
@@ -976,7 +976,6 @@
"downloading_asset_filename": "Downloading asset {filename}",
"downloading_from_icloud": "Downloading from iCloud",
"downloading_media": "Downloading media",
"drag_to_reorder": "Drag to reorder",
"drop_files_to_upload": "Drop files anywhere to upload",
"duplicates": "Duplicates",
"duplicates_description": "Resolve each group by indicating which, if any, are duplicates.",
@@ -2255,7 +2254,6 @@
"step_delete_confirm": "Are you sure you want to delete this step?",
"step_details": "Step details",
"steps": "Steps",
"steps_count": "{count, plural, one {# step} other {# steps}}",
"stop_casting": "Stop casting",
"stop_motion_photo": "Stop Motion Photo",
"stop_photo_sharing": "Stop sharing your photos?",
@@ -2478,7 +2476,6 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
-1
View File
@@ -73,7 +73,6 @@ run = "bash ./bin/generate-dart-sdk.sh"
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:build" },
{ task = "//server:install" },
{ task = "//server:build" },
{ task = "//server:sync-open-api" },
@@ -17,8 +17,6 @@ import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30
@@ -46,9 +44,7 @@ class MainActivity : FlutterFragmentActivity() {
} else {
NativeSyncApiImpl30(ctx)
}
val permissionApiImpl = PermissionApiImpl(ctx)
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
PermissionApi.setUp(messenger, permissionApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
@@ -57,7 +53,6 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(nativeSyncApiImpl)
flutterEngine.plugins.add(permissionApiImpl)
}
fun cancelPlugins(flutterEngine: FlutterEngine) {
@@ -65,8 +60,6 @@ class MainActivity : FlutterFragmentActivity() {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine()
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
permissionApi?.detachFromEngine()
}
}
}
@@ -1,96 +0,0 @@
package app.alextran.immich.permission
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.MediaStore
import android.provider.Settings
import androidx.core.net.toUri
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class ManageMediaPermissionDelegate(
context: Context,
private val requestCode: Int = 1003,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
if (hasManageMediaPermission()) {
callback(Result.success(true))
return
}
openManageMediaPermissionSettings(callback)
}
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
openManageMediaPermissionSettings(callback)
}
private fun openManageMediaPermissionSettings(callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
callback(Result.success(false))
return
}
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
pendingResult = callback
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA).apply {
data = "package:${activity.packageName}".toUri()
}
try {
activity.startActivityForResult(intent, requestCode)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("ACTIVITY_LAUNCH_FAILED", "Failed to launch MANAGE_MEDIA settings", e.toString())
)
)
}
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before MANAGE_MEDIA result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == this.requestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(hasManageMediaPermission()))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -1,128 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")
package app.alextran.immich.permission
import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object PermissionApiPigeonUtils {
fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}
fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}
/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
private open class PermissionApiPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface PermissionApi {
fun hasManageMediaPermission(): Boolean
fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit)
fun manageMediaPermission(callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by PermissionApi. */
val codec: MessageCodec<Any?> by lazy {
PermissionApiPigeonCodec()
}
/** Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.hasManageMediaPermission())
} catch (exception: Throwable) {
PermissionApiPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.requestManageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.manageMediaPermission{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PermissionApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PermissionApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -1,37 +0,0 @@
package app.alextran.immich.permission
import android.content.Context
import app.alextran.immich.core.ImmichPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
class PermissionApiImpl(context: Context) : ImmichPlugin(), PermissionApi, ActivityAware {
private val manageMediaPermissionDelegate = ManageMediaPermissionDelegate(context)
override fun hasManageMediaPermission(): Boolean =
manageMediaPermissionDelegate.hasManageMediaPermission()
override fun requestManageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.requestManageMediaPermission { completeWhenActive(callback, it) }
}
override fun manageMediaPermission(callback: (Result<Boolean>) -> Unit) {
manageMediaPermissionDelegate.manageMediaPermission { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
manageMediaPermissionDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
manageMediaPermissionDelegate.onDetachedFromActivity()
}
}
@@ -1,133 +0,0 @@
package app.alextran.immich.sync
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 androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.PluginRegistry
class MediaTrashDelegate(
context: Context,
private val trashRequestCode: Int = 1002,
) : PluginRegistry.ActivityResultListener {
private val ctx = context.applicationContext
private var activityBinding: ActivityPluginBinding? = null
private var pendingResult: ((Result<Boolean>) -> Unit)? = null
private fun hasManageMediaPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaStore.canManageMedia(ctx)
} else {
false
}
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || !hasManageMediaPermission()) {
callback(Result.failure(FlutterError("PERMISSION_DENIED", "Media permission required", null)))
return
}
val id = mediaId.toLongOrNull()
if (id == null) {
callback(Result.failure(FlutterError("INVALID_ID", "The file id is not a valid number: $mediaId", null)))
return
}
if (!isInTrash(id)) {
callback(Result.failure(FlutterError("TRASH_NOT_FOUND", "Item with id=$id not found in trash", null)))
return
}
restoreUri(ContentUris.withAppendedId(contentUriForType(type.toInt()), id), callback)
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreUri(
contentUri: Uri,
callback: (Result<Boolean>) -> Unit,
) {
val activity = activityBinding?.activity
if (activity == null) {
callback(Result.failure(FlutterError("NO_ACTIVITY", "Activity not available", null)))
return
}
try {
val pendingIntent = MediaStore.createTrashRequest(ctx.contentResolver, listOf(contentUri), false)
pendingResult = callback
activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null,
0,
0,
0,
)
} catch (e: Exception) {
pendingResult = null
callback(
Result.failure(
FlutterError("TRASH_ERROR", "Error creating or starting trash request", e.toString())
)
)
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun isInTrash(id: Long): Boolean {
val filesUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val args = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns._ID}=?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(id.toString()))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
putInt(ContentResolver.QUERY_ARG_LIMIT, 1)
}
return ctx.contentResolver.query(filesUri, arrayOf(MediaStore.Files.FileColumns._ID), args, null)
?.use { it.moveToFirst() } == true
}
private fun contentUriForType(type: Int): Uri =
when (type) {
// Same order as AssetType from Dart.
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
}
fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
fun onDetachedFromActivity() {
failPending("ACTIVITY_DETACHED", "Activity detached before trash result")
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == trashRequestCode) {
val callback = pendingResult
pendingResult = null
callback?.invoke(Result.success(resultCode == Activity.RESULT_OK))
return true
}
return false
}
private fun failPending(code: String, message: String) {
val callback = pendingResult ?: return
pendingResult = null
callback(Result.failure(FlutterError(code, message, null)))
}
}
@@ -553,7 +553,6 @@ interface NativeSyncApi {
fun hashAssets(assetIds: List<String>, allowNetworkAccess: Boolean, callback: (Result<List<HashResult>>) -> Unit)
fun cancelHashing()
fun getTrashedAssets(): Map<String, List<PlatformAsset>>
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit)
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult>
companion object {
@@ -748,27 +747,6 @@ interface NativeSyncApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val mediaIdArg = args[0] as String
val typeArg = args[1] as Long
api.restoreFromTrashById(mediaIdArg, typeArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(MessagesPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$separatedMessageChannelSuffix", codec, taskQueue)
if (api != null) {
@@ -17,8 +17,6 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.load.ImageHeaderParser
import com.bumptech.glide.load.ImageHeaderParserUtils
import com.bumptech.glide.load.resource.bitmap.DefaultImageHeaderParser
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -41,11 +39,10 @@ sealed class AssetResult {
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAware {
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
private val ctx: Context = context.applicationContext
private var hashTask: Job? = null
private val mediaTrashDelegate = MediaTrashDelegate(ctx)
companion object {
private const val MAX_CONCURRENT_HASH_OPERATIONS = 16
@@ -451,26 +448,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin(), ActivityAwa
hashTask = null
}
fun restoreFromTrashById(mediaId: String, type: Long, callback: (Result<Boolean>) -> Unit) {
mediaTrashDelegate.restoreFromTrashById(mediaId, type) { completeWhenActive(callback, it) }
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivityForConfigChanges() {
mediaTrashDelegate.onDetachedFromActivity()
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
mediaTrashDelegate.onAttachedToActivity(binding)
}
override fun onDetachedFromActivity() {
mediaTrashDelegate.onDetachedFromActivity()
}
// This method is only implemented on iOS; on Android, we do not have a concept of cloud IDs
@Suppress("unused", "UNUSED_PARAMETER")
fun getCloudIdForAssetIds(assetIds: List<String>): List<CloudIdResult> {
@@ -19,8 +19,6 @@
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */; };
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */; };
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */; };
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */; };
D218389C4A4C4693F141F7D1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 886774DBDDE6B35BF2B4F2CD /* Pods_Runner.framework */; };
F02538E92DFBCBDD008C3FA3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -107,8 +105,6 @@
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.g.swift; sourceTree = "<group>"; };
B25D377B2E72CA20008B6CA7 /* ConnectivityApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityApiImpl.swift; sourceTree = "<group>"; };
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApi.g.swift; sourceTree = "<group>"; };
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionApiImpl.swift; sourceTree = "<group>"; };
B2BE315E2E5E5229006EEF88 /* BackgroundWorker.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.g.swift; sourceTree = "<group>"; };
E0E99CDC17B3EB7FA8BA2332 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0B57D382DF764BD00DC5BCC /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -287,7 +283,6 @@
B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
B2EE00052E72CA15008B6CA7 /* Permission */,
FA9973382CF6DF4B000EF859 /* Runner.entitlements */,
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */,
@@ -322,15 +317,6 @@
path = Connectivity;
sourceTree = "<group>";
};
B2EE00052E72CA15008B6CA7 /* Permission */ = {
isa = PBXGroup;
children = (
B2EE00032E72CA15008B6CA7 /* PermissionApiImpl.swift */,
B2EE00012E72CA15008B6CA7 /* PermissionApi.g.swift */,
);
path = Permission;
sourceTree = "<group>";
};
FAC6F8B62D287F120078CB2F /* ShareExtension */ = {
isa = PBXGroup;
children = (
@@ -633,8 +619,6 @@
FE5499F42F1197D8006016CB /* RemoteImages.g.swift in Sources */,
FE5FE4AE2F30FBC000A71243 /* ImageProcessing.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
B2EE00022E72CA15008B6CA7 /* PermissionApi.g.swift in Sources */,
B2EE00042E72CA15008B6CA7 /* PermissionApiImpl.swift in Sources */,
FE5499F82F1198E2006016CB /* RemoteImagesImpl.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
-1
View File
@@ -26,7 +26,6 @@ import native_video_player
public static func registerPlugins(with registry: FlutterPluginRegistry, messenger: FlutterBinaryMessenger) {
NativeSyncApiImpl.register(with: registry.registrar(forPlugin: NativeSyncApiImpl.name)!)
PermissionApiSetup.setUp(binaryMessenger: messenger, api: PermissionApiImpl())
LocalImageApiSetup.setUp(binaryMessenger: messenger, api: LocalImageApiImpl())
RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl())
-106
View File
@@ -1,106 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
import Foundation
#if os(iOS)
import Flutter
#elseif os(macOS)
import FlutterMacOS
#else
#error("Unsupported platform.")
#endif
private func wrapResult(_ result: Any?) -> [Any?] {
return [result]
}
private func wrapError(_ error: Any) -> [Any?] {
if let pigeonError = error as? PigeonError {
return [
pigeonError.code,
pigeonError.message,
pigeonError.details,
]
}
if let flutterError = error as? FlutterError {
return [
flutterError.code,
flutterError.message,
flutterError.details,
]
}
return [
"\(error)",
"\(Swift.type(of: error))",
"Stacktrace: \(Thread.callStackSymbols)",
]
}
private func isNullish(_ value: Any?) -> Bool {
return value is NSNull || value == nil
}
private func nilOrValue<T>(_ value: Any?) -> T? {
if value is NSNull { return nil }
return value as! T?
}
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
protocol PermissionApi {
func hasManageMediaPermission() throws -> Bool
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
class PermissionApiSetup {
static var codec: FlutterStandardMessageCodec { FlutterStandardMessageCodec.sharedInstance() }
/// Sets up an instance of `PermissionApi` to handle messages through the `binaryMessenger`.
static func setUp(binaryMessenger: FlutterBinaryMessenger, api: PermissionApi?, messageChannelSuffix: String = "") {
let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : ""
let hasManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
hasManageMediaPermissionChannel.setMessageHandler { _, reply in
do {
let result = try api.hasManageMediaPermission()
reply(wrapResult(result))
} catch {
reply(wrapError(error))
}
}
} else {
hasManageMediaPermissionChannel.setMessageHandler(nil)
}
let requestManageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
requestManageMediaPermissionChannel.setMessageHandler { _, reply in
api.requestManageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
requestManageMediaPermissionChannel.setMessageHandler(nil)
}
let manageMediaPermissionChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
manageMediaPermissionChannel.setMessageHandler { _, reply in
api.manageMediaPermission { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
manageMediaPermissionChannel.setMessageHandler(nil)
}
}
}
@@ -1,15 +0,0 @@
import Foundation
class PermissionApiImpl: PermissionApi {
func hasManageMediaPermission() throws -> Bool {
return false
}
func requestManageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
func manageMediaPermission(completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
}
-19
View File
@@ -537,7 +537,6 @@ protocol NativeSyncApi {
func hashAssets(assetIds: [String], allowNetworkAccess: Bool, completion: @escaping (Result<[HashResult], Error>) -> Void)
func cancelHashing() throws
func getTrashedAssets() throws -> [String: [PlatformAsset]]
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void)
func getCloudIdForAssetIds(assetIds: [String]) throws -> [CloudIdResult]
}
@@ -722,24 +721,6 @@ class NativeSyncApiSetup {
} else {
getTrashedAssetsChannel.setMessageHandler(nil)
}
let restoreFromTrashByIdChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
restoreFromTrashByIdChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let mediaIdArg = args[0] as! String
let typeArg = args[1] as! Int64
api.restoreFromTrashById(mediaId: mediaIdArg, type: typeArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
restoreFromTrashByIdChannel.setMessageHandler(nil)
}
let getCloudIdForAssetIdsChannel = taskQueue == nil
? FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
: FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec, taskQueue: taskQueue)
@@ -382,10 +382,6 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
func getTrashedAssets() throws -> [String: [PlatformAsset]] {
throw PigeonError(code: "UNSUPPORTED_OS", message: "This feature not supported on iOS.", details: nil)
}
func restoreFromTrashById(mediaId: String, type: Int64, completion: @escaping (Result<Bool, Error>) -> Void) {
completion(.success(false))
}
private func getAssetsFromAlbum(in album: PHAssetCollection, options: PHFetchOptions) -> PHFetchResult<PHAsset> {
// Ensure to actually getting all assets for the Recents album
@@ -9,10 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/datetime_helpers.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart';
@@ -23,29 +23,29 @@ class LocalSyncService {
final DriftLocalAssetRepository _localAssetRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository,
required IPermissionRepository permissionRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository,
_permissionRepository = permissionRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
final hasPermission = await _permissionRepository.hasManageMediaPermission();
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
} else {
@@ -373,7 +373,7 @@ class LocalSyncService {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
@@ -381,15 +381,15 @@ class LocalSyncService {
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_log.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
)..removeWhere((_, assets) => assets.isEmpty);
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
@@ -9,12 +9,12 @@ import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_migration.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:logging/logging.dart';
@@ -34,8 +34,8 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final AssetMediaRepository _assetMediaRepository;
final IPermissionRepository _permissionRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final SyncMigrationRepository _syncMigrationRepository;
final ApiService _api;
final bool Function()? _cancelChecker;
@@ -45,8 +45,8 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required AssetMediaRepository assetMediaRepository,
required IPermissionRepository permissionRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required SyncMigrationRepository syncMigrationRepository,
required ApiService api,
bool Function()? cancelChecker,
@@ -54,8 +54,8 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_assetMediaRepository = assetMediaRepository,
_permissionRepository = permissionRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_syncMigrationRepository = syncMigrationRepository,
_api = api,
_cancelChecker = cancelChecker;
@@ -500,22 +500,22 @@ class SyncStreamService {
}
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
final localIds = localAssetsToTrash.values.expand((assets) => assets).map((asset) => asset.id).toList();
_logger.info("Moving to trash ${localIds.join(", ")} assets");
final movedIds = await _assetMediaRepository.deleteAll(localIds);
if (movedIds.isNotEmpty) {
final movedAssetsByAlbum = localAssetsToTrash.map(
(albumId, assets) => MapEntry(albumId, assets.where((asset) => movedIds.contains(asset.id)).toList()),
)..removeWhere((_, assets) => assets.isEmpty);
await _trashedLocalAssetRepository.trashLocalAsset(movedAssetsByAlbum);
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
Future<void> _applyRemoteRestoreToLocal() async {
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
if (assetsToRestore.isNotEmpty) {
final restoredIds = await _assetMediaRepository.restoreAssetsFromTrash(assetsToRestore);
final restoredIds = await _localFilesManager.restoreAssetsFromTrash(assetsToRestore);
await _trashedLocalAssetRepository.applyRestoredAssets(restoredIds);
} else {
_logger.info("No remote assets found for restoration");
@@ -523,7 +523,7 @@ class SyncStreamService {
}
Future<void> _syncAssetTrashStatus(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset trash status cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
@@ -533,7 +533,7 @@ class SyncStreamService {
}
Future<void> _syncAssetDeletion(List<String> remoteIds) async {
if (!(await _permissionRepository.hasManageMediaPermission())) {
if (!(await _localFilesManager.hasManageMediaPermission())) {
_logger.warning("Syncing asset deletion cannot proceed because MANAGE_MEDIA permission is missing");
return;
}
-19
View File
@@ -654,25 +654,6 @@ class NativeSyncApi {
return (pigeonVar_replyValue! as Map<Object?, Object?>).cast<String, List<PlatformAsset>>();
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.restoreFromTrashById$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[mediaId, type]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<List<CloudIdResult>> getCloudIdForAssetIds(List<String> assetIds) async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.NativeSyncApi.getCloudIdForAssetIds$pigeonVar_messageChannelSuffix';
-119
View File
@@ -1,119 +0,0 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<bool> hasManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.hasManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> requestManageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.requestManageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
Future<bool> manageMediaPermission() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.manageMediaPermission$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as bool;
}
}
@@ -3,10 +3,9 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -17,8 +16,6 @@ final backgroundWorkerLockServiceProvider = Provider<BackgroundWorkerLockService
final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final localImageApi = LocalImageApi();
@@ -11,8 +11,8 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncMigrationRepositoryProvider = Provider((ref) => SyncMigrationRepository(ref.watch(driftProvider)));
@@ -22,8 +22,8 @@ final syncStreamServiceProvider = Provider(
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
syncMigrationRepository: ref.watch(syncMigrationRepositoryProvider),
api: ref.watch(apiServiceProvider),
cancelChecker: ref.watch(cancellationProvider),
@@ -39,8 +39,8 @@ final localSyncServiceProvider = Provider(
localAlbumRepository: ref.watch(localAlbumRepository),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
assetMediaRepository: ref.watch(assetMediaRepositoryProvider),
permissionRepository: ref.watch(permissionRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),
),
);
@@ -8,24 +8,19 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:share_plus/share_plus.dart';
final assetMediaRepositoryProvider = Provider(
(ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider), ref.watch(nativeSyncApiProvider)),
);
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository(ref.watch(assetApiRepositoryProvider)));
class AssetMediaRepository {
final AssetApiRepository _assetApiRepository;
final NativeSyncApi _nativeSyncApi;
static final Logger _log = Logger("AssetMediaRepository");
const AssetMediaRepository(this._assetApiRepository, this._nativeSyncApi);
const AssetMediaRepository(this._assetApiRepository);
Future<bool> _androidSupportsTrash() async {
if (Platform.isAndroid) {
@@ -50,27 +45,6 @@ class AssetMediaRepository {
return PhotoManager.editor.deleteWithIds(ids);
}
Future<bool> _restoreFromTrashById(String mediaId, int type) async {
try {
return await _nativeSyncApi.restoreFromTrashById(mediaId, type);
} catch (e, s) {
_log.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_log.info("Restoring from trash, localId: ${asset.id}, checksum: ${asset.checksum}");
final result = await _restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
}
return restoredIds;
}
Future<AssetEntity?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return entity;
@@ -0,0 +1,51 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/services/local_files_manager.service.dart';
import 'package:logging/logging.dart';
final localFilesManagerRepositoryProvider = Provider(
(ref) => LocalFilesManagerRepository(ref.watch(localFileManagerServiceProvider)),
);
class LocalFilesManagerRepository {
LocalFilesManagerRepository(this._service);
final Logger _logger = Logger('LocalFilesManagerRepo');
final LocalFilesManagerService _service;
Future<bool> moveToTrash(List<String> mediaUrls) async {
return await _service.moveToTrash(mediaUrls);
}
Future<bool> restoreFromTrash(String fileName, int type) async {
return await _service.restoreFromTrash(fileName, type);
}
Future<bool> requestManageMediaPermission() async {
return await _service.requestManageMediaPermission();
}
Future<bool> hasManageMediaPermission() async {
return await _service.hasManageMediaPermission();
}
Future<bool> manageMediaPermission() async {
return await _service.manageMediaPermission();
}
Future<List<String>> restoreAssetsFromTrash(Iterable<LocalAsset> assets) async {
final restoredIds = <String>[];
for (final asset in assets) {
_logger.info("Restoring from trash, localId: ${asset.id}, remoteId: ${asset.checksum}");
try {
final result = await _service.restoreFromTrashById(asset.id, asset.type.index);
if (result) {
restoredIds.add(asset.id);
}
} catch (e) {
_logger.warning("Restoring failure: $e");
}
}
return restoredIds;
}
}
@@ -1,16 +1,12 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart';
final permissionRepositoryProvider = Provider((ref) {
return PermissionRepository(ref.watch(permissionApiProvider));
final permissionRepositoryProvider = Provider((_) {
return const PermissionRepository();
});
class PermissionRepository implements IPermissionRepository {
final PermissionApi _permissionApi;
const PermissionRepository(this._permissionApi);
const PermissionRepository();
@override
Future<bool> hasLocationWhenInUsePermission() {
@@ -38,21 +34,6 @@ class PermissionRepository implements IPermissionRepository {
Future<bool> openSettings() {
return openAppSettings();
}
@override
Future<bool> hasManageMediaPermission() {
return _permissionApi.hasManageMediaPermission();
}
@override
Future<bool> requestManageMediaPermission() {
return _permissionApi.requestManageMediaPermission();
}
@override
Future<bool> manageMediaPermission() {
return _permissionApi.manageMediaPermission();
}
}
abstract interface class IPermissionRepository {
@@ -61,7 +42,4 @@ abstract interface class IPermissionRepository {
Future<bool> hasLocationAlwaysPermission();
Future<bool> requestLocationAlwaysPermission();
Future<bool> openSettings();
Future<bool> hasManageMediaPermission();
Future<bool> requestManageMediaPermission();
Future<bool> manageMediaPermission();
}
@@ -0,0 +1,66 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:logging/logging.dart';
final localFileManagerServiceProvider = Provider<LocalFilesManagerService>((ref) => const LocalFilesManagerService());
class LocalFilesManagerService {
const LocalFilesManagerService();
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash');
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;
}
}
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;
}
}
Future<bool> restoreFromTrashById(String mediaId, int type) async {
try {
return await _channel.invokeMethod('restoreFromTrash', {'mediaId': mediaId, 'type': type});
} catch (e, s) {
_logger.warning('Error restore file from trash by Id', e, s);
return false;
}
}
Future<bool> requestManageMediaPermission() async {
try {
return await _channel.invokeMethod('requestManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission', e, s);
return false;
}
}
Future<bool> hasManageMediaPermission() async {
try {
return await _channel.invokeMethod('hasManageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission state', e, s);
return false;
}
}
Future<bool> manageMediaPermission() async {
try {
return await _channel.invokeMethod('manageMediaPermission');
} catch (e, s) {
_logger.warning('Error requesting manage media permission settings', e, s);
return false;
}
}
}
@@ -22,7 +22,7 @@ import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
@@ -193,7 +193,7 @@ class LoginForm extends HookConsumerWidget {
}
getManageMediaPermission() async {
final hasPermission = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
@@ -224,7 +224,7 @@ class LoginForm extends HookConsumerWidget {
),
TextButton(
onPressed: () {
unawaited(ref.read(permissionRepositoryProvider).requestManageMediaPermission());
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
@@ -10,7 +10,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/metadata.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.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/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
@@ -57,7 +57,9 @@ class AdvancedSettings extends HookConsumerWidget {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref.read(permissionRepositoryProvider).hasManageMediaPermission();
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}();
return null;
@@ -80,7 +82,7 @@ class AdvancedSettings extends HookConsumerWidget {
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(permissionRepositoryProvider).requestManageMediaPermission();
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
@@ -94,7 +96,7 @@ class AdvancedSettings extends HookConsumerWidget {
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(permissionRepositoryProvider).manageMediaPermission();
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
+8 -4
View File
@@ -11,7 +11,14 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset {
final String id;
@@ -135,9 +142,6 @@ abstract class NativeSyncApi {
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
Map<String, List<PlatformAsset>> getTrashedAssets();
@async
bool restoreFromTrashById(String mediaId, int type);
@TaskQueue(type: TaskQueueType.serialBackgroundThread)
List<CloudIdResult> getCloudIdForAssetIds(List<String> assetIds);
}
-23
View File
@@ -1,23 +0,0 @@
import 'package:pigeon/pigeon.dart';
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/permission_api.g.dart',
swiftOut: 'ios/Runner/Permission/PermissionApi.g.swift',
swiftOptions: SwiftOptions(),
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/permission/PermissionApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.permission'),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class PermissionApi {
bool hasManageMediaPermission();
@async
bool requestManageMediaPermission();
@async
bool manageMediaPermission();
}
@@ -10,15 +10,17 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../domain/service.mock.dart';
import '../../fixtures/asset.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
void main() {
@@ -26,8 +28,8 @@ void main() {
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftLocalAssetRepository mockLocalAssetRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late AssetMediaRepository mockAssetMediaRepository;
late MockPermissionRepository mockPermissionRepository;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
late Drift db;
@@ -49,8 +51,8 @@ void main() {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockLocalAssetRepository = MockLocalAssetRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockAssetMediaRepository = MockAssetMediaRepository();
mockPermissionRepository = MockPermissionRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
when(() => mockNativeSyncApi.shouldFullSync()).thenAnswer((_) async => false);
@@ -63,28 +65,25 @@ void main() {
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepository.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
return ids;
});
when(() => mockLocalFilesManager.moveToTrash(any<List<String>>())).thenAnswer((_) async => true);
sut = LocalSyncService(
localAlbumRepository: mockLocalAlbumRepository,
localAssetRepository: mockLocalAssetRepository,
trashedLocalAssetRepository: mockTrashedLocalAssetRepository,
assetMediaRepository: mockAssetMediaRepository,
permissionRepository: mockPermissionRepository,
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
});
group('LocalSyncService - syncTrashedAssets gating', () {
test('invokes syncTrashedAssets when Android flag enabled and permission granted', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -94,7 +93,7 @@ void main() {
test('skips syncTrashedAssets when store flag disabled', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, false);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -103,7 +102,7 @@ void main() {
test('skips syncTrashedAssets when MANAGE_MEDIA permission absent', () async {
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => false);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => false);
await sut.sync();
@@ -115,7 +114,7 @@ void main() {
addTearDown(() => debugDefaultTargetPlatformOverride = TargetPlatform.android);
await Store.put(StoreKey.manageLocalMediaAndroid, true);
when(() => mockPermissionRepository.hasManageMediaPermission()).thenAnswer((_) async => true);
when(() => mockLocalFilesManager.hasManageMediaPermission()).thenAnswer((_) async => true);
await sut.sync();
@@ -132,13 +131,13 @@ void main() {
durationMs: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final assetsToRestore = [LocalAssetStub.image1];
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => assetsToRestore);
final restoredIds = ['image1'];
when(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
when(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requested = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requested, orderedEquals(assetsToRestore));
return restoredIds;
@@ -151,6 +150,10 @@ void main() {
},
);
final assetEntity = MockAssetEntity();
when(() => assetEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-trash');
when(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).thenAnswer((_) async => assetEntity);
await sut.processTrashedAssets({
'album-a': [platformAsset],
});
@@ -165,11 +168,12 @@ void main() {
expect(trashedEntry.asset.name, platformAsset.name);
verify(() => mockTrashedLocalAssetRepository.getToTrash()).called(1);
verify(() => mockAssetMediaRepository.restoreAssetsFromTrash(any())).called(1);
verify(() => mockLocalFilesManager.restoreAssetsFromTrash(any())).called(1);
verify(() => mockTrashedLocalAssetRepository.applyRestoredAssets(restoredIds)).called(1);
final moveArgs = verify(() => mockAssetMediaRepository.deleteAll(captureAny())).captured.single as List<String>;
expect(moveArgs, ['local-trash']);
verify(() => mockStorageRepository.getAssetEntityForAsset(localAssetToTrash)).called(1);
final moveArgs = verify(() => mockLocalFilesManager.moveToTrash(captureAny())).captured.single as List<String>;
expect(moveArgs, ['content://local-trash']);
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
@@ -177,26 +181,6 @@ void main() {
expect(trashArgs['album-a'], [localAssetToTrash]);
});
test('records only local assets that were moved to device trash', () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockTrashedLocalAssetRepository.getToTrash()).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
);
when(() => mockAssetMediaRepository.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
await sut.processTrashedAssets({});
final trashArgs =
verify(() => mockTrashedLocalAssetRepository.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
});
test('does not attempt restore when repository has no assets to restore', () async {
when(() => mockTrashedLocalAssetRepository.getToRestore()).thenAnswer((_) async => []);
@@ -206,7 +190,7 @@ void main() {
verify(() => mockTrashedLocalAssetRepository.processTrashSnapshot(captureAny())).captured.single
as Iterable<TrashedAsset>;
expect(trashedSnapshot, isEmpty);
verifyNever(() => mockAssetMediaRepository.restoreAssetsFromTrash(any()));
verifyNever(() => mockLocalFilesManager.restoreAssetsFromTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.applyRestoredAssets(any()));
});
@@ -215,7 +199,7 @@ void main() {
await sut.processTrashedAssets({});
verifyNever(() => mockAssetMediaRepository.deleteAll(any()));
verifyNever(() => mockLocalFilesManager.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepository.trashLocalAsset(any()));
});
});
@@ -231,7 +215,7 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final localAsset = platformAsset.toLocalAsset();
@@ -12,11 +12,12 @@ import 'package:immich_mobile/domain/services/sync_stream.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
@@ -25,6 +26,7 @@ import '../../api.mocks.dart';
import '../../fixtures/asset.stub.dart';
import '../../fixtures/sync_stream.stub.dart';
import '../../infrastructure/repository.mock.dart';
import '../../mocks/asset_entity.mock.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
@@ -50,8 +52,8 @@ void main() {
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late AssetMediaRepository mockAssetMediaRepo;
late MockPermissionRepository mockPermissionRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late MockApiService mockApi;
late MockServerApi mockServerApi;
late MockSyncMigrationRepository mockSyncMigrationRepo;
@@ -84,8 +86,8 @@ void main() {
mockSyncApiRepo = MockSyncApiRepository();
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockAssetMediaRepo = MockAssetMediaRepository();
mockPermissionRepo = MockPermissionRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
mockApi = MockApiService();
@@ -157,8 +159,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
);
@@ -168,12 +170,10 @@ void main() {
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
hasManageMediaPermission = false;
when(() => mockPermissionRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
return ids;
});
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
when(() => mockLocalFilesManagerRepo.hasManageMediaPermission()).thenAnswer((_) async => hasManageMediaPermission);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((_) async => true);
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((_) async => []);
when(() => mockStorageRepo.getAssetEntityForAsset(any())).thenAnswer((_) async => null);
await Store.put(StoreKey.manageLocalMediaAndroid, false);
});
@@ -241,8 +241,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -282,8 +282,8 @@ void main() {
syncStreamRepository: mockSyncStreamRepo,
localAssetRepository: mockLocalAssetRepo,
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
assetMediaRepository: mockAssetMediaRepo,
permissionRepository: mockPermissionRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
api: mockApi,
syncMigrationRepository: mockSyncMigrationRepo,
@@ -424,10 +424,18 @@ void main() {
return assetsByAlbum;
});
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((invocation) async {
final ids = invocation.positionalArguments.first as List<String>;
expect(ids, unorderedEquals(['local-only', 'merged-local']));
return ids;
final localEntity = MockAssetEntity();
when(() => localEntity.getMediaUrl()).thenAnswer((_) async => 'content://local-only');
when(() => mockStorageRepo.getAssetEntityForAsset(localAsset)).thenAnswer((_) async => localEntity);
final mergedEntity = MockAssetEntity();
when(() => mergedEntity.getMediaUrl()).thenAnswer((_) async => 'content://merged-local');
when(() => mockStorageRepo.getAssetEntityForAsset(mergedAsset)).thenAnswer((_) async => mergedEntity);
when(() => mockLocalFilesManagerRepo.moveToTrash(any())).thenAnswer((invocation) async {
final urls = invocation.positionalArguments.first as List<String>;
expect(urls, unorderedEquals(['content://local-only', 'content://merged-local']));
return true;
});
final events = [
@@ -453,51 +461,10 @@ void main() {
await simulateEvents(events);
final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, unorderedEquals(['album-a', 'album-b']));
expect(trashArgs['album-a'], [localAsset]);
expect(trashArgs['album-b'], [mergedAsset]);
verify(() => mockAssetMediaRepo.deleteAll(any())).called(1);
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(assetsByAlbum)).called(1);
verify(() => mockSyncApiRepo.ack(['asset-remote-only-3'])).called(1);
});
test("records only assets that were moved to device trash", () async {
final movedAsset = LocalAssetStub.image1.copyWith(id: 'moved-local', checksum: 'checksum-moved');
final skippedAsset = LocalAssetStub.image2.copyWith(id: 'skipped-local', checksum: 'checksum-skipped');
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer(
(_) async => {
'album-a': [movedAsset],
'album-b': [skippedAsset],
},
);
when(() => mockAssetMediaRepo.deleteAll(any())).thenAnswer((_) async => ['moved-local']);
final events = [
SyncStreamStub.assetTrashed(
id: 'remote-moved',
checksum: movedAsset.checksum!,
ack: 'asset-remote-moved',
trashedAt: DateTime(2025, 5, 1),
),
SyncStreamStub.assetTrashed(
id: 'remote-skipped',
checksum: skippedAsset.checksum!,
ack: 'asset-remote-skipped',
trashedAt: DateTime(2025, 5, 2),
),
];
await simulateEvents(events);
final trashArgs =
verify(() => mockTrashedLocalAssetRepo.trashLocalAsset(captureAny())).captured.single
as Map<String, List<LocalAsset>>;
expect(trashArgs.keys, ['album-a']);
expect(trashArgs['album-a'], [movedAsset]);
});
test("skips device trashing when no local assets match the remote trash payload", () async {
final events = [
SyncStreamStub.assetTrashed(
@@ -511,7 +478,7 @@ void main() {
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
@@ -527,7 +494,7 @@ void main() {
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verifyNever(() => mockAssetMediaRepo.deleteAll(any()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
@@ -538,7 +505,7 @@ void main() {
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => trashedAssets);
final restoredIds = ['trashed-1'];
when(() => mockAssetMediaRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
when(() => mockLocalFilesManagerRepo.restoreAssetsFromTrash(any())).thenAnswer((invocation) async {
final Iterable<LocalAsset> requestedAssets = invocation.positionalArguments.first as Iterable<LocalAsset>;
expect(requestedAssets, orderedEquals(trashedAssets));
return restoredIds;
+3 -3
View File
@@ -3,17 +3,17 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/auth.repository.dart';
import 'package:immich_mobile/repositories/auth_api.repository.dart';
import 'package:immich_mobile/domain/services/tag.service.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
class MockAssetApiRepository extends Mock implements AssetApiRepository {}
class MockAssetMediaRepository extends Mock implements AssetMediaRepository {}
class MockPermissionRepository extends Mock implements IPermissionRepository {}
class MockAuthApiRepository extends Mock implements AuthApiRepository {}
class MockAuthRepository extends Mock implements AuthRepository {}
class MockLocalFilesManagerRepository extends Mock implements LocalFilesManagerRepository {}
class MockTagService extends Mock implements TagService {}
+8 -1
View File
@@ -20793,7 +20793,14 @@
"description": "Total number of matching assets",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
"type": "integer",
"x-immich-history": [
{
"version": "v3.0.0",
"state": "Deprecated"
}
],
"x-immich-state": "Deprecated"
}
},
"required": [
+2 -2
View File
@@ -88,8 +88,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-plugin-core/dist
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-plugin-core/manifest.json
COPY --from=plugins /app/packages/plugin-core/dist /build/plugins/immich-core-plugin/dist
COPY --from=plugins /app/packages/plugin-core/manifest.json /build/plugins/immich-core-plugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE
+5 -1
View File
@@ -186,7 +186,11 @@ const SearchAlbumResponseSchema = z
const SearchAssetResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching assets'),
total: z
.int()
.min(0)
.describe('Total number of matching assets')
.meta(new HistoryBuilder().deprecated('v3.0.0').getExtensions()),
count: z.int().min(0).describe('Number of assets in this page'),
items: z.array(AssetResponseSchema),
facets: z.array(SearchFacetResponseSchema),
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}
+1 -1
View File
@@ -12,7 +12,7 @@
const { trigger, selectedKey, onClose }: Props = $props();
</script>
<BasicModal title={$t('add_step')} {onClose} size="medium">
<BasicModal title={$t('add_step')} {onClose}>
{#await searchPluginMethods({ trigger })}
<div class="flex w-full place-content-center place-items-center">
<LoadingSpinner />
@@ -38,7 +38,7 @@
</script>
{#if method}
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="medium">
<FormModal title={$t('add_step')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -35,7 +35,7 @@
</script>
{#if method}
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="medium">
<FormModal title={$t('step_details')} {onClose} {onSubmit} disabled={!method} size="small">
<div class="flex items-center justify-between gap-2">
<div class="grow text-start">
<Text fontWeight="medium">{method.title}</Text>
@@ -17,7 +17,7 @@
const onSubmit = () => onClose(selected);
</script>
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="medium">
<FormModal title={$t('trigger')} {onClose} {onSubmit} size="small">
<div class="flex flex-col gap-2">
{#each pluginManager.triggers as item (item.trigger)}
<ListButton selected={selected === item.trigger} onclick={() => (selected = item.trigger)}>
+88 -51
View File
@@ -4,24 +4,26 @@
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
import { getWorkflowActions, getWorkflowsActions, getWorkflowShowSchemaAction } from '$lib/services/workflow.service';
import { getWorkflowForShare, type WorkflowResponseDto } from '@immich/sdk';
import {
Badge,
Button,
Card,
CardBody,
CardDescription,
CardHeader,
CardTitle,
CodeBlock,
Container,
Icon,
IconButton,
MenuItemType,
menuManager,
Text,
VStack,
} from '@immich/ui';
import { mdiClose, mdiDotsVertical, mdiFlashOutline } from '@mdi/js';
import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
@@ -44,6 +46,20 @@
}
};
const getTriggerLabel = (triggerType: string) => {
const labels: Record<string, string> = {
AssetCreate: $t('asset_created'),
PersonRecognized: $t('person_recognized'),
};
return labels[triggerType] || triggerType;
};
const formatTimestamp = (createdAt: string) =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(createdAt));
const showWorkflowMenu = (event: MouseEvent, workflow: WorkflowResponseDto) => {
const { ToggleEnabled, Edit, Delete } = getWorkflowActions($t, workflow);
void menuManager.show({
@@ -76,6 +92,12 @@
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 bg-light px-3 py-1.5 text-sm dark:border-gray-600">
<span class="font-medium text-dark">{title}</span>
</span>
{/snippet}
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<section class="flex place-content-center sm:mx-4">
<Container center size="large" class="pb-28">
@@ -89,77 +111,92 @@
class="mx-auto mt-10"
/>
{:else}
<div class="my-6 flex flex-col gap-3">
<div class="my-6 grid gap-6">
{#each workflows as workflow (workflow.id)}
<Card class="group shadow-none transition-colors hover:border-primary">
<CardHeader>
<a
href={Route.viewWorkflow({ id: workflow.id })}
class="flex items-center gap-4"
class:opacity-55={!workflow.enabled}
>
<div
class={`flex size-11 shrink-0 items-center justify-center rounded-xl ${
workflow.enabled
? 'bg-immich-primary/10 text-immich-primary dark:bg-immich-dark-primary/15 dark:text-immich-dark-primary'
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
}`}
>
<Icon icon={mdiFlashOutline} size="20" />
<Card class="border border-light-200">
<CardHeader
class={`flex flex-row gap-4 px-8 py-6 sm:items-center sm:gap-6 ${
workflow.enabled
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
: 'bg-neutral-50 dark:bg-neutral-900'
}`}
>
<div class="flex-1">
<div class="flex items-center gap-3">
<span class="rounded-full {workflow.enabled ? 'size-3 bg-success' : 'size-3 rounded-full bg-muted'}"
></span>
<CardTitle>{workflow.name || $t('workflow')}</CardTitle>
</div>
{#if workflow.description}
<CardDescription class="mt-1 text-sm">{workflow.description}</CardDescription>
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<CardTitle class="truncate font-semibold text-dark group-hover:text-primary">
{workflow.name || $t('workflow')}
</CardTitle>
{#if !workflow.enabled}
<Badge size="small" color="secondary">
{$t('disabled')}
</Badge>
{/if}
</div>
{#if workflow.description}
<CardDescription class="mt-0.5 truncate">
{workflow.description}
</CardDescription>
{/if}
<div class="flex items-center gap-4">
<div class="hidden text-right sm:block">
<Text size="tiny">{$t('created_at')}</Text>
<Text size="small" fontWeight="medium">
{formatTimestamp(workflow.createdAt)}
</Text>
</div>
<IconButton
shape="round"
variant="ghost"
color="secondary"
icon={mdiDotsVertical}
aria-label={$t('menu')}
onclick={(event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
showWorkflowMenu(event, workflow);
}}
onclick={(event: MouseEvent) => showWorkflowMenu(event, workflow)}
/>
</a>
</div>
</CardHeader>
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.trigger))}
</div>
<!-- Actions Section -->
<div class="rounded-2xl border border-light-200 bg-light-50 p-4">
<div class="mb-3">
<Text size="tiny" color="muted" fontWeight="medium">{$t('steps')}</Text>
</div>
<div>
{#if workflow.steps.length === 0}
<span class="text-sm text-light-600">
{$t('no_steps')}
</span>
{:else}
<div class="flex flex-wrap gap-2">
{#each workflow.steps as step, i (i)}
{@render chipItem(pluginManager.getMethodLabel(step.method))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedIds.has(workflow.id)}
{#await getWorkflowForShare({ id: workflow.id }) then result}
<div class="border-t border-gray-200 p-4 dark:border-gray-800">
<VStack gap={2} class="w-full rounded-2xl border border-light-200 bg-light-50 p-4">
<CodeBlock code={JSON.stringify(result, null, 2)} lineNumbers />
<Button
class="mt-2"
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleExpanded(workflow.id)}
onclick={() => toggleExpanded(workflow.id)}>{$t('close')}</Button
>
{$t('close')}
</Button>
</div>
</VStack>
{/await}
{/if}
</CardHeader>
</CardBody>
</Card>
{/each}
</div>
@@ -1,5 +1,5 @@
<script lang="ts">
import { beforeNavigate, goto, invalidate } from '$app/navigation';
import { goto, invalidate } from '$app/navigation';
import OnEvents from '$lib/components/OnEvents.svelte';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import WorkflowAddStepModal from '$lib/modals/WorkflowAddStepModal.svelte';
@@ -8,7 +8,7 @@
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow';
import type { WorkflowResponseDto, WorkflowStepDto, WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowResponseDto, WorkflowStepDto } from '@immich/sdk';
import {
ActionBar,
AppShell,
@@ -29,40 +29,26 @@
IconButton,
Input,
modalManager,
Stack,
Switch,
Text,
Textarea,
VStack,
type ActionItem,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiCodeJson,
mdiContentSave,
mdiFlashOutline,
mdiFormatListBulletedSquare,
mdiInformationOutline,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { cloneDeep, isEqual } from 'lodash-es';
import { flushSync } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import WorkflowJsonEditor from './WorkflowJsonEditor.svelte';
import WorkflowStepCard from './WorkflowStepCard.svelte';
import WorkflowStepDragImage from './WorkflowStepDragImage.svelte';
import WorkflowSummary from './WorkflowSummary.svelte';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type EditMode = 'visual' | 'json';
type StepDragImage = {
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
type Props = {
data: PageData;
@@ -71,27 +57,6 @@
let { data }: Props = $props();
let { id, enabled, name, description, trigger, steps } = $derived(data.workflow);
let savedWorkflow = $state(cloneDeep(data.workflow));
let allowNavigation = $state(false);
let isShowingNavigationDialog = $state(false);
let isSaving = $state(false);
let editMode = $state<EditMode>('visual');
let draggedIndex = $state<number | null>(null);
let dragHandleHoverIndex = $state<number | null>(null);
let dragImageElement = $state<HTMLElement | null>(null);
let dragImage = $state<StepDragImage>({ isFilter: false, label: '', stepNumber: 1 });
let dropTargetIndex = $state<number | null>(null);
const workflowSummary = $derived({ name, description, trigger, steps });
const workflowJsonContent = $derived<WorkflowJsonContent>({ description, enabled, name, steps, trigger });
const hasChanges = $derived(
enabled !== savedWorkflow.enabled ||
name !== savedWorkflow.name ||
description !== savedWorkflow.description ||
!isEqual(trigger, savedWorkflow.trigger) ||
!isEqual(steps, savedWorkflow.steps),
);
const handleAddStep = async () => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
@@ -100,90 +65,13 @@
}
};
const handleInsertStep = async (index: number) => {
const step = await modalManager.show(WorkflowAddStepModal, { trigger });
if (step) {
steps = [...steps.slice(0, index), step, ...steps.slice(index)];
}
};
const replaceStep = (index: number, step: WorkflowStepDto) => {
steps = steps.map((current, i) => (i === index ? cloneDeep(step) : current));
};
const handleEditStep = async (index: number) => {
const step = steps[index];
if (!step) {
return;
}
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) });
const handleEditStep = async (step: WorkflowStepDto) => {
const result = await modalManager.show(WorkflowEditStepModal, { trigger, step });
if (result) {
replaceStep(index, result);
Object.assign(step, result);
}
};
const handleDragStart = (index: number, event: DragEvent) => {
draggedIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(index));
const step = steps[index];
const method = step ? pluginManager.getMethod(step.method) : undefined;
dragImage = {
description: method?.description,
isFilter: method?.uiHints?.includes('filter') ?? false,
label: step ? pluginManager.getMethodLabel(step.method) : '',
stepNumber: index + 1,
};
flushSync();
if (dragImageElement) {
event.dataTransfer.setDragImage(dragImageElement, 16, 22);
}
}
};
const handleDragOver = (index: number, event: DragEvent) => {
if (draggedIndex === null) {
return;
}
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
if (dropTargetIndex !== index) {
dropTargetIndex = index;
}
};
const handleDragLeave = (index: number) => {
if (dropTargetIndex === index) {
dropTargetIndex = null;
}
};
const handleDrop = (index: number, event: DragEvent) => {
event.preventDefault();
const from = draggedIndex;
draggedIndex = null;
dropTargetIndex = null;
if (from === null || from === index) {
return;
}
const next = [...steps];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
steps = next;
};
const handleDragEnd = () => {
draggedIndex = null;
dragHandleHoverIndex = null;
dropTargetIndex = null;
};
const handleDeleteStep = async (index: number) => {
const confirmed = await modalManager.showDialog({ title: $t('step_delete'), prompt: $t('step_delete_confirm') });
if (confirmed) {
@@ -192,16 +80,11 @@
}
};
const handleJsonContentChange = (content: WorkflowJsonContent) => {
enabled = content.enabled;
name = content.name;
description = content.description;
trigger = content.trigger;
steps = cloneDeep(content.steps);
const onClose = async () => {
// check for pending changes
await goto(Route.workflows());
};
const onClose = () => goto(Route.workflows());
const onChangeTrigger = async () => {
const newTrigger = await modalManager.show(WorkflowTriggerPicker, { selected: trigger });
if (newTrigger) {
@@ -212,228 +95,163 @@
const onWorkflowUpdate = async (response: WorkflowResponseDto) => {
if (id === response.id) {
data.workflow = response;
savedWorkflow = cloneDeep(response);
await invalidate('workflow:data');
}
};
const confirmNavigation = async () => {
if (!hasChanges) {
return true;
}
if (isShowingNavigationDialog) {
return false;
}
try {
isShowingNavigationDialog = true;
return await modalManager.showDialog({
prompt: $t('workflow_navigation_prompt'),
confirmColor: 'primary',
});
} finally {
isShowingNavigationDialog = false;
}
const Done: ActionItem = {
title: $t('save'),
icon: mdiContentSave,
color: 'primary',
onAction: () => handleUpdateWorkflow(id, { enabled, name, description, trigger, steps }),
};
const saveWorkflow = async () => {
if (!hasChanges || isSaving) {
return;
}
isSaving = true;
try {
const submitted = { enabled, name, description, trigger, steps: cloneDeep(steps) };
const saved = await handleUpdateWorkflow(id, submitted);
if (saved) {
Object.assign(savedWorkflow, submitted);
}
} finally {
isSaving = false;
}
};
beforeNavigate(({ cancel, to, willUnload }) => {
if (!hasChanges || allowNavigation) {
return;
}
cancel();
if (willUnload || !to) {
return;
}
void confirmNavigation().then((confirmed) => {
if (confirmed) {
allowNavigation = true;
void goto(to.url);
}
});
});
</script>
<OnEvents {onWorkflowUpdate} />
<AppShell class="">
<AppShell>
<AppShellBar>
<ActionBar static {onClose} translations={{ close: $t('back') }} closeIcon={mdiArrowLeft}>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>
<ControlBarDescription>{data.workflow.description}</ControlBarDescription>
</ControlBarHeader>
<ControlBarContent class="flex items-center justify-end gap-6">
<div class="flex gap-1 rounded-full border border-light-200 bg-light p-1" role="group">
<Button
variant={editMode === 'visual' ? 'filled' : 'ghost'}
color={editMode === 'visual' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiFormatListBulletedSquare}
aria-pressed={editMode === 'visual'}
onclick={() => (editMode = 'visual')}
shape="round"
>
{$t('visual')}
</Button>
<Button
variant={editMode === 'json' ? 'filled' : 'ghost'}
color={editMode === 'json' ? 'primary' : 'secondary'}
size="small"
leadingIcon={mdiCodeJson}
aria-pressed={editMode === 'json'}
onclick={() => (editMode = 'json')}
shape="round"
>
JSON
</Button>
</div>
<Button
variant="filled"
size="small"
color="primary"
leadingIcon={mdiContentSave}
disabled={!hasChanges || isSaving}
loading={isSaving}
onclick={saveWorkflow}
>
{$t('save')}
</Button>
<ControlBarContent class="flex justify-end">
<HeaderActionButton action={Done} variant="filled" />
</ControlBarContent>
</ActionBar>
</AppShellBar>
<Container size="medium" class="pt-8 pb-24" center>
<VStack gap={4}>
{#if editMode === 'visual'}
<Card class="shadow-none" expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>
{$t('workflow_info')}
</CardTitle>
</div>
</CardHeader>
</div>
</CardHeader>
<CardBody>
<VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} />
</Field>
</div>
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
<CardBody>
<VStack gap={4}>
<div class="relative w-full overflow-hidden rounded-xl border p-4" class:bg-primary-50={enabled}>
<Field label={enabled ? $t('enabled') : $t('disabled')} color={enabled ? 'primary' : 'secondary'}>
<Switch bind:checked={enabled} />
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
</div>
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card class="shadow-none">
<CardHeader>
<div class="flex items-center gap-3">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-success-50">
<Icon icon={mdiFlashOutline} size="20" class="text-success" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">{getTriggerName($t, trigger)}</CardTitle>
<CardDescription class="truncate">{getTriggerDescription($t, trigger)}</CardDescription>
</div>
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={onChangeTrigger}
<Field label={$t('name')} required>
<Input
placeholder={$t('workflow_name')}
bind:value={() => name ?? '', (value) => (name = value || null)}
/>
</Field>
<Field label={$t('description')} for="workflow-description">
<Textarea
id="workflow-description"
grow
placeholder={$t('workflow_description')}
bind:value={() => description ?? '', (value) => (description = value || null)}
/>
</Field>
</VStack>
</CardBody>
</Card>
<div class="my-4 h-px w-[98%] bg-light-200"></div>
<Card>
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-success" />
<div class="flex grow flex-col">
<CardTitle class="text-left text-success">{$t('trigger')}</CardTitle>
<CardDescription>{$t('trigger_description')}</CardDescription>
</div>
</CardHeader>
</Card>
<div class="flex items-center justify-end">
<Button leadingIcon={mdiPencilOutline} size="small" color="secondary" onclick={onChangeTrigger}>
{$t('edit')}
</Button>
</div>
</div>
</CardHeader>
{#each steps as step, index (index)}
<WorkflowStepCard
{step}
{index}
isDragging={draggedIndex === index}
isDragHandleHovered={dragHandleHoverIndex === index}
isDropTarget={dropTargetIndex === index && draggedIndex !== null && draggedIndex !== index}
onEdit={handleEditStep}
onDelete={handleDeleteStep}
onInsertBefore={handleInsertStep}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onDragHandleEnter={(i) => (dragHandleHoverIndex = i)}
onDragHandleLeave={() => (dragHandleHoverIndex = null)}
/>
{/each}
<CardBody>
<div class="flex flex-col items-start">
<Text>{getTriggerName($t, trigger)}</Text>
<Text size="small" color="muted">{getTriggerDescription($t, trigger)}</Text>
</div>
</CardBody>
</Card>
<Button
size="small"
fullWidth
variant="ghost"
leadingIcon={mdiPlus}
class="border border-dashed"
onclick={handleAddStep}
>
{$t('add_step')}
</Button>
{:else}
<WorkflowJsonEditor jsonContent={workflowJsonContent} onContentChange={handleJsonContentChange} />
{/if}
<Card>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFormatListBulletedSquare} size="20" class="mt-1 text-primary" />
<CardTitle class="text-left text-primary">{$t('steps')}</CardTitle>
</div>
</CardHeader>
<CardBody>
{#if steps.length === 0}
<Button leadingIcon={mdiPlus} onclick={handleAddStep}>{$t('add_step')}</Button>
{:else}
<Stack gap={2}>
{#each steps as step, index (index)}
{@const method = pluginManager.getMethod(step.method)}
{#if index > 0}
<hr />
{/if}
<div
// {@attach dragAndDrop({
// index,
// onDragStart: handleFilterDragStart,
// onDragEnter: handleFilterDragEnter,
// onDrop: handleFilterDrop,
// onDragEnd: handleFilterDragEnd,
// isDragging: draggedIndex === index,
// isDragOver: dragOverIndex === index,
// })}
class="flex cursor-move justify-between gap-2 rounded-2xl border-2 border-dashed bg-light-50 p-4 transition-all hover:border-light-300"
>
<div class="flex flex-col gap-1">
<Text>{pluginManager.getMethodLabel(step.method)}</Text>
{#if method?.description}
<Text color="muted" size="small">{method.description}</Text>
{/if}
</div>
<div class="flex gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
onclick={() => handleEditStep(step)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
onclick={() => handleDeleteStep(index)}
/>
</div>
</div>
{/each}
<Button size="small" fullWidth variant="ghost" leadingIcon={mdiPlus} onclick={handleAddStep}>
{$t('add_step')}
</Button>
</Stack>
{/if}
</CardBody>
</Card>
</VStack>
</Container>
<WorkflowStepDragImage
bind:ref={dragImageElement}
description={dragImage.description}
isFilter={dragImage.isFilter}
label={dragImage.label}
stepNumber={dragImage.stepNumber}
/>
<WorkflowSummary workflow={workflowSummary} />
</AppShell>
@@ -1,4 +1,4 @@
import { getWorkflow } from '@immich/sdk';
import { searchWorkflows } from '@immich/sdk';
import { redirect } from '@sveltejs/kit';
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { Route } from '$lib/route';
@@ -8,7 +8,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url, params, depends }) => {
await authenticate(url);
const [workflow] = await Promise.all([getWorkflow({ id: params.workflowId }), pluginManager.ready()]);
const [[workflow]] = await Promise.all([searchWorkflows({ id: params.workflowId }), pluginManager.ready()]);
const $t = await getFormatter();
if (!workflow) {
@@ -1,6 +1,7 @@
<script lang="ts">
import { WorkflowTrigger, type WorkflowStepDto, type WorkflowUpdateDto } from '@immich/sdk';
import type { WorkflowResponseDto } from '@immich/sdk';
import {
Button,
Card,
CardBody,
CardDescription,
@@ -12,91 +13,40 @@
VStack,
} from '@immich/ui';
import { mdiCodeJson } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { untrack } from 'svelte';
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
import { t } from 'svelte-i18n';
type WorkflowJsonContent = Required<
Pick<WorkflowUpdateDto, 'description' | 'enabled' | 'name' | 'steps' | 'trigger'>
>;
type Props = {
jsonContent: WorkflowJsonContent;
onContentChange: (content: WorkflowJsonContent) => void;
jsonContent: WorkflowResponseDto;
onApply: () => void;
onContentChange: (content: WorkflowResponseDto) => void;
};
let { jsonContent, onContentChange }: Props = $props();
let { jsonContent, onApply, onContentChange }: Props = $props();
let content: Content = $state({ json: jsonContent });
let content: Content = $derived({ json: jsonContent });
let canApply = $state(false);
let editorClass = $derived(themeManager.value === Theme.Dark ? 'jse-theme-dark' : '');
const isWorkflowStep = (value: unknown): value is WorkflowStepDto => {
if (!value || typeof value !== 'object') {
return false;
}
const step = value as Partial<WorkflowStepDto>;
return (
typeof step.method === 'string' &&
(step.config === null || (typeof step.config === 'object' && !Array.isArray(step.config))) &&
(step.enabled === undefined || typeof step.enabled === 'boolean')
);
};
const isWorkflowJsonContent = (value: unknown): value is WorkflowJsonContent => {
if (!value || typeof value !== 'object') {
return false;
}
const workflow = value as Partial<WorkflowJsonContent>;
return (
typeof workflow.enabled === 'boolean' &&
(workflow.name === null || typeof workflow.name === 'string') &&
(workflow.description === null || typeof workflow.description === 'string') &&
Object.values(WorkflowTrigger).includes(workflow.trigger as WorkflowTrigger) &&
Array.isArray(workflow.steps) &&
workflow.steps.every(isWorkflowStep)
);
};
const parseContent = (updated: Content) => {
if ('json' in updated) {
return updated.json;
}
return JSON.parse(updated.text);
};
$effect(() => {
const nextContent = jsonContent;
let isSynced = false;
try {
isSynced = isEqual(
untrack(() => parseContent(content)),
nextContent,
);
} catch {
// The editor can be temporarily invalid while typing in text mode.
}
if (!isSynced) {
content = { json: nextContent };
}
});
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
if (status.contentErrors) {
return;
}
const parsed = parseContent(updated);
if (!isWorkflowJsonContent(parsed)) {
return;
}
canApply = true;
onContentChange(parsed);
if ('text' in updated && updated.text !== undefined) {
try {
const parsed = JSON.parse(updated.text);
onContentChange(parsed);
} catch (error_) {
console.error('Invalid JSON in text mode:', error_);
}
}
};
const handleApply = () => {
onApply();
canApply = false;
};
</script>
@@ -107,16 +57,17 @@
<div class="flex items-start gap-3">
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
<div class="flex flex-col">
<CardTitle>{$t('workflow_json')}</CardTitle>
<CardDescription>{$t('workflow_json_help')}</CardDescription>
<CardTitle>Workflow JSON</CardTitle>
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
</div>
</div>
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
</div>
</CardHeader>
<CardBody>
<VStack gap={2}>
<div class="h-[600px] w-full overflow-hidden rounded-lg border {editorClass}">
<JSONEditor bind:content onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
</div>
</VStack>
</CardBody>
@@ -1,195 +0,0 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import type { WorkflowStepDto } from '@immich/sdk';
import { Badge, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, IconButton } from '@immich/ui';
import {
mdiAutoFix,
mdiDragVertical,
mdiFilterVariant,
mdiPencilOutline,
mdiPlus,
mdiTrashCanOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
type Props = {
step: WorkflowStepDto;
index: number;
isDragging: boolean;
isDragHandleHovered: boolean;
isDropTarget: boolean;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onInsertBefore: (index: number) => void;
onDragStart: (index: number, event: DragEvent) => void;
onDragEnd: () => void;
onDragOver: (index: number, event: DragEvent) => void;
onDragLeave: (index: number) => void;
onDrop: (index: number, event: DragEvent) => void;
onDragHandleEnter: (index: number) => void;
onDragHandleLeave: () => void;
};
let {
step,
index,
isDragging,
isDragHandleHovered,
isDropTarget,
onEdit,
onDelete,
onInsertBefore,
onDragStart,
onDragEnd,
onDragOver,
onDragLeave,
onDrop,
onDragHandleEnter,
onDragHandleLeave,
}: Props = $props();
const method = $derived(pluginManager.getMethod(step.method));
const isFilter = $derived(method?.uiHints?.includes('filter') ?? false);
const configEntries = $derived(
Object.entries(step.config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== ''),
);
const truncate = (input: string, max = 24) => (input.length > max ? input.slice(0, max - 1) + '…' : input);
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'on' : 'off';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${truncate(value)}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return $t('none');
}
const items = value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v)));
const joined = items.join(' · ');
if (joined.length <= 28) {
return `"${joined}"`;
}
return $t('items_count', { values: { count: value.length } });
}
return '{…}';
};
</script>
<div class="group/step-row flex w-full flex-col">
<div class="-mt-4 ml-18 flex w-full items-center gap-4">
<div class="relative flex w-1 shrink-0 justify-start">
<div class="h-10 w-0.5 bg-light-200"></div>
<button
type="button"
class="absolute top-1/2 left-1/2 z-10 -translate-1/2 cursor-pointer rounded-full border border-dashed border-primary-200 bg-light p-0.5 text-primary opacity-0 transition-opacity group-hover/step-row:opacity-100 hover:bg-primary-50"
aria-label={$t('add_step')}
title={$t('add_step')}
onclick={() => onInsertBefore(index)}
>
<Icon icon={mdiPlus} size="14" />
</button>
</div>
</div>
<div
class="w-full transition-all"
class:opacity-40={isDragging}
class:scale-[0.99]={isDragging}
ondragover={(event) => onDragOver(index, event)}
ondragleave={() => onDragLeave(index)}
ondrop={(event) => onDrop(index, event)}
role="listitem"
>
<Card
class="shadow-none transition-colors {isDropTarget
? 'border-primary ring-2 ring-primary-200'
: isDragHandleHovered
? 'border-dashed border-primary'
: ''}"
>
<CardHeader>
<div class="flex items-center gap-2">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex shrink-0 cursor-grab items-center justify-center rounded-md border border-transparent p-1 text-light-400 select-none hover:border-primary-200 hover:bg-primary-50 hover:text-primary active:cursor-grabbing"
aria-label={$t('drag_to_reorder')}
draggable="true"
onmouseenter={() => onDragHandleEnter(index)}
onmouseleave={onDragHandleLeave}
ondragstart={(event) => onDragStart(index, event)}
ondragend={onDragEnd}
title={$t('drag_to_reorder')}
>
<Icon icon={mdiDragVertical} size="20" />
</div>
<div
class="flex size-10 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="20"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="flex min-w-0 flex-1 flex-col">
<CardTitle class="truncate">
<span class="mr-1 font-bold text-light-500">{index + 1}</span>
{pluginManager.getMethodLabel(step.method)}
</CardTitle>
{#if method?.description}
<CardDescription class="truncate">{method.description}</CardDescription>
{/if}
</div>
<div class="flex shrink-0 items-center gap-1">
<IconButton
icon={mdiPencilOutline}
aria-label={$t('edit')}
variant="ghost"
shape="round"
color="secondary"
size="small"
onclick={() => onEdit(index)}
/>
<IconButton
icon={mdiTrashCanOutline}
aria-label={$t('delete')}
variant="ghost"
shape="round"
color="danger"
size="small"
onclick={() => onDelete(index)}
/>
</div>
</div>
</CardHeader>
{#if configEntries.length > 0}
<CardBody class="py-3">
<div class="flex flex-wrap items-center gap-1.5">
{#each configEntries as [key, value] (key)}
<Badge
color={isFilter ? 'info' : 'warning'}
shape="round"
size="small"
class="border font-mono {isFilter ? 'border-primary-200' : 'border-warning-200'}"
>
<span class="opacity-60">{key}</span>{formatConfigValue(value)}
</Badge>
{/each}
</div>
</CardBody>
{/if}
</Card>
</div>
</div>
@@ -1,43 +0,0 @@
<script lang="ts">
import { Icon } from '@immich/ui';
import { mdiAutoFix, mdiFilterVariant } from '@mdi/js';
type Props = {
ref?: HTMLElement | null;
description?: string;
isFilter: boolean;
label: string;
stepNumber: number;
};
let { ref = $bindable(null), description, isFilter, label, stepNumber }: Props = $props();
</script>
<div
bind:this={ref}
aria-hidden="true"
class="pointer-events-none fixed top-[-1000px] left-0 flex w-80 items-center gap-2.5 rounded-lg border border-light-200 bg-light px-3 py-2.5 text-sm/5 text-dark shadow-2xl"
>
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg"
class:bg-primary-50={isFilter}
class:bg-warning-50={!isFilter}
>
<Icon
icon={isFilter ? mdiFilterVariant : mdiAutoFix}
size="18"
class={isFilter ? 'text-primary' : 'text-warning'}
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 font-bold text-light-500">#{stepNumber}</span>
<span class="truncate font-bold">{label}</span>
</div>
{#if description}
<div class="mt-0.5 truncate text-xs/4 text-light-500">{description}</div>
{/if}
</div>
</div>
@@ -1,176 +1,137 @@
<script lang="ts">
import { pluginManager } from '$lib/managers/plugin-manager.svelte';
import { getTriggerName } from '$lib/utils/workflow';
import type { WorkflowStepDto, WorkflowTrigger } from '@immich/sdk';
import type { WorkflowResponseDto } from '@immich/sdk';
import { Icon, IconButton, Text } from '@immich/ui';
import { mdiCheck, mdiClose, mdiContentCopy, mdiViewDashboardOutline } from '@mdi/js';
import { mdiClose, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
type WorkflowSummaryData = {
name: string | null;
description: string | null;
trigger: WorkflowTrigger;
steps: WorkflowStepDto[];
};
type Props = {
workflow: WorkflowSummaryData;
workflow: WorkflowResponseDto;
};
let { workflow }: Props = $props();
const { trigger, steps } = $derived(workflow);
let isOpen = $state(false);
let justCopied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
let panelElement = $state<HTMLElement | undefined>(undefined);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
$effect(() => {
if (!isOpen) {
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
event.preventDefault();
isOpen = false;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
const handlePointerDown = (event: PointerEvent) => {
if (panelElement && event.target instanceof Node && !panelElement.contains(event.target)) {
isOpen = false;
}
};
document.addEventListener('keydown', handleKeydown, { capture: true });
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('keydown', handleKeydown, { capture: true });
document.removeEventListener('pointerdown', handlePointerDown);
};
});
const formatConfigValue = (value: unknown): string => {
if (value === null || value === undefined) {
return '—';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'string') {
return `"${value}"`;
}
if (Array.isArray(value)) {
if (value.length === 0) {
return '[]';
}
return '[' + value.map((v) => (v !== null && typeof v === 'object' ? '{…}' : String(v))).join(', ') + ']';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const getConfigEntries = (config: WorkflowStepDto['config']) =>
Object.entries(config ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '');
const asciiSummary = $derived.by(() => {
const lines: string[] = [];
const title = workflow.name ?? $t('no_name');
lines.push(`${title}`);
if (workflow.description) {
lines.push(workflow.description);
}
lines.push('', ' WHEN', ` ⚡ ${getTriggerName($t, workflow.trigger)}`, '', ' THEN');
if (workflow.steps.length === 0) {
lines.push(` ${$t('no_steps')}`);
return lines.join('\n');
}
for (const [i, step] of workflow.steps.entries()) {
const method = pluginManager.getMethod(step.method);
const isFilter = method?.uiHints?.includes('filter') ?? false;
const type = isFilter ? $t('filter') : $t('action');
const label = pluginManager.getMethodLabel(step.method);
lines.push(` [${i + 1}] ${type.toUpperCase()} · ${label}`);
for (const [key, value] of getConfigEntries(step.config)) {
lines.push(` ${key} = ${formatConfigValue(value)}`);
}
if (i < workflow.steps.length - 1) {
lines.push('');
}
}
return lines.join('\n');
});
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(asciiSummary);
justCopied = true;
if (copyTimer) {
clearTimeout(copyTimer);
}
copyTimer = setTimeout(() => (justCopied = false), 1500);
} catch {
// ignore — clipboard may be unavailable
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
}
});
</script>
{#if isOpen}
<aside
bind:this={panelElement}
class="fixed inset-y-20 right-4 bottom-4 hidden max-w-lg flex-col overflow-hidden rounded-2xl border border-light-200 bg-light shadow-2xl sm:flex"
transition:fly={{ x: 400, duration: 250 }}
aria-label={$t('workflow_summary')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="fixed hidden w-64 select-none hover:cursor-grab sm:block"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-light-200 px-4 py-2.5">
<Text size="small" fontWeight="semi-bold" color="muted">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={justCopied ? mdiCheck : mdiContentCopy}
size="small"
variant="ghost"
color={justCopied ? 'success' : 'secondary'}
title={$t('copy_to_clipboard')}
aria-label={$t('copy_to_clipboard')}
onclick={handleCopy}
/>
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={() => (isOpen = false)}
/>
<div
class="rounded-xl border-2 border-transparent bg-light-50 p-4 shadow-sm transition-all hover:border-dashed hover:border-light-300 hover:shadow-xl"
>
<div class="mb-4 flex cursor-grab items-center justify-between select-none">
<Text size="small" fontWeight="semi-bold">{$t('workflow_summary')}</Text>
<div class="flex items-center gap-1">
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-1 flex items-center gap-2">
<Icon icon={mdiFlashOutline} size="18" class="text-primary" />
<Text size="tiny" fontWeight="semi-bold">{$t('trigger')}</Text>
</div>
<p class="truncate pl-5 text-sm">{getTriggerName($t, trigger)}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="h-3 w-0.5 bg-light-400"></div>
</div>
<!-- Steps -->
{#if steps.length > 0}
<div class="rounded-lg border bg-light-100 p-3">
<div class="mb-2 flex items-center gap-2">
<Icon icon={mdiPlayCircleOutline} size="18" class="text-success" />
<Text size="tiny" fontWeight="semi-bold">{$t('actions')}</Text>
</div>
<div class="space-y-1 pl-5">
{#each steps as step, index (index)}
<div class="flex items-center gap-2">
<span
class="flex size-4 shrink-0 items-center justify-center rounded-full bg-light-200 text-[10px] font-medium"
>{index + 1}</span
>
<p class="truncate text-sm">{step.method}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- ASCII body — what you see is what you copy -->
<div class="flex-1 overflow-auto p-4">
<pre
class="m-0 overflow-auto rounded-lg border border-light-200 bg-light-100 px-4 py-3 font-mono text-xs/relaxed whitespace-pre">{asciiSummary}</pre>
</div>
</aside>
</div>
{:else}
<button
type="button"
class="fixed right-6 bottom-6 hidden size-14 items-center justify-center rounded-full bg-primary text-light shadow-lg transition-colors hover:bg-primary/90 sm:flex"
title={$t('workflow_summary')}
aria-label={$t('workflow_summary')}
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />