From 3d075f2bf8bf3bdb17bef54fe4d8430f28fd2b6f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 18 May 2026 11:09:33 -0400 Subject: [PATCH] feat: workflows & plugins (#26727) feat: plugins chore: better types feat: plugins --- .../server/container-compose-overrides.yml | 2 +- .github/workflows/test.yml | 24 +- docker/docker-compose.dev.yml | 2 +- docs/docs/developer/testing.md | 2 +- i18n/en.json | 20 +- mise.toml | 13 +- mobile/openapi/README.md | 28 +- mobile/openapi/lib/api.dart | 20 +- mobile/openapi/lib/api/plugins_api.dart | 157 ++- mobile/openapi/lib/api/workflows_api.dart | 170 ++- mobile/openapi/lib/api_client.dart | 40 +- mobile/openapi/lib/api_helper.dart | 15 +- mobile/openapi/lib/model/job_name.dart | 6 +- .../lib/model/plugin_action_response_dto.dart | 158 --- .../lib/model/plugin_context_type.dart | 88 -- .../lib/model/plugin_filter_response_dto.dart | 158 --- .../openapi/lib/model/plugin_json_schema.dart | 158 --- .../model/plugin_json_schema_property.dart | 195 ---- ...schema_property_additional_properties.dart | 195 ---- .../lib/model/plugin_json_schema_type.dart | 100 -- .../lib/model/plugin_method_response_dto.dart | 172 +++ .../lib/model/plugin_response_dto.dart | 29 +- .../model/plugin_trigger_response_dto.dart | 107 -- .../lib/model/workflow_action_item_dto.dart | 107 -- .../model/workflow_action_response_dto.dart | 142 --- .../lib/model/workflow_create_dto.dart | 60 +- .../lib/model/workflow_filter_item_dto.dart | 107 -- .../model/workflow_filter_response_dto.dart | 142 --- .../lib/model/workflow_response_dto.dart | 69 +- .../model/workflow_share_response_dto.dart | 134 +++ .../lib/model/workflow_share_step_dto.dart | 131 +++ .../openapi/lib/model/workflow_step_dto.dart | 131 +++ ...rigger_type.dart => workflow_trigger.dart} | 44 +- .../model/workflow_trigger_response_dto.dart | 108 ++ mobile/openapi/lib/model/workflow_type.dart | 85 ++ .../lib/model/workflow_update_dto.dart | 53 +- open-api/immich-openapi-specs.json | 975 +++++++++--------- packages/plugin-core/.gitignore | 2 + packages/plugin-core/.prettierrc | 6 + packages/plugin-core/esbuild.js | 11 + packages/plugin-core/manifest.json | 258 +++++ packages/plugin-core/mise.toml | 6 + .../{plugins => plugin-core}/package.json | 3 +- packages/plugin-core/src/index.d.ts | 19 + packages/plugin-core/src/index.ts | 111 ++ .../{plugins => plugin-core}/tsconfig.json | 26 +- packages/plugin-sdk/.gitignore | 2 + packages/plugin-sdk/esbuild.js | 11 + packages/plugin-sdk/package.json | 38 + packages/plugin-sdk/src/enum.ts | 33 + packages/plugin-sdk/src/host-functions.ts | 51 + packages/plugin-sdk/src/index.ts | 4 + packages/plugin-sdk/src/sdk.ts | 43 + packages/plugin-sdk/src/types.ts | 129 +++ packages/plugin-sdk/tsconfig.json | 26 + packages/plugins/.gitignore | 2 - packages/plugins/LICENSE | 26 - packages/plugins/esbuild.js | 12 - packages/plugins/manifest.json | 159 --- packages/plugins/mise.toml | 11 - packages/plugins/package-lock.json | 533 ---------- packages/plugins/src/index.d.ts | 12 - packages/plugins/src/index.ts | 71 -- packages/sdk/src/fetch-client.ts | 291 +++--- pnpm-lock.yaml | 76 +- pnpm-workspace.yaml | 1 + server/Dockerfile | 25 +- server/mise.toml | 2 + server/package.json | 2 + .../src/controllers/plugin.controller.spec.ts | 56 + server/src/controllers/plugin.controller.ts | 39 +- .../controllers/workflow.controller.spec.ts | 113 ++ server/src/controllers/workflow.controller.ts | 47 +- server/src/database.ts | 89 +- server/src/decorators.ts | 4 + server/src/dtos/json-schema.dto.ts | 32 + server/src/dtos/plugin-manifest.dto.ts | 51 +- server/src/dtos/plugin.dto.ts | 108 +- server/src/dtos/workflow.dto.ts | 162 +-- server/src/enum.ts | 23 +- server/src/plugins.ts | 17 - server/src/queries/plugin.repository.sql | 230 ++--- server/src/queries/workflow.repository.sql | 137 ++- server/src/repositories/config.repository.ts | 2 +- server/src/repositories/logging.repository.ts | 4 + server/src/repositories/plugin.repository.ts | 368 ++++--- server/src/repositories/storage.repository.ts | 37 +- .../src/repositories/workflow.repository.ts | 267 ++--- server/src/schema/index.ts | 18 +- .../schema/migrations/1773175313374-Test.ts | 9 + .../1778614946174-UpdateWorkflowTables.ts | 83 ++ server/src/schema/tables/asset-exif.table.ts | 2 +- .../src/schema/tables/plugin-method.table.ts | 35 + server/src/schema/tables/plugin.table.ts | 71 +- .../src/schema/tables/workflow-step.table.ts | 26 + server/src/schema/tables/workflow.table.ts | 62 +- server/src/services/base.service.ts | 61 ++ server/src/services/index.ts | 2 + server/src/services/plugin-host.functions.ts | 120 --- server/src/services/plugin.service.ts | 313 +----- .../services/workflow-execution.service.ts | 344 ++++++ server/src/services/workflow.service.ts | 179 ++-- server/src/types.ts | 46 +- server/src/types/plugin-schema.types.ts | 56 - server/src/utils/workflow.spec.ts | 36 + server/src/utils/workflow.ts | 68 ++ server/test/medium.factory.ts | 13 +- .../specs/services/plugin.service.spec.ts | 297 +++--- .../specs/services/workflow.service.spec.ts | 691 +------------ .../workflow/workflow-core-plugin.spec.ts | 287 ++++++ .../repositories/config.repository.mock.ts | 2 +- .../repositories/storage.repository.mock.ts | 3 +- server/test/utils.ts | 2 +- .../lib/components/HeaderActionButton.svelte | 7 +- .../lib/components/SchemaAlbumPicker.svelte | 59 ++ .../lib/components/SchemaConfiguration.svelte | 99 ++ .../components/album-page/AlbumCard.svelte | 2 +- .../album-page/AlbumThumbnail.svelte | 50 + web/src/lib/managers/plugin-manager.svelte.ts | 84 ++ .../lib/modals/AddWorkflowStepModal.svelte | 80 -- web/src/lib/modals/AlbumPickerModal.svelte | 4 +- web/src/lib/modals/PluginMethodPicker.svelte | 41 + .../lib/modals/WorkflowAddStepModal.svelte | 75 ++ .../lib/modals/WorkflowEditStepModal.svelte | 67 ++ web/src/lib/modals/WorkflowEditTrigger.svelte | 37 + .../lib/modals/WorkflowTriggerPicker.svelte | 31 + web/src/lib/route.ts | 4 +- web/src/lib/services/workflow.service.ts | 354 +------ web/src/lib/types.ts | 18 + web/src/lib/utils/workflow.ts | 146 +-- .../(user)/utilities/UtilitiesMenu.svelte | 3 +- .../workflows/[workflowId]/+page.svelte | 619 ----------- .../utilities/workflows/[workflowId]/+page.ts | 23 - .../[workflowId]/SchemaFormFields.svelte | 161 --- .../[workflowId]/WorkflowCardConnector.svelte | 42 - .../[workflowId]/WorkflowPickerField.svelte | 104 -- .../WorkflowPickerItemCard.svelte | 57 - .../[workflowId]/WorkflowTriggerCard.svelte | 80 -- .../{utilities => }/workflows/+page.svelte | 142 +-- .../(user)/{utilities => }/workflows/+page.ts | 12 +- .../workflows/[workflowId]/+page.svelte | 257 +++++ .../(user)/workflows/[workflowId]/+page.ts | 26 + .../[workflowId]/WorkflowJsonEditor.svelte | 6 +- .../[workflowId]/WorkflowSummary.svelte | 67 +- 144 files changed, 6099 insertions(+), 7419 deletions(-) delete mode 100644 mobile/openapi/lib/model/plugin_action_response_dto.dart delete mode 100644 mobile/openapi/lib/model/plugin_context_type.dart delete mode 100644 mobile/openapi/lib/model/plugin_filter_response_dto.dart delete mode 100644 mobile/openapi/lib/model/plugin_json_schema.dart delete mode 100644 mobile/openapi/lib/model/plugin_json_schema_property.dart delete mode 100644 mobile/openapi/lib/model/plugin_json_schema_property_additional_properties.dart delete mode 100644 mobile/openapi/lib/model/plugin_json_schema_type.dart create mode 100644 mobile/openapi/lib/model/plugin_method_response_dto.dart delete mode 100644 mobile/openapi/lib/model/plugin_trigger_response_dto.dart delete mode 100644 mobile/openapi/lib/model/workflow_action_item_dto.dart delete mode 100644 mobile/openapi/lib/model/workflow_action_response_dto.dart delete mode 100644 mobile/openapi/lib/model/workflow_filter_item_dto.dart delete mode 100644 mobile/openapi/lib/model/workflow_filter_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_share_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_share_step_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_step_dto.dart rename mobile/openapi/lib/model/{plugin_trigger_type.dart => workflow_trigger.dart} (50%) create mode 100644 mobile/openapi/lib/model/workflow_trigger_response_dto.dart create mode 100644 mobile/openapi/lib/model/workflow_type.dart create mode 100644 packages/plugin-core/.gitignore create mode 100644 packages/plugin-core/.prettierrc create mode 100644 packages/plugin-core/esbuild.js create mode 100644 packages/plugin-core/manifest.json create mode 100644 packages/plugin-core/mise.toml rename packages/{plugins => plugin-core}/package.json (85%) create mode 100644 packages/plugin-core/src/index.d.ts create mode 100644 packages/plugin-core/src/index.ts rename packages/{plugins => plugin-core}/tsconfig.json (68%) create mode 100644 packages/plugin-sdk/.gitignore create mode 100644 packages/plugin-sdk/esbuild.js create mode 100644 packages/plugin-sdk/package.json create mode 100644 packages/plugin-sdk/src/enum.ts create mode 100644 packages/plugin-sdk/src/host-functions.ts create mode 100644 packages/plugin-sdk/src/index.ts create mode 100644 packages/plugin-sdk/src/sdk.ts create mode 100644 packages/plugin-sdk/src/types.ts create mode 100644 packages/plugin-sdk/tsconfig.json delete mode 100644 packages/plugins/.gitignore delete mode 100644 packages/plugins/LICENSE delete mode 100644 packages/plugins/esbuild.js delete mode 100644 packages/plugins/manifest.json delete mode 100644 packages/plugins/mise.toml delete mode 100644 packages/plugins/package-lock.json delete mode 100644 packages/plugins/src/index.d.ts delete mode 100644 packages/plugins/src/index.ts create mode 100644 server/src/controllers/plugin.controller.spec.ts create mode 100644 server/src/controllers/workflow.controller.spec.ts create mode 100644 server/src/dtos/json-schema.dto.ts delete mode 100644 server/src/plugins.ts create mode 100644 server/src/schema/migrations/1773175313374-Test.ts create mode 100644 server/src/schema/migrations/1778614946174-UpdateWorkflowTables.ts create mode 100644 server/src/schema/tables/plugin-method.table.ts create mode 100644 server/src/schema/tables/workflow-step.table.ts delete mode 100644 server/src/services/plugin-host.functions.ts create mode 100644 server/src/services/workflow-execution.service.ts delete mode 100644 server/src/types/plugin-schema.types.ts create mode 100644 server/src/utils/workflow.spec.ts create mode 100644 server/src/utils/workflow.ts create mode 100644 server/test/medium/specs/workflow/workflow-core-plugin.spec.ts create mode 100644 web/src/lib/components/SchemaAlbumPicker.svelte create mode 100644 web/src/lib/components/SchemaConfiguration.svelte create mode 100644 web/src/lib/components/album-page/AlbumThumbnail.svelte create mode 100644 web/src/lib/managers/plugin-manager.svelte.ts delete mode 100644 web/src/lib/modals/AddWorkflowStepModal.svelte create mode 100644 web/src/lib/modals/PluginMethodPicker.svelte create mode 100644 web/src/lib/modals/WorkflowAddStepModal.svelte create mode 100644 web/src/lib/modals/WorkflowEditStepModal.svelte create mode 100644 web/src/lib/modals/WorkflowEditTrigger.svelte create mode 100644 web/src/lib/modals/WorkflowTriggerPicker.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/+page.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/+page.ts delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/SchemaFormFields.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/WorkflowCardConnector.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/WorkflowPickerField.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/WorkflowPickerItemCard.svelte delete mode 100644 web/src/routes/(user)/utilities/workflows/[workflowId]/WorkflowTriggerCard.svelte rename web/src/routes/(user)/{utilities => }/workflows/+page.svelte (55%) rename web/src/routes/(user)/{utilities => }/workflows/+page.ts (55%) create mode 100644 web/src/routes/(user)/workflows/[workflowId]/+page.svelte create mode 100644 web/src/routes/(user)/workflows/[workflowId]/+page.ts rename web/src/routes/(user)/{utilities => }/workflows/[workflowId]/WorkflowJsonEditor.svelte (92%) rename web/src/routes/(user)/{utilities => }/workflows/[workflowId]/WorkflowSummary.svelte (65%) diff --git a/.devcontainer/server/container-compose-overrides.yml b/.devcontainer/server/container-compose-overrides.yml index 8f9e562e0a..db20390255 100644 --- a/.devcontainer/server/container-compose-overrides.yml +++ b/.devcontainer/server/container-compose-overrides.yml @@ -16,7 +16,7 @@ services: - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../packages/plugins:/build/corePlugin + - ../packages/plugin-core:/build/plugins/immich-plugin-core immich-web: env_file: !reset [] immich-machine-learning: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97bccbc9ba..323a572757 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,9 +62,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - defaults: - run: - working-directory: ./server steps: - id: token uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 @@ -84,7 +81,7 @@ jobs: github_token: ${{ steps.token.outputs.token }} - name: Run ci-unit - run: mise run ci-unit + run: mise run //server:ci-unit cli-unit-tests: name: Unit Test CLI @@ -380,7 +377,7 @@ jobs: cache-dependency-path: '**/pnpm-lock.yaml' - name: Setup packages - run: pnpm --filter "@immich/*" install --frozen-lockfile && pnpm --filter "@immich/*" build + run: pnpm --filter @immich/sdk --filter @immich/cli install --frozen-lockfile && pnpm --filter @immich/sdk --filter @immich/cli build - name: Run setup web run: pnpm install --frozen-lockfile && pnpm exec svelte-kit sync @@ -678,7 +675,6 @@ jobs: - name: Install server dependencies run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile - - name: Run API generation run: mise //:open-api working-directory: open-api @@ -717,9 +713,6 @@ jobs: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 - defaults: - run: - working-directory: ./server steps: - id: token uses: immich-app/devtools/actions/create-workflow-token@caa599d954228439ea3e8ce1c3328f41ab120ee6 # create-workflow-token-action-v2.0.0 @@ -741,18 +734,21 @@ jobs: - name: Install server dependencies run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile + - name: Build plugins + run: mise //:plugins + - name: Build the app - run: pnpm build + run: mise //server:build - name: Run existing migrations - run: pnpm migrations:run + run: pnpm --filter immich migrations:run - name: Test npm run schema:reset command works - run: pnpm schema:reset + run: pnpm --filter immich schema:reset - name: Generate new migrations continue-on-error: true - run: pnpm migrations:generate src/TestMigration + run: pnpm --filter migrations:generate src/TestMigration - name: Find file changes uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20.0.4 @@ -768,7 +764,7 @@ jobs: run: | echo "ERROR: Generated migration files not up to date!" echo "Changed files: ${CHANGED_FILES}" - cat ./src/*-TestMigration.ts + cat ./server/src/*-TestMigration.ts exit 1 - name: Run SQL generation diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index dfb876e6bd..350e82e767 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -74,7 +74,7 @@ services: - ${UPLOAD_LOCATION}/photos:/data - /etc/localtime:/etc/localtime:ro - pnpm_store_server:/buildcache/pnpm-store - - ../packages/plugins:/build/corePlugin + - ../packages/plugin-core:/build/plugins/immich-plugin-core env_file: - .env environment: diff --git a/docs/docs/developer/testing.md b/docs/docs/developer/testing.md index 219c33d1a1..d2f64b7824 100644 --- a/docs/docs/developer/testing.md +++ b/docs/docs/developer/testing.md @@ -18,7 +18,7 @@ make e2e Before you can run the tests, you need to run the following commands _once_: - `pnpm install` -- `pnpm --filter "@immich/*" build` +- `pnpm --filter @immich/sdk --filter @immich/cli build` - `mise //:open-api` Once the test environment is running, the e2e tests can be run via: diff --git a/i18n/en.json b/i18n/en.json index cb467cc819..efbbc453e7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -22,13 +22,12 @@ "add_birthday": "Add a birthday", "add_endpoint": "Add endpoint", "add_exclusion_pattern": "Add exclusion pattern", - "add_filter": "Add filter", - "add_filter_description": "Click to add a filter condition", "add_location": "Add location", "add_more_users": "Add more users", "add_partner": "Add partner", "add_path": "Add path", "add_photos": "Add photos", + "add_step": "Add step", "add_tag": "Add tag", "add_to": "Add to…", "add_to_album": "Add to album", @@ -42,7 +41,6 @@ "add_to_shared_album": "Add to shared album", "add_upload_to_stack": "Add upload to stack", "add_url": "Add URL", - "add_workflow_step": "Add workflow step", "added_to_archive": "Added to archive", "added_to_favorites": "Added to favorites", "added_to_favorites_count": "Added {count, number} to favorites", @@ -733,6 +731,7 @@ "cannot_update_the_description": "Cannot update the description", "cast": "Cast", "cast_description": "Configure available cast destinations", + "change": "Change", "change_date": "Change date", "change_description": "Change description", "change_display_order": "Change display order", @@ -761,6 +760,7 @@ "check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_logs": "Check Logs", "checksum": "Checksum", + "choose": "Choose", "choose_matching_people_to_merge": "Choose matching people to merge", "city": "City", "cleanup_confirm_description": "Immich found {count} assets (created before {date}) safely backed up to the server. Remove the local copies from this device?", @@ -810,6 +810,7 @@ "comments_are_disabled": "Comments are disabled", "common_create_new_album": "Create new album", "completed": "Completed", + "configuration": "Configuration", "confirm": "Confirm", "confirm_admin_password": "Confirm Admin Password", "confirm_delete_face": "Are you sure you want to delete {name} face from the asset?", @@ -1631,7 +1632,6 @@ "next": "Next", "next_memory": "Next memory", "no": "No", - "no_actions_added": "No actions added yet", "no_albums_found": "No albums found", "no_albums_message": "Create an album to organize your photos and videos", "no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.", @@ -1648,7 +1648,6 @@ "no_exif_info_available": "No exif info available", "no_explore_results_message": "Upload more photos to explore your collection.", "no_favorites_message": "Add favorites to quickly find your best pictures and videos", - "no_filters_added": "No filters added yet", "no_libraries_message": "Create an external library to view your photos and videos", "no_local_assets_found": "No local assets found with this checksum", "no_location_set": "No location set", @@ -1661,6 +1660,7 @@ "no_results": "No results", "no_results_description": "Try a synonym or more general keyword", "no_shared_albums_message": "Create an album to share photos and videos with people in your network", + "no_steps": "No steps added yet", "no_uploads_in_progress": "No uploads in progress", "none": "None", "not_allowed": "Not allowed", @@ -1797,6 +1797,8 @@ "play_original_video_setting_description": "Prefer playback of original videos rather than transcoded videos. If original asset is not compatible it may not playback correctly.", "play_transcoded_video": "Play transcoded video", "please_auth_to_access": "Please authenticate to access", + "plugin_method_filter_type": "Filter", + "plugin_method_filter_type_description": "This method can filter events and conditionally prevent subsequent steps from running", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", @@ -2239,6 +2241,10 @@ "start_date_before_end_date": "Start date must be before end date", "state": "State", "status": "Status", + "step_delete": "Delete step", + "step_delete_confirm": "Are you sure you want to delete this step?", + "step_details": "Step details", + "steps": "Steps", "stop_casting": "Stop casting", "stop_motion_photo": "Stop Motion Photo", "stop_photo_sharing": "Stop sharing your photos?", @@ -2332,7 +2338,7 @@ "trash_page_title": "Trash ({count})", "trashed_items_will_be_permanently_deleted_after": "Trashed items will be permanently deleted after {days, plural, one {# day} other {# days}}.", "trigger": "Trigger", - "trigger_asset_uploaded": "Asset Uploaded", + "trigger_asset_uploaded": "Asset Upload", "trigger_asset_uploaded_description": "Triggered when a new asset is uploaded", "trigger_description": "An event that kicks off the workflow", "trigger_person_recognized": "Person Recognized", @@ -2372,7 +2378,6 @@ "unsupported_field_type": "Unsupported field type", "unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.", "untagged": "Untagged", - "untitled_workflow": "Untitled workflow", "up_next": "Up next", "update_location_action_prompt": "Update the location of {count} selected assets with:", "updated_at": "Updated", @@ -2464,6 +2469,7 @@ "welcome_to_immich": "Welcome to Immich", "width": "Width", "wifi_name": "Wi-Fi Name", + "workflow": "Workflow", "workflow_delete_prompt": "Are you sure you want to delete this workflow?", "workflow_deleted": "Workflow deleted", "workflow_description": "Workflow description", diff --git a/mise.toml b/mise.toml index 8514cdd7aa..08e6b7fd78 100644 --- a/mise.toml +++ b/mise.toml @@ -2,7 +2,7 @@ experimental_monorepo_root = true [monorepo] config_roots = [ - "packages/plugins", + "packages/plugin-core", "server", "packages/cli", "deployment", @@ -22,6 +22,9 @@ terragrunt = "1.0.3" opentofu = "1.11.6" java = "21.0.2" "npm:oazapfts" = "7.5.0" +"github:extism/cli" = "1.6.3" +"github:webassembly/binaryen" = "version_124" +"github:extism/js-pdk" = "1.6.0" [tools."github:CQLabs/homebrew-dcm"] version = "1.37.0" @@ -48,6 +51,12 @@ macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" experimental = true pin = true +[tasks.plugins] +run = [ + "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile", + "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build" +] + [tasks.open-api-typescript] run = [ "oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas open-api/immich-openapi-specs.json packages/sdk/src/fetch-client.ts", @@ -62,6 +71,8 @@ run = "bash ./bin/generate-dart-sdk.sh" [tasks.open-api] env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true } run = [ + { task = "//:plugins" }, + { task = "//server:build" }, { task = "//server:install" }, { task = "//server:build" }, { task = "//server:sync-open-api" }, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2538f8e7a7..ecc75dd945 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -205,8 +205,8 @@ Class | Method | HTTP request | Description *PeopleApi* | [**updatePeople**](doc//PeopleApi.md#updatepeople) | **PUT** /people | Update people *PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person *PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin -*PluginsApi* | [**getPluginTriggers**](doc//PluginsApi.md#getplugintriggers) | **GET** /plugins/triggers | List all plugin triggers -*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins +*PluginsApi* | [**searchPluginMethods**](doc//PluginsApi.md#searchpluginmethods) | **GET** /plugins/methods | Retrieve plugin methods +*PluginsApi* | [**searchPlugins**](doc//PluginsApi.md#searchplugins) | **GET** /plugins | List all plugins *QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue *QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue *QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs @@ -314,7 +314,9 @@ Class | Method | HTTP request | Description *WorkflowsApi* | [**createWorkflow**](doc//WorkflowsApi.md#createworkflow) | **POST** /workflows | Create a workflow *WorkflowsApi* | [**deleteWorkflow**](doc//WorkflowsApi.md#deleteworkflow) | **DELETE** /workflows/{id} | Delete a workflow *WorkflowsApi* | [**getWorkflow**](doc//WorkflowsApi.md#getworkflow) | **GET** /workflows/{id} | Retrieve a workflow -*WorkflowsApi* | [**getWorkflows**](doc//WorkflowsApi.md#getworkflows) | **GET** /workflows | List all workflows +*WorkflowsApi* | [**getWorkflowForShare**](doc//WorkflowsApi.md#getworkflowforshare) | **GET** /workflows/{id}/share | Retrieve a workflow +*WorkflowsApi* | [**getWorkflowTriggers**](doc//WorkflowsApi.md#getworkflowtriggers) | **GET** /workflows/triggers | List all workflow triggers +*WorkflowsApi* | [**searchWorkflows**](doc//WorkflowsApi.md#searchworkflows) | **GET** /workflows | List all workflows *WorkflowsApi* | [**updateWorkflow**](doc//WorkflowsApi.md#updateworkflow) | **PUT** /workflows/{id} | Update a workflow @@ -487,16 +489,8 @@ Class | Method | HTTP request | Description - [PinCodeResetDto](doc//PinCodeResetDto.md) - [PinCodeSetupDto](doc//PinCodeSetupDto.md) - [PlacesResponseDto](doc//PlacesResponseDto.md) - - [PluginActionResponseDto](doc//PluginActionResponseDto.md) - - [PluginContextType](doc//PluginContextType.md) - - [PluginFilterResponseDto](doc//PluginFilterResponseDto.md) - - [PluginJsonSchema](doc//PluginJsonSchema.md) - - [PluginJsonSchemaProperty](doc//PluginJsonSchemaProperty.md) - - [PluginJsonSchemaPropertyAdditionalProperties](doc//PluginJsonSchemaPropertyAdditionalProperties.md) - - [PluginJsonSchemaType](doc//PluginJsonSchemaType.md) + - [PluginMethodResponseDto](doc//PluginMethodResponseDto.md) - [PluginResponseDto](doc//PluginResponseDto.md) - - [PluginTriggerResponseDto](doc//PluginTriggerResponseDto.md) - - [PluginTriggerType](doc//PluginTriggerType.md) - [PurchaseResponse](doc//PurchaseResponse.md) - [PurchaseUpdate](doc//PurchaseUpdate.md) - [QueueCommand](doc//QueueCommand.md) @@ -669,12 +663,14 @@ Class | Method | HTTP request | Description - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md) - [VideoCodec](doc//VideoCodec.md) - [VideoContainer](doc//VideoContainer.md) - - [WorkflowActionItemDto](doc//WorkflowActionItemDto.md) - - [WorkflowActionResponseDto](doc//WorkflowActionResponseDto.md) - [WorkflowCreateDto](doc//WorkflowCreateDto.md) - - [WorkflowFilterItemDto](doc//WorkflowFilterItemDto.md) - - [WorkflowFilterResponseDto](doc//WorkflowFilterResponseDto.md) - [WorkflowResponseDto](doc//WorkflowResponseDto.md) + - [WorkflowShareResponseDto](doc//WorkflowShareResponseDto.md) + - [WorkflowShareStepDto](doc//WorkflowShareStepDto.md) + - [WorkflowStepDto](doc//WorkflowStepDto.md) + - [WorkflowTrigger](doc//WorkflowTrigger.md) + - [WorkflowTriggerResponseDto](doc//WorkflowTriggerResponseDto.md) + - [WorkflowType](doc//WorkflowType.md) - [WorkflowUpdateDto](doc//WorkflowUpdateDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 097f0b41bb..1769c8af75 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -235,16 +235,8 @@ part 'model/pin_code_change_dto.dart'; part 'model/pin_code_reset_dto.dart'; part 'model/pin_code_setup_dto.dart'; part 'model/places_response_dto.dart'; -part 'model/plugin_action_response_dto.dart'; -part 'model/plugin_context_type.dart'; -part 'model/plugin_filter_response_dto.dart'; -part 'model/plugin_json_schema.dart'; -part 'model/plugin_json_schema_property.dart'; -part 'model/plugin_json_schema_property_additional_properties.dart'; -part 'model/plugin_json_schema_type.dart'; +part 'model/plugin_method_response_dto.dart'; part 'model/plugin_response_dto.dart'; -part 'model/plugin_trigger_response_dto.dart'; -part 'model/plugin_trigger_type.dart'; part 'model/purchase_response.dart'; part 'model/purchase_update.dart'; part 'model/queue_command.dart'; @@ -417,12 +409,14 @@ part 'model/validate_library_response_dto.dart'; part 'model/version_check_state_response_dto.dart'; part 'model/video_codec.dart'; part 'model/video_container.dart'; -part 'model/workflow_action_item_dto.dart'; -part 'model/workflow_action_response_dto.dart'; part 'model/workflow_create_dto.dart'; -part 'model/workflow_filter_item_dto.dart'; -part 'model/workflow_filter_response_dto.dart'; part 'model/workflow_response_dto.dart'; +part 'model/workflow_share_response_dto.dart'; +part 'model/workflow_share_step_dto.dart'; +part 'model/workflow_step_dto.dart'; +part 'model/workflow_trigger.dart'; +part 'model/workflow_trigger_response_dto.dart'; +part 'model/workflow_type.dart'; part 'model/workflow_update_dto.dart'; diff --git a/mobile/openapi/lib/api/plugins_api.dart b/mobile/openapi/lib/api/plugins_api.dart index 5735fba379..d2a1d386d9 100644 --- a/mobile/openapi/lib/api/plugins_api.dart +++ b/mobile/openapi/lib/api/plugins_api.dart @@ -73,14 +73,40 @@ class PluginsApi { return null; } - /// List all plugin triggers + /// Retrieve plugin methods /// - /// Retrieve a list of all available plugin triggers. + /// Retrieve a list of plugin methods /// /// Note: This method returns the HTTP [Response]. - Future getPluginTriggersWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin method is enabled + /// + /// * [String] id: + /// Plugin method ID + /// + /// * [String] name: + /// + /// * [String] pluginName: + /// Plugin name + /// + /// * [String] pluginVersion: + /// Plugin version + /// + /// * [String] title: + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger + /// + /// * [WorkflowType] type: + /// Workflow types + Future searchPluginMethodsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async { // ignore: prefer_const_declarations - final apiPath = r'/plugins/triggers'; + final apiPath = r'/plugins/methods'; // ignore: prefer_final_locals Object? postBody; @@ -89,6 +115,34 @@ class PluginsApi { final headerParams = {}; final formParams = {}; + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (pluginName != null) { + queryParams.addAll(_queryParams('', 'pluginName', pluginName)); + } + if (pluginVersion != null) { + queryParams.addAll(_queryParams('', 'pluginVersion', pluginVersion)); + } + if (title != null) { + queryParams.addAll(_queryParams('', 'title', title)); + } + if (trigger != null) { + queryParams.addAll(_queryParams('', 'trigger', trigger)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + const contentTypes = []; @@ -103,11 +157,37 @@ class PluginsApi { ); } - /// List all plugin triggers + /// Retrieve plugin methods /// - /// Retrieve a list of all available plugin triggers. - Future?> getPluginTriggers() async { - final response = await getPluginTriggersWithHttpInfo(); + /// Retrieve a list of plugin methods + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin method is enabled + /// + /// * [String] id: + /// Plugin method ID + /// + /// * [String] name: + /// + /// * [String] pluginName: + /// Plugin name + /// + /// * [String] pluginVersion: + /// Plugin version + /// + /// * [String] title: + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger + /// + /// * [WorkflowType] type: + /// Workflow types + Future?> searchPluginMethods({ String? description, bool? enabled, String? id, String? name, String? pluginName, String? pluginVersion, String? title, WorkflowTrigger? trigger, WorkflowType? type, }) async { + final response = await searchPluginMethodsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, pluginName: pluginName, pluginVersion: pluginVersion, title: title, trigger: trigger, type: type, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -116,8 +196,8 @@ class PluginsApi { // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { final responseBody = await _decodeBodyBytes(response); - return (await apiClient.deserializeAsync(responseBody, 'List') as List) - .cast() + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() .toList(growable: false); } @@ -129,7 +209,23 @@ class PluginsApi { /// Retrieve a list of plugins available to the authenticated user. /// /// Note: This method returns the HTTP [Response]. - Future getPluginsWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin is enabled + /// + /// * [String] id: + /// Plugin ID + /// + /// * [String] name: + /// + /// * [String] title: + /// + /// * [String] version: + Future searchPluginsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async { // ignore: prefer_const_declarations final apiPath = r'/plugins'; @@ -140,6 +236,25 @@ class PluginsApi { final headerParams = {}; final formParams = {}; + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (title != null) { + queryParams.addAll(_queryParams('', 'title', title)); + } + if (version != null) { + queryParams.addAll(_queryParams('', 'version', version)); + } + const contentTypes = []; @@ -157,8 +272,24 @@ class PluginsApi { /// List all plugins /// /// Retrieve a list of plugins available to the authenticated user. - Future?> getPlugins() async { - final response = await getPluginsWithHttpInfo(); + /// + /// Parameters: + /// + /// * [String] description: + /// + /// * [bool] enabled: + /// Whether the plugin is enabled + /// + /// * [String] id: + /// Plugin ID + /// + /// * [String] name: + /// + /// * [String] title: + /// + /// * [String] version: + Future?> searchPlugins({ String? description, bool? enabled, String? id, String? name, String? title, String? version, }) async { + final response = await searchPluginsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, title: title, version: version, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/workflows_api.dart b/mobile/openapi/lib/api/workflows_api.dart index c589ec9823..12b33b7238 100644 --- a/mobile/openapi/lib/api/workflows_api.dart +++ b/mobile/openapi/lib/api/workflows_api.dart @@ -178,14 +178,19 @@ class WorkflowsApi { return null; } - /// List all workflows + /// Retrieve a workflow /// - /// Retrieve a list of workflows available to the authenticated user. + /// Retrieve a workflow details without ids, default values, etc. /// /// Note: This method returns the HTTP [Response]. - Future getWorkflowsWithHttpInfo() async { + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowForShareWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/workflows'; + final apiPath = r'/workflows/{id}/share' + .replaceAll('{id}', id); // ignore: prefer_final_locals Object? postBody; @@ -208,11 +213,164 @@ class WorkflowsApi { ); } + /// Retrieve a workflow + /// + /// Retrieve a workflow details without ids, default values, etc. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getWorkflowForShare(String id,) async { + final response = await getWorkflowForShareWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'WorkflowShareResponseDto',) as WorkflowShareResponseDto; + + } + return null; + } + + /// List all workflow triggers + /// + /// Retrieve a list of all available workflow triggers. + /// + /// Note: This method returns the HTTP [Response]. + Future getWorkflowTriggersWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows/triggers'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all workflow triggers + /// + /// Retrieve a list of all available workflow triggers. + Future?> getWorkflowTriggers() async { + final response = await getWorkflowTriggersWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// List all workflows /// /// Retrieve a list of workflows available to the authenticated user. - Future?> getWorkflows() async { - final response = await getWorkflowsWithHttpInfo(); + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] description: + /// Workflow description + /// + /// * [bool] enabled: + /// Workflow enabled + /// + /// * [String] id: + /// Workflow ID + /// + /// * [String] name: + /// Workflow name + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger type + Future searchWorkflowsWithHttpInfo({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/workflows'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (description != null) { + queryParams.addAll(_queryParams('', 'description', description)); + } + if (enabled != null) { + queryParams.addAll(_queryParams('', 'enabled', enabled)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (name != null) { + queryParams.addAll(_queryParams('', 'name', name)); + } + if (trigger != null) { + queryParams.addAll(_queryParams('', 'trigger', trigger)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// List all workflows + /// + /// Retrieve a list of workflows available to the authenticated user. + /// + /// Parameters: + /// + /// * [String] description: + /// Workflow description + /// + /// * [bool] enabled: + /// Workflow enabled + /// + /// * [String] id: + /// Workflow ID + /// + /// * [String] name: + /// Workflow name + /// + /// * [WorkflowTrigger] trigger: + /// Workflow trigger type + Future?> searchWorkflows({ String? description, bool? enabled, String? id, String? name, WorkflowTrigger? trigger, }) async { + final response = await searchWorkflowsWithHttpInfo( description: description, enabled: enabled, id: id, name: name, trigger: trigger, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index e04f800d3e..103a5db5f4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -516,26 +516,10 @@ class ApiClient { return PinCodeSetupDto.fromJson(value); case 'PlacesResponseDto': return PlacesResponseDto.fromJson(value); - case 'PluginActionResponseDto': - return PluginActionResponseDto.fromJson(value); - case 'PluginContextType': - return PluginContextTypeTypeTransformer().decode(value); - case 'PluginFilterResponseDto': - return PluginFilterResponseDto.fromJson(value); - case 'PluginJsonSchema': - return PluginJsonSchema.fromJson(value); - case 'PluginJsonSchemaProperty': - return PluginJsonSchemaProperty.fromJson(value); - case 'PluginJsonSchemaPropertyAdditionalProperties': - return PluginJsonSchemaPropertyAdditionalProperties.fromJson(value); - case 'PluginJsonSchemaType': - return PluginJsonSchemaTypeTypeTransformer().decode(value); + case 'PluginMethodResponseDto': + return PluginMethodResponseDto.fromJson(value); case 'PluginResponseDto': return PluginResponseDto.fromJson(value); - case 'PluginTriggerResponseDto': - return PluginTriggerResponseDto.fromJson(value); - case 'PluginTriggerType': - return PluginTriggerTypeTypeTransformer().decode(value); case 'PurchaseResponse': return PurchaseResponse.fromJson(value); case 'PurchaseUpdate': @@ -880,18 +864,22 @@ class ApiClient { return VideoCodecTypeTransformer().decode(value); case 'VideoContainer': return VideoContainerTypeTransformer().decode(value); - case 'WorkflowActionItemDto': - return WorkflowActionItemDto.fromJson(value); - case 'WorkflowActionResponseDto': - return WorkflowActionResponseDto.fromJson(value); case 'WorkflowCreateDto': return WorkflowCreateDto.fromJson(value); - case 'WorkflowFilterItemDto': - return WorkflowFilterItemDto.fromJson(value); - case 'WorkflowFilterResponseDto': - return WorkflowFilterResponseDto.fromJson(value); case 'WorkflowResponseDto': return WorkflowResponseDto.fromJson(value); + case 'WorkflowShareResponseDto': + return WorkflowShareResponseDto.fromJson(value); + case 'WorkflowShareStepDto': + return WorkflowShareStepDto.fromJson(value); + case 'WorkflowStepDto': + return WorkflowStepDto.fromJson(value); + case 'WorkflowTrigger': + return WorkflowTriggerTypeTransformer().decode(value); + case 'WorkflowTriggerResponseDto': + return WorkflowTriggerResponseDto.fromJson(value); + case 'WorkflowType': + return WorkflowTypeTypeTransformer().decode(value); case 'WorkflowUpdateDto': return WorkflowUpdateDto.fromJson(value); default: diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 340962cde5..b5d348edd6 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -142,15 +142,6 @@ String parameterToString(dynamic value) { if (value is Permission) { return PermissionTypeTransformer().encode(value).toString(); } - if (value is PluginContextType) { - return PluginContextTypeTypeTransformer().encode(value).toString(); - } - if (value is PluginJsonSchemaType) { - return PluginJsonSchemaTypeTypeTransformer().encode(value).toString(); - } - if (value is PluginTriggerType) { - return PluginTriggerTypeTypeTransformer().encode(value).toString(); - } if (value is QueueCommand) { return QueueCommandTypeTransformer().encode(value).toString(); } @@ -208,6 +199,12 @@ String parameterToString(dynamic value) { if (value is VideoContainer) { return VideoContainerTypeTransformer().encode(value).toString(); } + if (value is WorkflowTrigger) { + return WorkflowTriggerTypeTransformer().encode(value).toString(); + } + if (value is WorkflowType) { + return WorkflowTypeTypeTransformer().encode(value).toString(); + } return value.toString(); } diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 08f70569f8..444b080c12 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -77,7 +77,7 @@ class JobName { static const versionCheck = JobName._(r'VersionCheck'); static const ocrQueueAll = JobName._(r'OcrQueueAll'); static const ocr = JobName._(r'Ocr'); - static const workflowRun = JobName._(r'WorkflowRun'); + static const workflowAssetCreate = JobName._(r'WorkflowAssetCreate'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -135,7 +135,7 @@ class JobName { versionCheck, ocrQueueAll, ocr, - workflowRun, + workflowAssetCreate, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -228,7 +228,7 @@ class JobNameTypeTransformer { case r'VersionCheck': return JobName.versionCheck; case r'OcrQueueAll': return JobName.ocrQueueAll; case r'Ocr': return JobName.ocr; - case r'WorkflowRun': return JobName.workflowRun; + case r'WorkflowAssetCreate': return JobName.workflowAssetCreate; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/plugin_action_response_dto.dart b/mobile/openapi/lib/model/plugin_action_response_dto.dart deleted file mode 100644 index cff2dc92f7..0000000000 --- a/mobile/openapi/lib/model/plugin_action_response_dto.dart +++ /dev/null @@ -1,158 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginActionResponseDto { - /// Returns a new [PluginActionResponseDto] instance. - PluginActionResponseDto({ - required this.description, - required this.id, - required this.methodName, - required this.pluginId, - required this.schema, - this.supportedContexts = const [], - required this.title, - }); - - /// Action description - String description; - - /// Action ID - String id; - - /// Method name - String methodName; - - /// Plugin ID - String pluginId; - - /// Action schema - PluginJsonSchema? schema; - - /// Supported contexts - List supportedContexts; - - /// Action title - String title; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginActionResponseDto && - other.description == description && - other.id == id && - other.methodName == methodName && - other.pluginId == pluginId && - other.schema == schema && - _deepEquality.equals(other.supportedContexts, supportedContexts) && - other.title == title; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (description.hashCode) + - (id.hashCode) + - (methodName.hashCode) + - (pluginId.hashCode) + - (schema == null ? 0 : schema!.hashCode) + - (supportedContexts.hashCode) + - (title.hashCode); - - @override - String toString() => 'PluginActionResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; - - Map toJson() { - final json = {}; - json[r'description'] = this.description; - json[r'id'] = this.id; - json[r'methodName'] = this.methodName; - json[r'pluginId'] = this.pluginId; - if (this.schema != null) { - json[r'schema'] = this.schema; - } else { - // json[r'schema'] = null; - } - json[r'supportedContexts'] = this.supportedContexts; - json[r'title'] = this.title; - return json; - } - - /// Returns a new [PluginActionResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginActionResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PluginActionResponseDto"); - if (value is Map) { - final json = value.cast(); - - return PluginActionResponseDto( - description: mapValueOfType(json, r'description')!, - id: mapValueOfType(json, r'id')!, - methodName: mapValueOfType(json, r'methodName')!, - pluginId: mapValueOfType(json, r'pluginId')!, - schema: PluginJsonSchema.fromJson(json[r'schema']), - supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), - title: mapValueOfType(json, r'title')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginActionResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginActionResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginActionResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginActionResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'description', - 'id', - 'methodName', - 'pluginId', - 'schema', - 'supportedContexts', - 'title', - }; -} - diff --git a/mobile/openapi/lib/model/plugin_context_type.dart b/mobile/openapi/lib/model/plugin_context_type.dart deleted file mode 100644 index beda0b0f1a..0000000000 --- a/mobile/openapi/lib/model/plugin_context_type.dart +++ /dev/null @@ -1,88 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -/// Plugin context -class PluginContextType { - /// Instantiate a new enum with the provided [value]. - const PluginContextType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const asset = PluginContextType._(r'asset'); - static const album = PluginContextType._(r'album'); - static const person = PluginContextType._(r'person'); - - /// List of all possible values in this [enum][PluginContextType]. - static const values = [ - asset, - album, - person, - ]; - - static PluginContextType? fromJson(dynamic value) => PluginContextTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginContextType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [PluginContextType] to String, -/// and [decode] dynamic data back to [PluginContextType]. -class PluginContextTypeTypeTransformer { - factory PluginContextTypeTypeTransformer() => _instance ??= const PluginContextTypeTypeTransformer._(); - - const PluginContextTypeTypeTransformer._(); - - String encode(PluginContextType data) => data.value; - - /// Decodes a [dynamic value][data] to a PluginContextType. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - PluginContextType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'asset': return PluginContextType.asset; - case r'album': return PluginContextType.album; - case r'person': return PluginContextType.person; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [PluginContextTypeTypeTransformer] instance. - static PluginContextTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/plugin_filter_response_dto.dart b/mobile/openapi/lib/model/plugin_filter_response_dto.dart deleted file mode 100644 index d1ab867ff9..0000000000 --- a/mobile/openapi/lib/model/plugin_filter_response_dto.dart +++ /dev/null @@ -1,158 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginFilterResponseDto { - /// Returns a new [PluginFilterResponseDto] instance. - PluginFilterResponseDto({ - required this.description, - required this.id, - required this.methodName, - required this.pluginId, - required this.schema, - this.supportedContexts = const [], - required this.title, - }); - - /// Filter description - String description; - - /// Filter ID - String id; - - /// Method name - String methodName; - - /// Plugin ID - String pluginId; - - /// Filter schema - PluginJsonSchema? schema; - - /// Supported contexts - List supportedContexts; - - /// Filter title - String title; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginFilterResponseDto && - other.description == description && - other.id == id && - other.methodName == methodName && - other.pluginId == pluginId && - other.schema == schema && - _deepEquality.equals(other.supportedContexts, supportedContexts) && - other.title == title; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (description.hashCode) + - (id.hashCode) + - (methodName.hashCode) + - (pluginId.hashCode) + - (schema == null ? 0 : schema!.hashCode) + - (supportedContexts.hashCode) + - (title.hashCode); - - @override - String toString() => 'PluginFilterResponseDto[description=$description, id=$id, methodName=$methodName, pluginId=$pluginId, schema=$schema, supportedContexts=$supportedContexts, title=$title]'; - - Map toJson() { - final json = {}; - json[r'description'] = this.description; - json[r'id'] = this.id; - json[r'methodName'] = this.methodName; - json[r'pluginId'] = this.pluginId; - if (this.schema != null) { - json[r'schema'] = this.schema; - } else { - // json[r'schema'] = null; - } - json[r'supportedContexts'] = this.supportedContexts; - json[r'title'] = this.title; - return json; - } - - /// Returns a new [PluginFilterResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginFilterResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PluginFilterResponseDto"); - if (value is Map) { - final json = value.cast(); - - return PluginFilterResponseDto( - description: mapValueOfType(json, r'description')!, - id: mapValueOfType(json, r'id')!, - methodName: mapValueOfType(json, r'methodName')!, - pluginId: mapValueOfType(json, r'pluginId')!, - schema: PluginJsonSchema.fromJson(json[r'schema']), - supportedContexts: PluginContextType.listFromJson(json[r'supportedContexts']), - title: mapValueOfType(json, r'title')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginFilterResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginFilterResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginFilterResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginFilterResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'description', - 'id', - 'methodName', - 'pluginId', - 'schema', - 'supportedContexts', - 'title', - }; -} - diff --git a/mobile/openapi/lib/model/plugin_json_schema.dart b/mobile/openapi/lib/model/plugin_json_schema.dart deleted file mode 100644 index f7a2d584d9..0000000000 --- a/mobile/openapi/lib/model/plugin_json_schema.dart +++ /dev/null @@ -1,158 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginJsonSchema { - /// Returns a new [PluginJsonSchema] instance. - PluginJsonSchema({ - this.additionalProperties, - this.description, - this.properties = const {}, - this.required_ = const [], - this.type, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? additionalProperties; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? description; - - Map properties; - - List required_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaType? type; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchema && - other.additionalProperties == additionalProperties && - other.description == description && - _deepEquality.equals(other.properties, properties) && - _deepEquality.equals(other.required_, required_) && - other.type == type; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (additionalProperties == null ? 0 : additionalProperties!.hashCode) + - (description == null ? 0 : description!.hashCode) + - (properties.hashCode) + - (required_.hashCode) + - (type == null ? 0 : type!.hashCode); - - @override - String toString() => 'PluginJsonSchema[additionalProperties=$additionalProperties, description=$description, properties=$properties, required_=$required_, type=$type]'; - - Map toJson() { - final json = {}; - if (this.additionalProperties != null) { - json[r'additionalProperties'] = this.additionalProperties; - } else { - // json[r'additionalProperties'] = null; - } - if (this.description != null) { - json[r'description'] = this.description; - } else { - // json[r'description'] = null; - } - json[r'properties'] = this.properties; - json[r'required'] = this.required_; - if (this.type != null) { - json[r'type'] = this.type; - } else { - // json[r'type'] = null; - } - return json; - } - - /// Returns a new [PluginJsonSchema] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginJsonSchema? fromJson(dynamic value) { - upgradeDto(value, "PluginJsonSchema"); - if (value is Map) { - final json = value.cast(); - - return PluginJsonSchema( - additionalProperties: mapValueOfType(json, r'additionalProperties'), - description: mapValueOfType(json, r'description'), - properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']), - required_: json[r'required'] is Iterable - ? (json[r'required'] as Iterable).cast().toList(growable: false) - : const [], - type: PluginJsonSchemaType.fromJson(json[r'type']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginJsonSchema.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginJsonSchema.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginJsonSchema-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginJsonSchema.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/mobile/openapi/lib/model/plugin_json_schema_property.dart b/mobile/openapi/lib/model/plugin_json_schema_property.dart deleted file mode 100644 index 65951da0a3..0000000000 --- a/mobile/openapi/lib/model/plugin_json_schema_property.dart +++ /dev/null @@ -1,195 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginJsonSchemaProperty { - /// Returns a new [PluginJsonSchemaProperty] instance. - PluginJsonSchemaProperty({ - this.additionalProperties, - this.default_, - this.description, - this.enum_ = const [], - this.items, - this.properties = const {}, - this.required_ = const [], - this.type, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaPropertyAdditionalProperties? additionalProperties; - - Object? default_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? description; - - List enum_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaProperty? items; - - Map properties; - - List required_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaType? type; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchemaProperty && - other.additionalProperties == additionalProperties && - other.default_ == default_ && - other.description == description && - _deepEquality.equals(other.enum_, enum_) && - other.items == items && - _deepEquality.equals(other.properties, properties) && - _deepEquality.equals(other.required_, required_) && - other.type == type; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (additionalProperties == null ? 0 : additionalProperties!.hashCode) + - (default_ == null ? 0 : default_!.hashCode) + - (description == null ? 0 : description!.hashCode) + - (enum_.hashCode) + - (items == null ? 0 : items!.hashCode) + - (properties.hashCode) + - (required_.hashCode) + - (type == null ? 0 : type!.hashCode); - - @override - String toString() => 'PluginJsonSchemaProperty[additionalProperties=$additionalProperties, default_=$default_, description=$description, enum_=$enum_, items=$items, properties=$properties, required_=$required_, type=$type]'; - - Map toJson() { - final json = {}; - if (this.additionalProperties != null) { - json[r'additionalProperties'] = this.additionalProperties; - } else { - // json[r'additionalProperties'] = null; - } - if (this.default_ != null) { - json[r'default'] = this.default_; - } else { - // json[r'default'] = null; - } - if (this.description != null) { - json[r'description'] = this.description; - } else { - // json[r'description'] = null; - } - json[r'enum'] = this.enum_; - if (this.items != null) { - json[r'items'] = this.items; - } else { - // json[r'items'] = null; - } - json[r'properties'] = this.properties; - json[r'required'] = this.required_; - if (this.type != null) { - json[r'type'] = this.type; - } else { - // json[r'type'] = null; - } - return json; - } - - /// Returns a new [PluginJsonSchemaProperty] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginJsonSchemaProperty? fromJson(dynamic value) { - upgradeDto(value, "PluginJsonSchemaProperty"); - if (value is Map) { - final json = value.cast(); - - return PluginJsonSchemaProperty( - additionalProperties: PluginJsonSchemaPropertyAdditionalProperties.fromJson(json[r'additionalProperties']), - default_: mapValueOfType(json, r'default'), - description: mapValueOfType(json, r'description'), - enum_: json[r'enum'] is Iterable - ? (json[r'enum'] as Iterable).cast().toList(growable: false) - : const [], - items: PluginJsonSchemaProperty.fromJson(json[r'items']), - properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']), - required_: json[r'required'] is Iterable - ? (json[r'required'] as Iterable).cast().toList(growable: false) - : const [], - type: PluginJsonSchemaType.fromJson(json[r'type']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginJsonSchemaProperty.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginJsonSchemaProperty.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginJsonSchemaProperty-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginJsonSchemaProperty.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/mobile/openapi/lib/model/plugin_json_schema_property_additional_properties.dart b/mobile/openapi/lib/model/plugin_json_schema_property_additional_properties.dart deleted file mode 100644 index 169c6be772..0000000000 --- a/mobile/openapi/lib/model/plugin_json_schema_property_additional_properties.dart +++ /dev/null @@ -1,195 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginJsonSchemaPropertyAdditionalProperties { - /// Returns a new [PluginJsonSchemaPropertyAdditionalProperties] instance. - PluginJsonSchemaPropertyAdditionalProperties({ - this.additionalProperties, - this.default_, - this.description, - this.enum_ = const [], - this.items, - this.properties = const {}, - this.required_ = const [], - this.type, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaPropertyAdditionalProperties? additionalProperties; - - Object? default_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - String? description; - - List enum_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaProperty? items; - - Map properties; - - List required_; - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - PluginJsonSchemaType? type; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginJsonSchemaPropertyAdditionalProperties && - other.additionalProperties == additionalProperties && - other.default_ == default_ && - other.description == description && - _deepEquality.equals(other.enum_, enum_) && - other.items == items && - _deepEquality.equals(other.properties, properties) && - _deepEquality.equals(other.required_, required_) && - other.type == type; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (additionalProperties == null ? 0 : additionalProperties!.hashCode) + - (default_ == null ? 0 : default_!.hashCode) + - (description == null ? 0 : description!.hashCode) + - (enum_.hashCode) + - (items == null ? 0 : items!.hashCode) + - (properties.hashCode) + - (required_.hashCode) + - (type == null ? 0 : type!.hashCode); - - @override - String toString() => 'PluginJsonSchemaPropertyAdditionalProperties[additionalProperties=$additionalProperties, default_=$default_, description=$description, enum_=$enum_, items=$items, properties=$properties, required_=$required_, type=$type]'; - - Map toJson() { - final json = {}; - if (this.additionalProperties != null) { - json[r'additionalProperties'] = this.additionalProperties; - } else { - // json[r'additionalProperties'] = null; - } - if (this.default_ != null) { - json[r'default'] = this.default_; - } else { - // json[r'default'] = null; - } - if (this.description != null) { - json[r'description'] = this.description; - } else { - // json[r'description'] = null; - } - json[r'enum'] = this.enum_; - if (this.items != null) { - json[r'items'] = this.items; - } else { - // json[r'items'] = null; - } - json[r'properties'] = this.properties; - json[r'required'] = this.required_; - if (this.type != null) { - json[r'type'] = this.type; - } else { - // json[r'type'] = null; - } - return json; - } - - /// Returns a new [PluginJsonSchemaPropertyAdditionalProperties] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginJsonSchemaPropertyAdditionalProperties? fromJson(dynamic value) { - upgradeDto(value, "PluginJsonSchemaPropertyAdditionalProperties"); - if (value is Map) { - final json = value.cast(); - - return PluginJsonSchemaPropertyAdditionalProperties( - additionalProperties: PluginJsonSchemaPropertyAdditionalProperties.fromJson(json[r'additionalProperties']), - default_: mapValueOfType(json, r'default'), - description: mapValueOfType(json, r'description'), - enum_: json[r'enum'] is Iterable - ? (json[r'enum'] as Iterable).cast().toList(growable: false) - : const [], - items: PluginJsonSchemaProperty.fromJson(json[r'items']), - properties: PluginJsonSchemaProperty.mapFromJson(json[r'properties']), - required_: json[r'required'] is Iterable - ? (json[r'required'] as Iterable).cast().toList(growable: false) - : const [], - type: PluginJsonSchemaType.fromJson(json[r'type']), - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginJsonSchemaPropertyAdditionalProperties.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginJsonSchemaPropertyAdditionalProperties.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginJsonSchemaPropertyAdditionalProperties-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginJsonSchemaPropertyAdditionalProperties.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - }; -} - diff --git a/mobile/openapi/lib/model/plugin_json_schema_type.dart b/mobile/openapi/lib/model/plugin_json_schema_type.dart deleted file mode 100644 index cabac9b71b..0000000000 --- a/mobile/openapi/lib/model/plugin_json_schema_type.dart +++ /dev/null @@ -1,100 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - - -class PluginJsonSchemaType { - /// Instantiate a new enum with the provided [value]. - const PluginJsonSchemaType._(this.value); - - /// The underlying value of this enum member. - final String value; - - @override - String toString() => value; - - String toJson() => value; - - static const string = PluginJsonSchemaType._(r'string'); - static const number = PluginJsonSchemaType._(r'number'); - static const integer = PluginJsonSchemaType._(r'integer'); - static const boolean = PluginJsonSchemaType._(r'boolean'); - static const object = PluginJsonSchemaType._(r'object'); - static const array = PluginJsonSchemaType._(r'array'); - static const null_ = PluginJsonSchemaType._(r'null'); - - /// List of all possible values in this [enum][PluginJsonSchemaType]. - static const values = [ - string, - number, - integer, - boolean, - object, - array, - null_, - ]; - - static PluginJsonSchemaType? fromJson(dynamic value) => PluginJsonSchemaTypeTypeTransformer().decode(value); - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginJsonSchemaType.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } -} - -/// Transformation class that can [encode] an instance of [PluginJsonSchemaType] to String, -/// and [decode] dynamic data back to [PluginJsonSchemaType]. -class PluginJsonSchemaTypeTypeTransformer { - factory PluginJsonSchemaTypeTypeTransformer() => _instance ??= const PluginJsonSchemaTypeTypeTransformer._(); - - const PluginJsonSchemaTypeTypeTransformer._(); - - String encode(PluginJsonSchemaType data) => data.value; - - /// Decodes a [dynamic value][data] to a PluginJsonSchemaType. - /// - /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, - /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] - /// cannot be decoded successfully, then an [UnimplementedError] is thrown. - /// - /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, - /// and users are still using an old app with the old code. - PluginJsonSchemaType? decode(dynamic data, {bool allowNull = true}) { - if (data != null) { - switch (data) { - case r'string': return PluginJsonSchemaType.string; - case r'number': return PluginJsonSchemaType.number; - case r'integer': return PluginJsonSchemaType.integer; - case r'boolean': return PluginJsonSchemaType.boolean; - case r'object': return PluginJsonSchemaType.object; - case r'array': return PluginJsonSchemaType.array; - case r'null': return PluginJsonSchemaType.null_; - default: - if (!allowNull) { - throw ArgumentError('Unknown enum value to decode: $data'); - } - } - } - return null; - } - - /// Singleton [PluginJsonSchemaTypeTypeTransformer] instance. - static PluginJsonSchemaTypeTypeTransformer? _instance; -} - diff --git a/mobile/openapi/lib/model/plugin_method_response_dto.dart b/mobile/openapi/lib/model/plugin_method_response_dto.dart new file mode 100644 index 0000000000..2887f4cc16 --- /dev/null +++ b/mobile/openapi/lib/model/plugin_method_response_dto.dart @@ -0,0 +1,172 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class PluginMethodResponseDto { + /// Returns a new [PluginMethodResponseDto] instance. + PluginMethodResponseDto({ + required this.description, + required this.hostFunctions, + required this.key, + required this.name, + this.schema, + required this.title, + this.types = const [], + this.uiHints = const [], + }); + + /// Description + String description; + + bool hostFunctions; + + /// Key + String key; + + /// Name + String name; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + Object? schema; + + /// Title + String title; + + /// Workflow types + List types; + + /// Ui hints + List uiHints; + + @override + bool operator ==(Object other) => identical(this, other) || other is PluginMethodResponseDto && + other.description == description && + other.hostFunctions == hostFunctions && + other.key == key && + other.name == name && + other.schema == schema && + other.title == title && + _deepEquality.equals(other.types, types) && + _deepEquality.equals(other.uiHints, uiHints); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description.hashCode) + + (hostFunctions.hashCode) + + (key.hashCode) + + (name.hashCode) + + (schema == null ? 0 : schema!.hashCode) + + (title.hashCode) + + (types.hashCode) + + (uiHints.hashCode); + + @override + String toString() => 'PluginMethodResponseDto[description=$description, hostFunctions=$hostFunctions, key=$key, name=$name, schema=$schema, title=$title, types=$types, uiHints=$uiHints]'; + + Map toJson() { + final json = {}; + json[r'description'] = this.description; + json[r'hostFunctions'] = this.hostFunctions; + json[r'key'] = this.key; + json[r'name'] = this.name; + if (this.schema != null) { + json[r'schema'] = this.schema; + } else { + // json[r'schema'] = null; + } + json[r'title'] = this.title; + json[r'types'] = this.types; + json[r'uiHints'] = this.uiHints; + return json; + } + + /// Returns a new [PluginMethodResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PluginMethodResponseDto? fromJson(dynamic value) { + upgradeDto(value, "PluginMethodResponseDto"); + if (value is Map) { + final json = value.cast(); + + return PluginMethodResponseDto( + description: mapValueOfType(json, r'description')!, + hostFunctions: mapValueOfType(json, r'hostFunctions')!, + key: mapValueOfType(json, r'key')!, + name: mapValueOfType(json, r'name')!, + schema: mapValueOfType(json, r'schema'), + title: mapValueOfType(json, r'title')!, + types: WorkflowType.listFromJson(json[r'types']), + uiHints: json[r'uiHints'] is Iterable + ? (json[r'uiHints'] as Iterable).cast().toList(growable: false) + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PluginMethodResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PluginMethodResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PluginMethodResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PluginMethodResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'hostFunctions', + 'key', + 'name', + 'title', + 'types', + 'uiHints', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_response_dto.dart b/mobile/openapi/lib/model/plugin_response_dto.dart index 7a99896475..1bdb366f9e 100644 --- a/mobile/openapi/lib/model/plugin_response_dto.dart +++ b/mobile/openapi/lib/model/plugin_response_dto.dart @@ -13,21 +13,17 @@ part of openapi.api; class PluginResponseDto { /// Returns a new [PluginResponseDto] instance. PluginResponseDto({ - this.actions = const [], required this.author, required this.createdAt, required this.description, - this.filters = const [], required this.id, + this.methods = const [], required this.name, required this.title, required this.updatedAt, required this.version, }); - /// Plugin actions - List actions; - /// Plugin author String author; @@ -37,12 +33,12 @@ class PluginResponseDto { /// Plugin description String description; - /// Plugin filters - List filters; - /// Plugin ID String id; + /// Plugin methods + List methods; + /// Plugin name String name; @@ -57,12 +53,11 @@ class PluginResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is PluginResponseDto && - _deepEquality.equals(other.actions, actions) && other.author == author && other.createdAt == createdAt && other.description == description && - _deepEquality.equals(other.filters, filters) && other.id == id && + _deepEquality.equals(other.methods, methods) && other.name == name && other.title == title && other.updatedAt == updatedAt && @@ -71,28 +66,26 @@ class PluginResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (author.hashCode) + (createdAt.hashCode) + (description.hashCode) + - (filters.hashCode) + (id.hashCode) + + (methods.hashCode) + (name.hashCode) + (title.hashCode) + (updatedAt.hashCode) + (version.hashCode); @override - String toString() => 'PluginResponseDto[actions=$actions, author=$author, createdAt=$createdAt, description=$description, filters=$filters, id=$id, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; + String toString() => 'PluginResponseDto[author=$author, createdAt=$createdAt, description=$description, id=$id, methods=$methods, name=$name, title=$title, updatedAt=$updatedAt, version=$version]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; json[r'author'] = this.author; json[r'createdAt'] = this.createdAt; json[r'description'] = this.description; - json[r'filters'] = this.filters; json[r'id'] = this.id; + json[r'methods'] = this.methods; json[r'name'] = this.name; json[r'title'] = this.title; json[r'updatedAt'] = this.updatedAt; @@ -109,12 +102,11 @@ class PluginResponseDto { final json = value.cast(); return PluginResponseDto( - actions: PluginActionResponseDto.listFromJson(json[r'actions']), author: mapValueOfType(json, r'author')!, createdAt: mapValueOfType(json, r'createdAt')!, description: mapValueOfType(json, r'description')!, - filters: PluginFilterResponseDto.listFromJson(json[r'filters']), id: mapValueOfType(json, r'id')!, + methods: PluginMethodResponseDto.listFromJson(json[r'methods']), name: mapValueOfType(json, r'name')!, title: mapValueOfType(json, r'title')!, updatedAt: mapValueOfType(json, r'updatedAt')!, @@ -166,12 +158,11 @@ class PluginResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', 'author', 'createdAt', 'description', - 'filters', 'id', + 'methods', 'name', 'title', 'updatedAt', diff --git a/mobile/openapi/lib/model/plugin_trigger_response_dto.dart b/mobile/openapi/lib/model/plugin_trigger_response_dto.dart deleted file mode 100644 index a6ee1c6b69..0000000000 --- a/mobile/openapi/lib/model/plugin_trigger_response_dto.dart +++ /dev/null @@ -1,107 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class PluginTriggerResponseDto { - /// Returns a new [PluginTriggerResponseDto] instance. - PluginTriggerResponseDto({ - required this.contextType, - required this.type, - }); - - PluginContextType contextType; - - PluginTriggerType type; - - @override - bool operator ==(Object other) => identical(this, other) || other is PluginTriggerResponseDto && - other.contextType == contextType && - other.type == type; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (contextType.hashCode) + - (type.hashCode); - - @override - String toString() => 'PluginTriggerResponseDto[contextType=$contextType, type=$type]'; - - Map toJson() { - final json = {}; - json[r'contextType'] = this.contextType; - json[r'type'] = this.type; - return json; - } - - /// Returns a new [PluginTriggerResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static PluginTriggerResponseDto? fromJson(dynamic value) { - upgradeDto(value, "PluginTriggerResponseDto"); - if (value is Map) { - final json = value.cast(); - - return PluginTriggerResponseDto( - contextType: PluginContextType.fromJson(json[r'contextType'])!, - type: PluginTriggerType.fromJson(json[r'type'])!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = PluginTriggerResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = PluginTriggerResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of PluginTriggerResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = PluginTriggerResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'contextType', - 'type', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_action_item_dto.dart b/mobile/openapi/lib/model/workflow_action_item_dto.dart deleted file mode 100644 index 1ad70238d8..0000000000 --- a/mobile/openapi/lib/model/workflow_action_item_dto.dart +++ /dev/null @@ -1,107 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class WorkflowActionItemDto { - /// Returns a new [WorkflowActionItemDto] instance. - WorkflowActionItemDto({ - this.actionConfig = const {}, - required this.pluginActionId, - }); - - Map actionConfig; - - /// Plugin action ID - String pluginActionId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowActionItemDto && - _deepEquality.equals(other.actionConfig, actionConfig) && - other.pluginActionId == pluginActionId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (actionConfig.hashCode) + - (pluginActionId.hashCode); - - @override - String toString() => 'WorkflowActionItemDto[actionConfig=$actionConfig, pluginActionId=$pluginActionId]'; - - Map toJson() { - final json = {}; - json[r'actionConfig'] = this.actionConfig; - json[r'pluginActionId'] = this.pluginActionId; - return json; - } - - /// Returns a new [WorkflowActionItemDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowActionItemDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowActionItemDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowActionItemDto( - actionConfig: mapCastOfType(json, r'actionConfig') ?? const {}, - pluginActionId: mapValueOfType(json, r'pluginActionId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowActionItemDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowActionItemDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowActionItemDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowActionItemDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'pluginActionId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_action_response_dto.dart b/mobile/openapi/lib/model/workflow_action_response_dto.dart deleted file mode 100644 index 999d9d86cb..0000000000 --- a/mobile/openapi/lib/model/workflow_action_response_dto.dart +++ /dev/null @@ -1,142 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class WorkflowActionResponseDto { - /// Returns a new [WorkflowActionResponseDto] instance. - WorkflowActionResponseDto({ - required this.actionConfig, - required this.id, - required this.order, - required this.pluginActionId, - required this.workflowId, - }); - - Map? actionConfig; - - /// Action ID - String id; - - /// Action order - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int order; - - /// Plugin action ID - String pluginActionId; - - /// Workflow ID - String workflowId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowActionResponseDto && - _deepEquality.equals(other.actionConfig, actionConfig) && - other.id == id && - other.order == order && - other.pluginActionId == pluginActionId && - other.workflowId == workflowId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (actionConfig == null ? 0 : actionConfig!.hashCode) + - (id.hashCode) + - (order.hashCode) + - (pluginActionId.hashCode) + - (workflowId.hashCode); - - @override - String toString() => 'WorkflowActionResponseDto[actionConfig=$actionConfig, id=$id, order=$order, pluginActionId=$pluginActionId, workflowId=$workflowId]'; - - Map toJson() { - final json = {}; - if (this.actionConfig != null) { - json[r'actionConfig'] = this.actionConfig; - } else { - // json[r'actionConfig'] = null; - } - json[r'id'] = this.id; - json[r'order'] = this.order; - json[r'pluginActionId'] = this.pluginActionId; - json[r'workflowId'] = this.workflowId; - return json; - } - - /// Returns a new [WorkflowActionResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowActionResponseDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowActionResponseDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowActionResponseDto( - actionConfig: mapCastOfType(json, r'actionConfig'), - id: mapValueOfType(json, r'id')!, - order: mapValueOfType(json, r'order')!, - pluginActionId: mapValueOfType(json, r'pluginActionId')!, - workflowId: mapValueOfType(json, r'workflowId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowActionResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowActionResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowActionResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowActionResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'actionConfig', - 'id', - 'order', - 'pluginActionId', - 'workflowId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_create_dto.dart b/mobile/openapi/lib/model/workflow_create_dto.dart index 143af0ca6c..dfd2d51290 100644 --- a/mobile/openapi/lib/model/workflow_create_dto.dart +++ b/mobile/openapi/lib/model/workflow_create_dto.dart @@ -13,24 +13,14 @@ part of openapi.api; class WorkflowCreateDto { /// Returns a new [WorkflowCreateDto] instance. WorkflowCreateDto({ - this.actions = const [], this.description, this.enabled, - this.filters = const [], - required this.name, - required this.triggerType, + this.name, + this.steps = const [], + required this.trigger, }); - /// Workflow actions - List actions; - /// Workflow description - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? description; /// Workflow enabled @@ -42,39 +32,35 @@ class WorkflowCreateDto { /// bool? enabled; - /// Workflow filters - List filters; - /// Workflow name - String name; + String? name; - PluginTriggerType triggerType; + List steps; + + WorkflowTrigger trigger; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowCreateDto && - _deepEquality.equals(other.actions, actions) && other.description == description && other.enabled == enabled && - _deepEquality.equals(other.filters, filters) && other.name == name && - other.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (description == null ? 0 : description!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) + - (filters.hashCode) + - (name.hashCode) + - (triggerType.hashCode); + (name == null ? 0 : name!.hashCode) + + (steps.hashCode) + + (trigger.hashCode); @override - String toString() => 'WorkflowCreateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + String toString() => 'WorkflowCreateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; if (this.description != null) { json[r'description'] = this.description; } else { @@ -85,9 +71,13 @@ class WorkflowCreateDto { } else { // json[r'enabled'] = null; } - json[r'filters'] = this.filters; + if (this.name != null) { json[r'name'] = this.name; - json[r'triggerType'] = this.triggerType; + } else { + // json[r'name'] = null; + } + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; return json; } @@ -100,12 +90,11 @@ class WorkflowCreateDto { final json = value.cast(); return WorkflowCreateDto( - actions: WorkflowActionItemDto.listFromJson(json[r'actions']), description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled'), - filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), - name: mapValueOfType(json, r'name')!, - triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + name: mapValueOfType(json, r'name'), + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, ); } return null; @@ -153,10 +142,7 @@ class WorkflowCreateDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', - 'filters', - 'name', - 'triggerType', + 'trigger', }; } diff --git a/mobile/openapi/lib/model/workflow_filter_item_dto.dart b/mobile/openapi/lib/model/workflow_filter_item_dto.dart deleted file mode 100644 index 92224b9f16..0000000000 --- a/mobile/openapi/lib/model/workflow_filter_item_dto.dart +++ /dev/null @@ -1,107 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class WorkflowFilterItemDto { - /// Returns a new [WorkflowFilterItemDto] instance. - WorkflowFilterItemDto({ - this.filterConfig = const {}, - required this.pluginFilterId, - }); - - Map filterConfig; - - /// Plugin filter ID - String pluginFilterId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterItemDto && - _deepEquality.equals(other.filterConfig, filterConfig) && - other.pluginFilterId == pluginFilterId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (filterConfig.hashCode) + - (pluginFilterId.hashCode); - - @override - String toString() => 'WorkflowFilterItemDto[filterConfig=$filterConfig, pluginFilterId=$pluginFilterId]'; - - Map toJson() { - final json = {}; - json[r'filterConfig'] = this.filterConfig; - json[r'pluginFilterId'] = this.pluginFilterId; - return json; - } - - /// Returns a new [WorkflowFilterItemDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowFilterItemDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowFilterItemDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowFilterItemDto( - filterConfig: mapCastOfType(json, r'filterConfig') ?? const {}, - pluginFilterId: mapValueOfType(json, r'pluginFilterId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowFilterItemDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowFilterItemDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowFilterItemDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowFilterItemDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'pluginFilterId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_filter_response_dto.dart b/mobile/openapi/lib/model/workflow_filter_response_dto.dart deleted file mode 100644 index b9a841a68b..0000000000 --- a/mobile/openapi/lib/model/workflow_filter_response_dto.dart +++ /dev/null @@ -1,142 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.18 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class WorkflowFilterResponseDto { - /// Returns a new [WorkflowFilterResponseDto] instance. - WorkflowFilterResponseDto({ - required this.filterConfig, - required this.id, - required this.order, - required this.pluginFilterId, - required this.workflowId, - }); - - Map? filterConfig; - - /// Filter ID - String id; - - /// Filter order - /// - /// Minimum value: -9007199254740991 - /// Maximum value: 9007199254740991 - int order; - - /// Plugin filter ID - String pluginFilterId; - - /// Workflow ID - String workflowId; - - @override - bool operator ==(Object other) => identical(this, other) || other is WorkflowFilterResponseDto && - _deepEquality.equals(other.filterConfig, filterConfig) && - other.id == id && - other.order == order && - other.pluginFilterId == pluginFilterId && - other.workflowId == workflowId; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (filterConfig == null ? 0 : filterConfig!.hashCode) + - (id.hashCode) + - (order.hashCode) + - (pluginFilterId.hashCode) + - (workflowId.hashCode); - - @override - String toString() => 'WorkflowFilterResponseDto[filterConfig=$filterConfig, id=$id, order=$order, pluginFilterId=$pluginFilterId, workflowId=$workflowId]'; - - Map toJson() { - final json = {}; - if (this.filterConfig != null) { - json[r'filterConfig'] = this.filterConfig; - } else { - // json[r'filterConfig'] = null; - } - json[r'id'] = this.id; - json[r'order'] = this.order; - json[r'pluginFilterId'] = this.pluginFilterId; - json[r'workflowId'] = this.workflowId; - return json; - } - - /// Returns a new [WorkflowFilterResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static WorkflowFilterResponseDto? fromJson(dynamic value) { - upgradeDto(value, "WorkflowFilterResponseDto"); - if (value is Map) { - final json = value.cast(); - - return WorkflowFilterResponseDto( - filterConfig: mapCastOfType(json, r'filterConfig'), - id: mapValueOfType(json, r'id')!, - order: mapValueOfType(json, r'order')!, - pluginFilterId: mapValueOfType(json, r'pluginFilterId')!, - workflowId: mapValueOfType(json, r'workflowId')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = WorkflowFilterResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = WorkflowFilterResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of WorkflowFilterResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = WorkflowFilterResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'filterConfig', - 'id', - 'order', - 'pluginFilterId', - 'workflowId', - }; -} - diff --git a/mobile/openapi/lib/model/workflow_response_dto.dart b/mobile/openapi/lib/model/workflow_response_dto.dart index 6461b62508..f44506d69d 100644 --- a/mobile/openapi/lib/model/workflow_response_dto.dart +++ b/mobile/openapi/lib/model/workflow_response_dto.dart @@ -13,86 +13,83 @@ part of openapi.api; class WorkflowResponseDto { /// Returns a new [WorkflowResponseDto] instance. WorkflowResponseDto({ - this.actions = const [], required this.createdAt, required this.description, required this.enabled, - this.filters = const [], required this.id, required this.name, - required this.ownerId, - required this.triggerType, + this.steps = const [], + required this.trigger, + required this.updatedAt, }); - /// Workflow actions - List actions; - /// Creation date String createdAt; /// Workflow description - String description; + String? description; /// Workflow enabled bool enabled; - /// Workflow filters - List filters; - /// Workflow ID String id; /// Workflow name String? name; - /// Owner user ID - String ownerId; + /// Workflow steps + List steps; - PluginTriggerType triggerType; + WorkflowTrigger trigger; + + /// Update date + String updatedAt; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowResponseDto && - _deepEquality.equals(other.actions, actions) && other.createdAt == createdAt && other.description == description && other.enabled == enabled && - _deepEquality.equals(other.filters, filters) && other.id == id && other.name == name && - other.ownerId == ownerId && - other.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger && + other.updatedAt == updatedAt; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (createdAt.hashCode) + - (description.hashCode) + + (description == null ? 0 : description!.hashCode) + (enabled.hashCode) + - (filters.hashCode) + (id.hashCode) + (name == null ? 0 : name!.hashCode) + - (ownerId.hashCode) + - (triggerType.hashCode); + (steps.hashCode) + + (trigger.hashCode) + + (updatedAt.hashCode); @override - String toString() => 'WorkflowResponseDto[actions=$actions, createdAt=$createdAt, description=$description, enabled=$enabled, filters=$filters, id=$id, name=$name, ownerId=$ownerId, triggerType=$triggerType]'; + String toString() => 'WorkflowResponseDto[createdAt=$createdAt, description=$description, enabled=$enabled, id=$id, name=$name, steps=$steps, trigger=$trigger, updatedAt=$updatedAt]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; json[r'createdAt'] = this.createdAt; + if (this.description != null) { json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } json[r'enabled'] = this.enabled; - json[r'filters'] = this.filters; json[r'id'] = this.id; if (this.name != null) { json[r'name'] = this.name; } else { // json[r'name'] = null; } - json[r'ownerId'] = this.ownerId; - json[r'triggerType'] = this.triggerType; + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; + json[r'updatedAt'] = this.updatedAt; return json; } @@ -105,15 +102,14 @@ class WorkflowResponseDto { final json = value.cast(); return WorkflowResponseDto( - actions: WorkflowActionResponseDto.listFromJson(json[r'actions']), createdAt: mapValueOfType(json, r'createdAt')!, - description: mapValueOfType(json, r'description')!, + description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled')!, - filters: WorkflowFilterResponseDto.listFromJson(json[r'filters']), id: mapValueOfType(json, r'id')!, name: mapValueOfType(json, r'name'), - ownerId: mapValueOfType(json, r'ownerId')!, - triggerType: PluginTriggerType.fromJson(json[r'triggerType'])!, + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + updatedAt: mapValueOfType(json, r'updatedAt')!, ); } return null; @@ -161,15 +157,14 @@ class WorkflowResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'actions', 'createdAt', 'description', 'enabled', - 'filters', 'id', 'name', - 'ownerId', - 'triggerType', + 'steps', + 'trigger', + 'updatedAt', }; } diff --git a/mobile/openapi/lib/model/workflow_share_response_dto.dart b/mobile/openapi/lib/model/workflow_share_response_dto.dart new file mode 100644 index 0000000000..336e8503c5 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_share_response_dto.dart @@ -0,0 +1,134 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowShareResponseDto { + /// Returns a new [WorkflowShareResponseDto] instance. + WorkflowShareResponseDto({ + required this.description, + required this.name, + this.steps = const [], + required this.trigger, + }); + + /// Workflow description + String? description; + + /// Workflow name + String? name; + + /// Workflow steps + List steps; + + WorkflowTrigger trigger; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowShareResponseDto && + other.description == description && + other.name == name && + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description == null ? 0 : description!.hashCode) + + (name == null ? 0 : name!.hashCode) + + (steps.hashCode) + + (trigger.hashCode); + + @override + String toString() => 'WorkflowShareResponseDto[description=$description, name=$name, steps=$steps, trigger=$trigger]'; + + Map toJson() { + final json = {}; + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } + if (this.name != null) { + json[r'name'] = this.name; + } else { + // json[r'name'] = null; + } + json[r'steps'] = this.steps; + json[r'trigger'] = this.trigger; + return json; + } + + /// Returns a new [WorkflowShareResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowShareResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowShareResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowShareResponseDto( + description: mapValueOfType(json, r'description'), + name: mapValueOfType(json, r'name'), + steps: WorkflowShareStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowShareResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowShareResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowShareResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowShareResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'description', + 'name', + 'steps', + 'trigger', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_share_step_dto.dart b/mobile/openapi/lib/model/workflow_share_step_dto.dart new file mode 100644 index 0000000000..79c55ef716 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_share_step_dto.dart @@ -0,0 +1,131 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowShareStepDto { + /// Returns a new [WorkflowShareStepDto] instance. + WorkflowShareStepDto({ + this.config = const {}, + this.enabled, + required this.method, + }); + + /// Step configuration + Map? config; + + /// Step is enabled + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// Step plugin method + String method; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowShareStepDto && + _deepEquality.equals(other.config, config) && + other.enabled == enabled && + other.method == method; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (config == null ? 0 : config!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (method.hashCode); + + @override + String toString() => 'WorkflowShareStepDto[config=$config, enabled=$enabled, method=$method]'; + + Map toJson() { + final json = {}; + if (this.config != null) { + json[r'config'] = this.config; + } else { + // json[r'config'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'method'] = this.method; + return json; + } + + /// Returns a new [WorkflowShareStepDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowShareStepDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowShareStepDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowShareStepDto( + config: mapCastOfType(json, r'config'), + enabled: mapValueOfType(json, r'enabled'), + method: mapValueOfType(json, r'method')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowShareStepDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowShareStepDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowShareStepDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowShareStepDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'config', + 'method', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_step_dto.dart b/mobile/openapi/lib/model/workflow_step_dto.dart new file mode 100644 index 0000000000..e881ad3150 --- /dev/null +++ b/mobile/openapi/lib/model/workflow_step_dto.dart @@ -0,0 +1,131 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowStepDto { + /// Returns a new [WorkflowStepDto] instance. + WorkflowStepDto({ + this.config = const {}, + this.enabled, + required this.method, + }); + + /// Step configuration + Map? config; + + /// Step is enabled + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + bool? enabled; + + /// Step plugin method + String method; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowStepDto && + _deepEquality.equals(other.config, config) && + other.enabled == enabled && + other.method == method; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (config == null ? 0 : config!.hashCode) + + (enabled == null ? 0 : enabled!.hashCode) + + (method.hashCode); + + @override + String toString() => 'WorkflowStepDto[config=$config, enabled=$enabled, method=$method]'; + + Map toJson() { + final json = {}; + if (this.config != null) { + json[r'config'] = this.config; + } else { + // json[r'config'] = null; + } + if (this.enabled != null) { + json[r'enabled'] = this.enabled; + } else { + // json[r'enabled'] = null; + } + json[r'method'] = this.method; + return json; + } + + /// Returns a new [WorkflowStepDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowStepDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowStepDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowStepDto( + config: mapCastOfType(json, r'config'), + enabled: mapValueOfType(json, r'enabled'), + method: mapValueOfType(json, r'method')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowStepDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowStepDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowStepDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowStepDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'config', + 'method', + }; +} + diff --git a/mobile/openapi/lib/model/plugin_trigger_type.dart b/mobile/openapi/lib/model/workflow_trigger.dart similarity index 50% rename from mobile/openapi/lib/model/plugin_trigger_type.dart rename to mobile/openapi/lib/model/workflow_trigger.dart index 3ebcef7a95..47bf95e05e 100644 --- a/mobile/openapi/lib/model/plugin_trigger_type.dart +++ b/mobile/openapi/lib/model/workflow_trigger.dart @@ -11,9 +11,9 @@ part of openapi.api; /// Plugin trigger type -class PluginTriggerType { +class WorkflowTrigger { /// Instantiate a new enum with the provided [value]. - const PluginTriggerType._(this.value); + const WorkflowTrigger._(this.value); /// The underlying value of this enum member. final String value; @@ -23,22 +23,22 @@ class PluginTriggerType { String toJson() => value; - static const assetCreate = PluginTriggerType._(r'AssetCreate'); - static const personRecognized = PluginTriggerType._(r'PersonRecognized'); + static const assetCreate = WorkflowTrigger._(r'AssetCreate'); + static const personRecognized = WorkflowTrigger._(r'PersonRecognized'); - /// List of all possible values in this [enum][PluginTriggerType]. - static const values = [ + /// List of all possible values in this [enum][WorkflowTrigger]. + static const values = [ assetCreate, personRecognized, ]; - static PluginTriggerType? fromJson(dynamic value) => PluginTriggerTypeTypeTransformer().decode(value); + static WorkflowTrigger? fromJson(dynamic value) => WorkflowTriggerTypeTransformer().decode(value); - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = PluginTriggerType.fromJson(row); + final value = WorkflowTrigger.fromJson(row); if (value != null) { result.add(value); } @@ -48,16 +48,16 @@ class PluginTriggerType { } } -/// Transformation class that can [encode] an instance of [PluginTriggerType] to String, -/// and [decode] dynamic data back to [PluginTriggerType]. -class PluginTriggerTypeTypeTransformer { - factory PluginTriggerTypeTypeTransformer() => _instance ??= const PluginTriggerTypeTypeTransformer._(); +/// Transformation class that can [encode] an instance of [WorkflowTrigger] to String, +/// and [decode] dynamic data back to [WorkflowTrigger]. +class WorkflowTriggerTypeTransformer { + factory WorkflowTriggerTypeTransformer() => _instance ??= const WorkflowTriggerTypeTransformer._(); - const PluginTriggerTypeTypeTransformer._(); + const WorkflowTriggerTypeTransformer._(); - String encode(PluginTriggerType data) => data.value; + String encode(WorkflowTrigger data) => data.value; - /// Decodes a [dynamic value][data] to a PluginTriggerType. + /// Decodes a [dynamic value][data] to a WorkflowTrigger. /// /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] @@ -65,11 +65,11 @@ class PluginTriggerTypeTypeTransformer { /// /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, /// and users are still using an old app with the old code. - PluginTriggerType? decode(dynamic data, {bool allowNull = true}) { + WorkflowTrigger? decode(dynamic data, {bool allowNull = true}) { if (data != null) { switch (data) { - case r'AssetCreate': return PluginTriggerType.assetCreate; - case r'PersonRecognized': return PluginTriggerType.personRecognized; + case r'AssetCreate': return WorkflowTrigger.assetCreate; + case r'PersonRecognized': return WorkflowTrigger.personRecognized; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); @@ -79,7 +79,7 @@ class PluginTriggerTypeTypeTransformer { return null; } - /// Singleton [PluginTriggerTypeTypeTransformer] instance. - static PluginTriggerTypeTypeTransformer? _instance; + /// Singleton [WorkflowTriggerTypeTransformer] instance. + static WorkflowTriggerTypeTransformer? _instance; } diff --git a/mobile/openapi/lib/model/workflow_trigger_response_dto.dart b/mobile/openapi/lib/model/workflow_trigger_response_dto.dart new file mode 100644 index 0000000000..6e24e1559a --- /dev/null +++ b/mobile/openapi/lib/model/workflow_trigger_response_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class WorkflowTriggerResponseDto { + /// Returns a new [WorkflowTriggerResponseDto] instance. + WorkflowTriggerResponseDto({ + required this.trigger, + this.types = const [], + }); + + WorkflowTrigger trigger; + + /// Workflow types + List types; + + @override + bool operator ==(Object other) => identical(this, other) || other is WorkflowTriggerResponseDto && + other.trigger == trigger && + _deepEquality.equals(other.types, types); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (trigger.hashCode) + + (types.hashCode); + + @override + String toString() => 'WorkflowTriggerResponseDto[trigger=$trigger, types=$types]'; + + Map toJson() { + final json = {}; + json[r'trigger'] = this.trigger; + json[r'types'] = this.types; + return json; + } + + /// Returns a new [WorkflowTriggerResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static WorkflowTriggerResponseDto? fromJson(dynamic value) { + upgradeDto(value, "WorkflowTriggerResponseDto"); + if (value is Map) { + final json = value.cast(); + + return WorkflowTriggerResponseDto( + trigger: WorkflowTrigger.fromJson(json[r'trigger'])!, + types: WorkflowType.listFromJson(json[r'types']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowTriggerResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = WorkflowTriggerResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of WorkflowTriggerResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = WorkflowTriggerResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'trigger', + 'types', + }; +} + diff --git a/mobile/openapi/lib/model/workflow_type.dart b/mobile/openapi/lib/model/workflow_type.dart new file mode 100644 index 0000000000..c18b07e9fb --- /dev/null +++ b/mobile/openapi/lib/model/workflow_type.dart @@ -0,0 +1,85 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +/// Workflow type +class WorkflowType { + /// Instantiate a new enum with the provided [value]. + const WorkflowType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const assetV1 = WorkflowType._(r'AssetV1'); + static const assetPersonV1 = WorkflowType._(r'AssetPersonV1'); + + /// List of all possible values in this [enum][WorkflowType]. + static const values = [ + assetV1, + assetPersonV1, + ]; + + static WorkflowType? fromJson(dynamic value) => WorkflowTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = WorkflowType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [WorkflowType] to String, +/// and [decode] dynamic data back to [WorkflowType]. +class WorkflowTypeTypeTransformer { + factory WorkflowTypeTypeTransformer() => _instance ??= const WorkflowTypeTypeTransformer._(); + + const WorkflowTypeTypeTransformer._(); + + String encode(WorkflowType data) => data.value; + + /// Decodes a [dynamic value][data] to a WorkflowType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + WorkflowType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'AssetV1': return WorkflowType.assetV1; + case r'AssetPersonV1': return WorkflowType.assetPersonV1; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [WorkflowTypeTypeTransformer] instance. + static WorkflowTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/workflow_update_dto.dart b/mobile/openapi/lib/model/workflow_update_dto.dart index 9abb45ddd5..0bce75283a 100644 --- a/mobile/openapi/lib/model/workflow_update_dto.dart +++ b/mobile/openapi/lib/model/workflow_update_dto.dart @@ -13,24 +13,14 @@ part of openapi.api; class WorkflowUpdateDto { /// Returns a new [WorkflowUpdateDto] instance. WorkflowUpdateDto({ - this.actions = const [], this.description, this.enabled, - this.filters = const [], this.name, - this.triggerType, + this.steps = const [], + this.trigger, }); - /// Workflow actions - List actions; - /// Workflow description - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? description; /// Workflow enabled @@ -42,51 +32,41 @@ class WorkflowUpdateDto { /// bool? enabled; - /// Workflow filters - List filters; - /// Workflow name - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// String? name; + List steps; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - PluginTriggerType? triggerType; + WorkflowTrigger? trigger; @override bool operator ==(Object other) => identical(this, other) || other is WorkflowUpdateDto && - _deepEquality.equals(other.actions, actions) && other.description == description && other.enabled == enabled && - _deepEquality.equals(other.filters, filters) && other.name == name && - other.triggerType == triggerType; + _deepEquality.equals(other.steps, steps) && + other.trigger == trigger; @override int get hashCode => // ignore: unnecessary_parenthesis - (actions.hashCode) + (description == null ? 0 : description!.hashCode) + (enabled == null ? 0 : enabled!.hashCode) + - (filters.hashCode) + (name == null ? 0 : name!.hashCode) + - (triggerType == null ? 0 : triggerType!.hashCode); + (steps.hashCode) + + (trigger == null ? 0 : trigger!.hashCode); @override - String toString() => 'WorkflowUpdateDto[actions=$actions, description=$description, enabled=$enabled, filters=$filters, name=$name, triggerType=$triggerType]'; + String toString() => 'WorkflowUpdateDto[description=$description, enabled=$enabled, name=$name, steps=$steps, trigger=$trigger]'; Map toJson() { final json = {}; - json[r'actions'] = this.actions; if (this.description != null) { json[r'description'] = this.description; } else { @@ -97,16 +77,16 @@ class WorkflowUpdateDto { } else { // json[r'enabled'] = null; } - json[r'filters'] = this.filters; if (this.name != null) { json[r'name'] = this.name; } else { // json[r'name'] = null; } - if (this.triggerType != null) { - json[r'triggerType'] = this.triggerType; + json[r'steps'] = this.steps; + if (this.trigger != null) { + json[r'trigger'] = this.trigger; } else { - // json[r'triggerType'] = null; + // json[r'trigger'] = null; } return json; } @@ -120,12 +100,11 @@ class WorkflowUpdateDto { final json = value.cast(); return WorkflowUpdateDto( - actions: WorkflowActionItemDto.listFromJson(json[r'actions']), description: mapValueOfType(json, r'description'), enabled: mapValueOfType(json, r'enabled'), - filters: WorkflowFilterItemDto.listFromJson(json[r'filters']), name: mapValueOfType(json, r'name'), - triggerType: PluginTriggerType.fromJson(json[r'triggerType']), + steps: WorkflowStepDto.listFromJson(json[r'steps']), + trigger: WorkflowTrigger.fromJson(json[r'trigger']), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9c2b3f32a6..b11e19af75 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8620,8 +8620,61 @@ "/plugins": { "get": { "description": "Retrieve a list of plugins available to the authenticated user.", - "operationId": "getPlugins", - "parameters": [], + "operationId": "searchPlugins", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Whether the plugin is enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Plugin ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "version", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "content": { @@ -8654,30 +8707,106 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, - "/plugins/triggers": { + "/plugins/methods": { "get": { - "description": "Retrieve a list of all available plugin triggers.", - "operationId": "getPluginTriggers", - "parameters": [], + "description": "Retrieve a list of plugin methods", + "operationId": "searchPluginMethods", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Whether the plugin method is enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Plugin method ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "pluginName", + "required": false, + "in": "query", + "description": "Plugin name", + "schema": { + "type": "string" + } + }, + { + "name": "pluginVersion", + "required": false, + "in": "query", + "description": "Plugin version", + "schema": { + "type": "string" + } + }, + { + "name": "title", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "trigger", + "required": false, + "in": "query", + "description": "Workflow trigger", + "schema": { + "$ref": "#/components/schemas/WorkflowTrigger" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Workflow types", + "schema": { + "$ref": "#/components/schemas/WorkflowType" + } + } + ], "responses": { "200": { "content": { "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/PluginTriggerResponseDto" + "$ref": "#/components/schemas/PluginMethodResponseDto" }, "type": "array" } @@ -8697,22 +8826,17 @@ "api_key": [] } ], - "summary": "List all plugin triggers", + "summary": "Retrieve plugin methods", "tags": [ "Plugins" ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/plugins/{id}": { @@ -8760,16 +8884,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "plugin.read", - "x-immich-state": "Alpha" + "x-immich-permission": "plugin.read" } }, "/queues": { @@ -14722,8 +14841,56 @@ "/workflows": { "get": { "description": "Retrieve a list of workflows available to the authenticated user.", - "operationId": "getWorkflows", - "parameters": [], + "operationId": "searchWorkflows", + "parameters": [ + { + "name": "description", + "required": false, + "in": "query", + "description": "Workflow description", + "schema": { + "type": "string" + } + }, + { + "name": "enabled", + "required": false, + "in": "query", + "description": "Workflow enabled", + "schema": { + "type": "boolean" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "description": "Workflow ID", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "name", + "required": false, + "in": "query", + "description": "Workflow name", + "schema": { + "type": "string" + } + }, + { + "name": "trigger", + "required": false, + "in": "query", + "description": "Workflow trigger type", + "schema": { + "$ref": "#/components/schemas/WorkflowTrigger" + } + } + ], "responses": { "200": { "content": { @@ -14756,16 +14923,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "post": { "description": "Create a new workflow, the workflow can also be created with empty filters and actions.", @@ -14810,16 +14972,54 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.create", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.create" + } + }, + "/workflows/triggers": { + "get": { + "description": "Retrieve a list of all available workflow triggers.", + "operationId": "getWorkflowTriggers", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/WorkflowTriggerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List all workflow triggers", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ] } }, "/workflows/{id}": { @@ -14860,16 +15060,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.delete", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.delete" }, "get": { "description": "Retrieve information about a specific workflow by its ID.", @@ -14915,16 +15110,11 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.read", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.read" }, "put": { "description": "Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.", @@ -14980,16 +15170,63 @@ ], "x-immich-history": [ { - "version": "v2.3.0", + "version": "v3.0.0", "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" } ], - "x-immich-permission": "workflow.update", - "x-immich-state": "Alpha" + "x-immich-permission": "workflow.update" + } + }, + "/workflows/{id}/share": { + "get": { + "description": "Retrieve a workflow details without ids, default values, etc.", + "operationId": "getWorkflowForShare", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowShareResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve a workflow", + "tags": [ + "Workflows" + ], + "x-immich-history": [ + { + "version": "v3.0.0", + "state": "Added" + } + ], + "x-immich-permission": "workflow.read" } } }, @@ -17891,7 +18128,7 @@ "VersionCheck", "OcrQueueAll", "Ocr", - "WorkflowRun" + "WorkflowAssetCreate" ], "type": "string" }, @@ -19809,206 +20046,59 @@ ], "type": "object" }, - "PluginActionResponseDto": { + "PluginMethodResponseDto": { "properties": { "description": { - "description": "Action description", + "description": "Description", "type": "string" }, - "id": { - "description": "Action ID", - "type": "string" - }, - "methodName": { - "description": "Method name", - "type": "string" - }, - "pluginId": { - "description": "Plugin ID", - "type": "string" - }, - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginJsonSchema" - } - ], - "description": "Action schema", - "nullable": true - }, - "supportedContexts": { - "description": "Supported contexts", - "items": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": "array" - }, - "title": { - "description": "Action title", - "type": "string" - } - }, - "required": [ - "description", - "id", - "methodName", - "pluginId", - "schema", - "supportedContexts", - "title" - ], - "type": "object" - }, - "PluginConfigValue": {}, - "PluginContextType": { - "description": "Plugin context", - "enum": [ - "asset", - "album", - "person" - ], - "type": "string" - }, - "PluginFilterResponseDto": { - "properties": { - "description": { - "description": "Filter description", - "type": "string" - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "methodName": { - "description": "Method name", - "type": "string" - }, - "pluginId": { - "description": "Plugin ID", - "type": "string" - }, - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/PluginJsonSchema" - } - ], - "description": "Filter schema", - "nullable": true - }, - "supportedContexts": { - "description": "Supported contexts", - "items": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": "array" - }, - "title": { - "description": "Filter title", - "type": "string" - } - }, - "required": [ - "description", - "id", - "methodName", - "pluginId", - "schema", - "supportedContexts", - "title" - ], - "type": "object" - }, - "PluginJsonSchema": { - "properties": { - "additionalProperties": { + "hostFunctions": { "type": "boolean" }, - "description": { + "key": { + "description": "Key", "type": "string" }, - "properties": { - "additionalProperties": { - "$ref": "#/components/schemas/PluginJsonSchemaProperty" - }, - "type": "object" - }, - "required": { - "items": { - "type": "string" - }, - "type": "array" - }, - "type": { - "$ref": "#/components/schemas/PluginJsonSchemaType" - } - }, - "type": "object" - }, - "PluginJsonSchemaProperty": { - "properties": { - "additionalProperties": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/components/schemas/PluginJsonSchemaProperty" - } - ] - }, - "default": {}, - "description": { + "name": { + "description": "Name", "type": "string" }, - "enum": { - "items": { - "type": "string" - }, - "type": "array" - }, - "items": { - "$ref": "#/components/schemas/PluginJsonSchemaProperty" - }, - "properties": { - "additionalProperties": { - "$ref": "#/components/schemas/PluginJsonSchemaProperty" - }, + "schema": { + "properties": {}, "type": "object" }, - "required": { + "title": { + "description": "Title", + "type": "string" + }, + "types": { + "description": "Workflow types", + "items": { + "$ref": "#/components/schemas/WorkflowType" + }, + "type": "array" + }, + "uiHints": { + "description": "Ui hints", "items": { "type": "string" }, "type": "array" - }, - "type": { - "$ref": "#/components/schemas/PluginJsonSchemaType" } }, - "type": "object" - }, - "PluginJsonSchemaType": { - "enum": [ - "string", - "number", - "integer", - "boolean", - "object", - "array", - "null" + "required": [ + "description", + "hostFunctions", + "key", + "name", + "title", + "types", + "uiHints" ], - "type": "string" + "type": "object" }, "PluginResponseDto": { "properties": { - "actions": { - "description": "Plugin actions", - "items": { - "$ref": "#/components/schemas/PluginActionResponseDto" - }, - "type": "array" - }, "author": { "description": "Plugin author", "type": "string" @@ -20021,17 +20111,17 @@ "description": "Plugin description", "type": "string" }, - "filters": { - "description": "Plugin filters", - "items": { - "$ref": "#/components/schemas/PluginFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Plugin ID", "type": "string" }, + "methods": { + "description": "Plugin methods", + "items": { + "$ref": "#/components/schemas/PluginMethodResponseDto" + }, + "type": "array" + }, "name": { "description": "Plugin name", "type": "string" @@ -20050,12 +20140,11 @@ } }, "required": [ - "actions", "author", "createdAt", "description", - "filters", "id", + "methods", "name", "title", "updatedAt", @@ -20063,29 +20152,6 @@ ], "type": "object" }, - "PluginTriggerResponseDto": { - "properties": { - "contextType": { - "$ref": "#/components/schemas/PluginContextType" - }, - "type": { - "$ref": "#/components/schemas/PluginTriggerType" - } - }, - "required": [ - "contextType", - "type" - ], - "type": "object" - }, - "PluginTriggerType": { - "description": "Plugin trigger type", - "enum": [ - "AssetCreate", - "PersonRecognized" - ], - "type": "string" - }, "PurchaseResponse": { "properties": { "hideBuyButtonUntil": { @@ -26009,196 +26075,53 @@ ], "type": "string" }, - "WorkflowActionConfig": { - "additionalProperties": { - "$ref": "#/components/schemas/PluginConfigValue" - }, - "type": "object" - }, - "WorkflowActionItemDto": { - "properties": { - "actionConfig": { - "$ref": "#/components/schemas/WorkflowActionConfig" - }, - "pluginActionId": { - "description": "Plugin action ID", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" - } - }, - "required": [ - "pluginActionId" - ], - "type": "object" - }, - "WorkflowActionResponseDto": { - "properties": { - "actionConfig": { - "allOf": [ - { - "$ref": "#/components/schemas/WorkflowActionConfig" - } - ], - "nullable": true - }, - "id": { - "description": "Action ID", - "type": "string" - }, - "order": { - "description": "Action order", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "pluginActionId": { - "description": "Plugin action ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "actionConfig", - "id", - "order", - "pluginActionId", - "workflowId" - ], - "type": "object" - }, "WorkflowCreateDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" - }, - "type": "array" - }, "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" + "$ref": "#/components/schemas/WorkflowStepDto" }, "type": "array" }, - "name": { - "description": "Workflow name", - "type": "string" - }, - "triggerType": { - "$ref": "#/components/schemas/PluginTriggerType" + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Workflow trigger type" } }, "required": [ - "actions", - "filters", - "name", - "triggerType" - ], - "type": "object" - }, - "WorkflowFilterConfig": { - "additionalProperties": { - "$ref": "#/components/schemas/PluginConfigValue" - }, - "type": "object" - }, - "WorkflowFilterItemDto": { - "properties": { - "filterConfig": { - "$ref": "#/components/schemas/WorkflowFilterConfig" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "format": "uuid", - "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", - "type": "string" - } - }, - "required": [ - "pluginFilterId" - ], - "type": "object" - }, - "WorkflowFilterResponseDto": { - "properties": { - "filterConfig": { - "allOf": [ - { - "$ref": "#/components/schemas/WorkflowFilterConfig" - } - ], - "nullable": true - }, - "id": { - "description": "Filter ID", - "type": "string" - }, - "order": { - "description": "Filter order", - "maximum": 9007199254740991, - "minimum": -9007199254740991, - "type": "integer" - }, - "pluginFilterId": { - "description": "Plugin filter ID", - "type": "string" - }, - "workflowId": { - "description": "Workflow ID", - "type": "string" - } - }, - "required": [ - "filterConfig", - "id", - "order", - "pluginFilterId", - "workflowId" + "trigger" ], "type": "object" }, "WorkflowResponseDto": { "properties": { - "actions": { - "description": "Workflow actions", - "items": { - "$ref": "#/components/schemas/WorkflowActionResponseDto" - }, - "type": "array" - }, "createdAt": { "description": "Creation date", "type": "string" }, "description": { "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", - "items": { - "$ref": "#/components/schemas/WorkflowFilterResponseDto" - }, - "type": "array" - }, "id": { "description": "Workflow ID", "type": "string" @@ -26208,57 +26131,173 @@ "nullable": true, "type": "string" }, - "ownerId": { - "description": "Owner user ID", - "type": "string" - }, - "triggerType": { - "$ref": "#/components/schemas/PluginTriggerType" - } - }, - "required": [ - "actions", - "createdAt", - "description", - "enabled", - "filters", - "id", - "name", - "ownerId", - "triggerType" - ], - "type": "object" - }, - "WorkflowUpdateDto": { - "properties": { - "actions": { - "description": "Workflow actions", + "steps": { + "description": "Workflow steps", "items": { - "$ref": "#/components/schemas/WorkflowActionItemDto" + "$ref": "#/components/schemas/WorkflowStepDto" }, "type": "array" }, + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Workflow trigger type" + }, + "updatedAt": { + "description": "Update date", + "type": "string" + } + }, + "required": [ + "createdAt", + "description", + "enabled", + "id", + "name", + "steps", + "trigger", + "updatedAt" + ], + "type": "object" + }, + "WorkflowShareResponseDto": { + "properties": { "description": { "description": "Workflow description", + "nullable": true, + "type": "string" + }, + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { + "description": "Workflow steps", + "items": { + "$ref": "#/components/schemas/WorkflowShareStepDto" + }, + "type": "array" + }, + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Workflow trigger type" + } + }, + "required": [ + "description", + "name", + "steps", + "trigger" + ], + "type": "object" + }, + "WorkflowShareStepDto": { + "properties": { + "config": { + "additionalProperties": {}, + "description": "Step configuration", + "nullable": true, + "type": "object" + }, + "enabled": { + "description": "Step is enabled", + "type": "boolean" + }, + "method": { + "description": "Step plugin method", + "type": "string" + } + }, + "required": [ + "config", + "method" + ], + "type": "object" + }, + "WorkflowStepDto": { + "properties": { + "config": { + "additionalProperties": {}, + "description": "Step configuration", + "nullable": true, + "type": "object" + }, + "enabled": { + "description": "Step is enabled", + "type": "boolean" + }, + "method": { + "description": "Step plugin method", + "type": "string" + } + }, + "required": [ + "config", + "method" + ], + "type": "object" + }, + "WorkflowTrigger": { + "description": "Plugin trigger type", + "enum": [ + "AssetCreate", + "PersonRecognized" + ], + "type": "string" + }, + "WorkflowTriggerResponseDto": { + "properties": { + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Trigger type" + }, + "types": { + "description": "Workflow types", + "items": { + "$ref": "#/components/schemas/WorkflowType" + }, + "type": "array" + } + }, + "required": [ + "trigger", + "types" + ], + "type": "object" + }, + "WorkflowType": { + "description": "Workflow type", + "enum": [ + "AssetV1", + "AssetPersonV1" + ], + "type": "string" + }, + "WorkflowUpdateDto": { + "properties": { + "description": { + "description": "Workflow description", + "nullable": true, "type": "string" }, "enabled": { "description": "Workflow enabled", "type": "boolean" }, - "filters": { - "description": "Workflow filters", + "name": { + "description": "Workflow name", + "nullable": true, + "type": "string" + }, + "steps": { "items": { - "$ref": "#/components/schemas/WorkflowFilterItemDto" + "$ref": "#/components/schemas/WorkflowStepDto" }, "type": "array" }, - "name": { - "description": "Workflow name", - "type": "string" - }, - "triggerType": { - "$ref": "#/components/schemas/PluginTriggerType" + "trigger": { + "$ref": "#/components/schemas/WorkflowTrigger", + "description": "Workflow trigger type" } }, "type": "object" diff --git a/packages/plugin-core/.gitignore b/packages/plugin-core/.gitignore new file mode 100644 index 0000000000..c925c21d56 --- /dev/null +++ b/packages/plugin-core/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/plugin-core/.prettierrc b/packages/plugin-core/.prettierrc new file mode 100644 index 0000000000..7cebf3813c --- /dev/null +++ b/packages/plugin-core/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "semi": true +} diff --git a/packages/plugin-core/esbuild.js b/packages/plugin-core/esbuild.js new file mode 100644 index 0000000000..4e1eda47e2 --- /dev/null +++ b/packages/plugin-core/esbuild.js @@ -0,0 +1,11 @@ +const esbuild = require('esbuild'); + +esbuild.build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: false, + minify: false, // might want to use true for production build + format: 'cjs', // needs to be CJS for now + target: ['es2020'], // don't go over es2020 because quickjs doesn't support it +}); diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json new file mode 100644 index 0000000000..1dc6d21409 --- /dev/null +++ b/packages/plugin-core/manifest.json @@ -0,0 +1,258 @@ +{ + "name": "immich-plugin-core", + "version": "2.0.1", + "title": "Immich Core Plugin", + "description": "Core workflow capabilities for Immich", + "author": "Immich Team", + "wasmPath": "dist/plugin.wasm", + "methods": [ + { + "name": "assetFileFilter", + "title": "Filter by filename", + "description": "Filter assets by filename pattern using text matching or regular expressions", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "title": "Filename pattern", + "description": "Text or regex pattern to match against filename" + }, + "matchType": { + "type": "string", + "title": "Match type", + "enum": ["contains", "startsWith", "exact", "regex"], + "default": "contains", + "description": "Type of pattern matching to perform" + }, + "caseSensitive": { + "type": "boolean", + "default": false, + "title": "Case sensitive", + "description": "Whether matching should be case-sensitive" + } + }, + "required": ["pattern"] + }, + "uiHints": ["filter"] + }, + { + "name": "filterFileType", + "title": "Filter by file type", + "description": "Filter assets by file type", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "fileTypes": { + "title": "File types", + "description": "Allowed file types", + "type": "string", + "array": true, + "enum": ["image", "video"] + } + }, + "required": ["fileTypes"] + }, + "uiHints": ["filter"] + }, + { + "name": "filterPerson", + "title": "Filter by person", + "description": "Filter by detected person", + "types": ["AssetV1"], + "schema": { + "properties": { + "personIds": { + "type": "string", + "array": true, + "title": "Person IDs", + "description": "List of person to match", + "uiHint": "personI" + }, + "matchAny": { + "type": "boolean", + "title": "Match any", + "default": true, + "description": "Match any name (true) or require all names (false)" + } + }, + "required": ["personIds"] + }, + "uiHints": ["filter"] + }, + { + "name": "assetArchive", + "title": "Archive asset", + "description": "Change asset visibility to archive", + "types": ["AssetV1"], + "schema": { + "properties": { + "inverse": { + "title": "Inverse", + "description": "When true will unarchive any archived assets", + "type": "boolean" + } + } + } + }, + { + "name": "assetLock", + "title": "Move to locked folder", + "description": "Change visibility to locked", + "types": ["AssetV1"] + }, + { + "name": "assetTimeline", + "title": "Move to timeline", + "description": "Change visibility to timeline", + "types": ["AssetV1"] + }, + { + "name": "assetVisibility", + "title": "Update visibility", + "description": "Change visibility to selected option", + "types": ["AssetV1"], + "schema": { + "properties": { + "visibility": { + "title": "Visibility", + "description": "Asset visibility", + "type": "string", + "enum": ["archive", "timeline", "locked"] + } + }, + "required": ["visibility"] + } + }, + { + "name": "assetFavorite", + "title": "Favorite", + "description": "Favorite an asset", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "inverse": { + "type": "boolean", + "title": "Inverse", + "description": "Unfavorite by default, set to true to favorite instead", + "default": false + } + } + } + }, + { + "name": "assetAddToAlbums", + "title": "Add to Album(s)", + "description": "Add asset to selected albums", + "types": ["AssetV1"], + "hostFunctions": true, + "schema": { + "type": "object", + "properties": { + "albumIds": { + "type": "string", + "title": "Album IDs", + "array": true, + "description": "Target album IDs", + "uiHint": "albumId" + } + }, + "required": ["albumIds"] + } + }, + { + "name": "noop1", + "title": "DEV: Nested properties", + "description": "Example configuration with nested properties", + "types": ["AssetV1"], + "schema": { + "type": "object", + "properties": { + "number1": { + "type": "number", + "title": "Number 1", + "description": "Basic number" + }, + "number2": { + "type": "number", + "title": "Number 2", + "array": true, + "description": "List of numbers" + }, + "string1": { + "type": "string", + "title": "String 1", + "description": "Basic string" + }, + "string2": { + "type": "string", + "title": "String 2", + "array": true, + "description": "List of strings" + }, + "string3": { + "type": "string", + "title": "String 3", + "enum": ["choice-1", "choice-2"], + "description": "Select from a list" + }, + "nested": { + "type": "object", + "title": "Nested", + "description": "Nested properties for nesting", + "properties": { + "nested1": { + "type": "string", + "title": "Nested 1", + "description": "Nested string" + }, + "nested2": { + "type": "number", + "title": "Nested 2", + "description": "Nested number" + }, + "nested3": { + "type": "object", + "title": "Nested 3", + "description": "Nested again", + "properties": { + "nested4": { + "type": "boolean", + "title": "Nested 4", + "description": "Nested, nested boolean" + } + } + } + } + } + } + } + }, + { + "name": "noop2", + "title": "DEV: Album pickers", + "description": "Example configuration with album pickers", + "types": ["AssetV1"], + "schema": { + "properties": { + "albumId": { + "type": "string", + "title": "Album ID", + "description": "Target album ID", + "uiHint": "albumId" + }, + "albumIds": { + "type": "string", + "title": "Album IDs", + "description": "Target album IDs", + "array": true, + "uiHint": "albumId" + } + } + } + } + ] +} diff --git a/packages/plugin-core/mise.toml b/packages/plugin-core/mise.toml new file mode 100644 index 0000000000..cb69c36ee7 --- /dev/null +++ b/packages/plugin-core/mise.toml @@ -0,0 +1,6 @@ +[tasks.install] +run = "pnpm install --frozen-lockfile" + +[tasks.build] +depends = ["install"] +run = "pnpm --filter @immich/plugin-sdk --filter @immich/plugin-core build" diff --git a/packages/plugins/package.json b/packages/plugin-core/package.json similarity index 85% rename from packages/plugins/package.json rename to packages/plugin-core/package.json index 2aa1bd599f..7c0bdf9af2 100644 --- a/packages/plugins/package.json +++ b/packages/plugin-core/package.json @@ -1,5 +1,5 @@ { - "name": "plugins", + "name": "@immich/plugin-core", "version": "1.0.0", "description": "", "main": "src/index.ts", @@ -13,6 +13,7 @@ "license": "AGPL-3.0", "devDependencies": { "@extism/js-pdk": "^1.0.1", + "@immich/plugin-sdk": "workspace:*", "esbuild": "^0.28.0", "typescript": "^6.0.0" } diff --git a/packages/plugin-core/src/index.d.ts b/packages/plugin-core/src/index.d.ts new file mode 100644 index 0000000000..ae45184cbe --- /dev/null +++ b/packages/plugin-core/src/index.d.ts @@ -0,0 +1,19 @@ +// copy from +// import '@immich/plugin-sdk/host-functions'; +declare module 'extism:host' { + interface user { + albumAddAssets(ptr: PTR): I64; + addAssetsToAlbums(ptr: PTR): I64; + } +} + +declare module 'main' { + export function assetFileFilter(): I32; + export function assetFavorite(): I32; + export function assetVisibility(): I32; + export function assetArchive(): I32; + export function assetLock(): I32; + export function assetTimeline(): I32; + export function assetTrash(): I32; + export function assetAddToAlbums(): I32; +} diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts new file mode 100644 index 0000000000..de54e52bb0 --- /dev/null +++ b/packages/plugin-core/src/index.ts @@ -0,0 +1,111 @@ +import { AssetStatus, AssetVisibility, WorkflowType, wrapper } from '@immich/plugin-sdk'; + +type AssetFileFilterConfig = { + pattern: string; + matchType?: 'contains' | 'exact' | 'regex' | 'startsWith'; + caseSensitive?: boolean; +}; +export const assetFileFilter = () => { + return wrapper(({ data, config }) => { + const { pattern, matchType = 'contains', caseSensitive = false } = config; + + const { asset } = data; + + const fileName = asset.originalFileName || ''; + const searchName = caseSensitive ? fileName : fileName.toLowerCase(); + const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); + + switch (matchType) { + case 'contains': { + return { workflow: { continue: searchName.includes(searchPattern) } }; + } + + case 'exact': { + return { workflow: { continue: searchName === searchPattern } }; + } + + case 'startsWith': { + return { workflow: { continue: searchName.startsWith(searchPattern) } }; + } + + case 'regex': { + const flags = caseSensitive ? '' : 'i'; + const regex = new RegExp(searchPattern, flags); + return { workflow: { continue: regex.test(fileName) } }; + } + + default: { + return {}; + } + } + }); +}; + +export const assetFavorite = () => { + return wrapper(({ config, data }) => { + const target = config.inverse ? false : true; + if (target !== data.asset.isFavorite) { + return { + changes: { + asset: { isFavorite: target }, + }, + }; + } + }); +}; + +export const assetVisibility = () => { + return wrapper(({ config }) => ({ + changes: { asset: { visibility: config.visibility } }, + })); +}; + +export const assetArchive = () => { + return wrapper(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Archive } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Archive) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; + }); +}; + +export const assetLock = () => { + return wrapper(({ config, data }) => { + if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Locked } } }; + } + + if (config.inverse && data.asset.visibility === AssetVisibility.Locked) { + return { changes: { asset: { visibility: AssetVisibility.Timeline } } }; + } + + return {}; + }); +}; + +export const assetTrash = () => { + return wrapper(({ config, data }) => ({ + changes: { + asset: config.inverse + ? { deletedAt: null, status: AssetStatus.Active } + : { deletedAt: new Date(), status: AssetStatus.Trashed }, + }, + })); +}; + +export const assetAddToAlbums = () => { + return wrapper(({ config, data, functions }) => { + if (config.albumIds.length === 1) { + functions.albumAddAssets(config.albumIds[0], [data.asset.id]); + return {}; + } + + functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [data.asset.id] }); + return {}; + }); +}; diff --git a/packages/plugins/tsconfig.json b/packages/plugin-core/tsconfig.json similarity index 68% rename from packages/plugins/tsconfig.json rename to packages/plugin-core/tsconfig.json index a0bc730661..23ab692016 100644 --- a/packages/plugins/tsconfig.json +++ b/packages/plugin-core/tsconfig.json @@ -1,20 +1,24 @@ { "compilerOptions": { - "target": "es2020", // Specify ECMAScript target version - "module": "commonjs", // Specify module code generation - "lib": ["es2020"], // Specify a list of library files to be included in the compilation - "types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"], // Specify a list of type definition files to be included in the compilation - "strict": true, // Enable all strict type-checking options - "esModuleInterop": true, // Enables compatibility with Babel-style module imports - "skipLibCheck": true, // Skip type checking of declaration files "allowJs": true, // Allow JavaScript files to be compiled + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, // Enables compatibility with Babel-style module imports + "lib": ["es2020"], // Specify a list of library files to be included in the compilation + "module": "nodenext", // Specify module code generation + "moduleResolution": "nodenext", + "noEmit": true, // Do not emit outputs (no .js or .d.ts files) + "outDir": "./dist", "rootDir": "./src", - "noEmit": true // Do not emit outputs (no .js or .d.ts files) + "skipLibCheck": true, // Skip type checking of declaration files + "strict": true, // Enable all strict type-checking options + "target": "es2020", // Specify ECMAScript target version + "types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation }, - "include": [ - "src/**/*.ts" // Include all TypeScript files in src directory - ], "exclude": [ "node_modules" // Exclude the node_modules directory + ], + "include": [ + "src/**/*.ts" // Include all TypeScript files in src directory ] } diff --git a/packages/plugin-sdk/.gitignore b/packages/plugin-sdk/.gitignore new file mode 100644 index 0000000000..c925c21d56 --- /dev/null +++ b/packages/plugin-sdk/.gitignore @@ -0,0 +1,2 @@ +/dist +/node_modules diff --git a/packages/plugin-sdk/esbuild.js b/packages/plugin-sdk/esbuild.js new file mode 100644 index 0000000000..d2e036e5c7 --- /dev/null +++ b/packages/plugin-sdk/esbuild.js @@ -0,0 +1,11 @@ +import esbuild from 'esbuild'; + +esbuild.build({ + entryPoints: ['src/index.ts'], + outdir: 'dist', + bundle: true, + sourcemap: false, + minify: false, + format: 'esm', + target: ['es2020'], +}); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json new file mode 100644 index 0000000000..ff06c681fb --- /dev/null +++ b/packages/plugin-sdk/package.json @@ -0,0 +1,38 @@ +{ + "name": "@immich/plugin-sdk", + "version": "0.0.0", + "description": "", + "main": "index.js", + "type": "module", + "exports": { + "./host-functions": { + "import": "./dist/host-functions.js", + "types": "./dist/host-functions.d.ts" + }, + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "node esbuild.js && tsc --emitDeclarationOnly && tsc-alias" + }, + "files": [ + "dist" + ], + "keywords": [], + "author": "", + "license": "GNU Affero General Public License version 3", + "packageManager": "pnpm@10.30.3", + "devDependencies": { + "@extism/js-pdk": "^1.1.1", + "@types/node": "^24.11.0", + "esbuild": "^0.27.3", + "tsc-alias": "^1.8.16", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@extism/js-pdk": "^1.1.1" + } +} diff --git a/packages/plugin-sdk/src/enum.ts b/packages/plugin-sdk/src/enum.ts new file mode 100644 index 0000000000..a11dab64da --- /dev/null +++ b/packages/plugin-sdk/src/enum.ts @@ -0,0 +1,33 @@ +export enum WorkflowTrigger { + AssetCreate = 'AssetCreate', + PersonRecognized = 'PersonRecognized', +} + +export enum WorkflowType { + AssetV1 = 'AssetV1', + AssetPersonV1 = 'AssetPersonV1', +} + +export enum AssetType { + Image = 'IMAGE', + Video = 'VIDEO', + Audio = 'AUDIO', + Other = 'OTHER', +} + +export enum AssetStatus { + Active = 'active', + Trashed = 'trashed', + Deleted = 'deleted', +} + +export enum AssetVisibility { + Archive = 'archive', + Timeline = 'timeline', + + /** + * Video part of the LivePhotos and MotionPhotos + */ + Hidden = 'hidden', + Locked = 'locked', +} diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts new file mode 100644 index 0000000000..d0f8a3ef17 --- /dev/null +++ b/packages/plugin-sdk/src/host-functions.ts @@ -0,0 +1,51 @@ +declare module 'extism:host' { + interface user { + albumAddAssets(ptr: PTR): I64; + addAssetsToAlbums(ptr: PTR): I64; + } +} + +const host = Host.getFunctions(); +type HostFunctionName = keyof typeof host; +type HostFunctionSuccessResult = { success: true; response: T }; +type HostFunctionErrorResult = { + success: false; + status: number; + message: string; +}; +type HostFunctionResult = + | HostFunctionSuccessResult + | HostFunctionErrorResult; + +const call = (name: HostFunctionName, authToken: string, args: T) => { + const pointer1 = Memory.fromString(JSON.stringify({ authToken, args })); + const fn = host[name]; + const handler = Memory.find(fn(pointer1.offset)); + + try { + const result = JSON.parse(handler.readString()) as HostFunctionResult; + + if (result.success) { + return result.response; + } + + throw new Error( + `Failed to call host function "${String(name)}", received ${result.status} - ${JSON.stringify(result.message)}`, + ); + } finally { + handler.free(); + pointer1.free(); + } +}; + +type AlbumsToAssets = { + assetIds: string[]; + albumIds: string[]; +}; + +export const hostFunctions = (authToken: string) => ({ + albumAddAssets: (albumId: string, assetIds: string[]) => + call('albumAddAssets', authToken, [albumId, { ids: assetIds }]), + addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) => + call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]), +}); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts new file mode 100644 index 0000000000..6d4deb2053 --- /dev/null +++ b/packages/plugin-sdk/src/index.ts @@ -0,0 +1,4 @@ +export * from 'src/enum.js'; +export * from 'src/host-functions.js'; +export * from 'src/sdk.js'; +export * from 'src/types.js'; diff --git a/packages/plugin-sdk/src/sdk.ts b/packages/plugin-sdk/src/sdk.ts new file mode 100644 index 0000000000..f0ff8723a6 --- /dev/null +++ b/packages/plugin-sdk/src/sdk.ts @@ -0,0 +1,43 @@ +import type { WorkflowType } from 'src/enum.js'; +import { hostFunctions } from 'src/host-functions.js'; +import type { + ConfigValue, + WorkflowEventPayload, + WorkflowResponse, +} from 'src/types.js'; + +export const wrapper = < + T extends WorkflowType = WorkflowType, + TConfig extends ConfigValue = ConfigValue, +>( + fn: ( + payload: WorkflowEventPayload & { + functions: ReturnType; + }, + ) => WorkflowResponse | undefined, +) => { + const input = Host.inputString(); + + try { + const event = JSON.parse(input) as WorkflowEventPayload; + // const debug = event.workflow.debug ?? false; + + console.debug( + `Inputs: trigger=${event.trigger}, event=${event.type}, config=${JSON.stringify(event.config)}`, + ); + + const response = + fn({ ...event, functions: hostFunctions(event.workflow.authToken) }) ?? + {}; + + console.debug( + `Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}`, + ); + + const output = JSON.stringify(response); + Host.outputString(output); + } catch (error: Error | any) { + console.error(`Unhandled plugin exception: ${error.message || error}`); + throw error; + } +}; diff --git a/packages/plugin-sdk/src/types.ts b/packages/plugin-sdk/src/types.ts new file mode 100644 index 0000000000..54cca3a5aa --- /dev/null +++ b/packages/plugin-sdk/src/types.ts @@ -0,0 +1,129 @@ +import type { + AssetStatus, + AssetType, + AssetVisibility, + WorkflowTrigger, + WorkflowType, +} from 'src/enum.js'; + +type DeepPartial = T extends Date + ? T + : T extends Record + ? { [K in keyof T]?: DeepPartial } + : T extends Array + ? DeepPartial[] + : T; + +export type WorkflowEventMap = { + [WorkflowType.AssetV1]: AssetV1; + [WorkflowType.AssetPersonV1]: AssetPersonV1; +}; + +export type WorkflowEventData = WorkflowEventMap[T]; + +export type WorkflowEventPayload< + T extends WorkflowType = WorkflowType, + TConfig = WorkflowStepConfig, +> = { + trigger: WorkflowTrigger; + type: T; + data: WorkflowEventData; + config: TConfig; + workflow: { + id: string; + authToken: string; + stepId: string; + debug?: boolean; + }; +}; + +export type WorkflowChanges = + DeepPartial>; + +export type WorkflowResponse = { + workflow?: { + /** stop the workflow */ + continue?: boolean; + }; + changes?: WorkflowChanges; + /** data to be passed to the next workflow step */ + data?: Record; +}; + +export type WorkflowStepConfig = { + [key: string]: ConfigValue; +}; + +export type ConfigValue = + | string + | number + | boolean + | null + | ConfigValue[] + | { [key: string]: ConfigValue }; + +export type AssetV1 = { + asset: { + id: string; + ownerId: string; + type: AssetType; + originalPath: string; + fileCreatedAt: Date; + fileModifiedAt: Date; + isFavorite: boolean; + checksum: Buffer; // sha1 checksum + livePhotoVideoId: string | null; + updatedAt: Date; + createdAt: Date; + originalFileName: string; + isOffline: boolean; + libraryId: string | null; + isExternal: boolean; + deletedAt: Date | null; + localDateTime: Date; + stackId: string | null; + duplicateId: string | null; + status: AssetStatus; + visibility: AssetVisibility; + isEdited: boolean; + exifInfo: { + make: string | null; + model: string | null; + exifImageWidth: number | null; + exifImageHeight: number | null; + fileSizeInByte: number | null; + orientation: string | null; + dateTimeOriginal: Date | null; + modifyDate: Date | null; + lensModel: string | null; + fNumber: number | null; + focalLength: number | null; + iso: number | null; + latitude: number | null; + longitude: number | null; + city: string | null; + state: string | null; + country: string | null; + description: string | null; + fps: number | null; + exposureTime: string | null; + livePhotoCID: string | null; + timeZone: string | null; + projectionType: string | null; + profileDescription: string | null; + colorspace: string | null; + bitsPerSample: number | null; + autoStackId: string | null; + rating: number | null; + tags: string[] | null; + updatedAt: Date | null; + } | null; + }; +}; + +export type AssetPersonV1 = AssetV1 & { + person: { + id: string; + name: string; + }; +}; diff --git a/packages/plugin-sdk/tsconfig.json b/packages/plugin-sdk/tsconfig.json new file mode 100644 index 0000000000..2dbd91de0a --- /dev/null +++ b/packages/plugin-sdk/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": true, + "isolatedModules": true, + "lib": ["esnext"], + "module": "nodenext", + "moduleDetection": "force", + "noUncheckedIndexedAccess": true, + "noUncheckedSideEffectImports": true, + "outDir": "./dist", + "paths": { + "src/*": ["./src/*"] + }, + "removeComments": true, + "rootDir": "./src", + "skipLibCheck": true, + "sourceMap": false, + "strict": true, + "target": "esnext", + "types": ["node", "@extism/js-pdk"], + "verbatimModuleSyntax": true + } +} diff --git a/packages/plugins/.gitignore b/packages/plugins/.gitignore deleted file mode 100644 index 76add878f8..0000000000 --- a/packages/plugins/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/packages/plugins/LICENSE b/packages/plugins/LICENSE deleted file mode 100644 index 53f0fa6953..0000000000 --- a/packages/plugins/LICENSE +++ /dev/null @@ -1,26 +0,0 @@ -Copyright 2024, The Extism Authors. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/plugins/esbuild.js b/packages/plugins/esbuild.js deleted file mode 100644 index 04cb6e85aa..0000000000 --- a/packages/plugins/esbuild.js +++ /dev/null @@ -1,12 +0,0 @@ -const esbuild = require('esbuild'); - -esbuild - .build({ - entryPoints: ['src/index.ts'], - outdir: 'dist', - bundle: true, - sourcemap: true, - minify: false, // might want to use true for production build - format: 'cjs', // needs to be CJS for now - target: ['es2020'] // don't go over es2020 because quickjs doesn't support it - }) \ No newline at end of file diff --git a/packages/plugins/manifest.json b/packages/plugins/manifest.json deleted file mode 100644 index 4d2de275ca..0000000000 --- a/packages/plugins/manifest.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "name": "immich-core", - "version": "2.0.1", - "title": "Immich Core", - "description": "Core workflow capabilities for Immich", - "author": "Immich Team", - "wasm": { - "path": "dist/plugin.wasm" - }, - "filters": [ - { - "methodName": "filterFileName", - "title": "Filter by filename", - "description": "Filter assets by filename pattern using text matching or regular expressions", - "supportedContexts": [ - "asset" - ], - "schema": { - "type": "object", - "properties": { - "pattern": { - "type": "string", - "title": "Filename pattern", - "description": "Text or regex pattern to match against filename" - }, - "matchType": { - "type": "string", - "title": "Match type", - "enum": [ - "contains", - "regex", - "exact" - ], - "default": "contains", - "description": "Type of pattern matching to perform" - }, - "caseSensitive": { - "type": "boolean", - "default": false, - "description": "Whether matching should be case-sensitive" - } - }, - "required": [ - "pattern" - ] - } - }, - { - "methodName": "filterFileType", - "title": "Filter by file type", - "description": "Filter assets by file type", - "supportedContexts": [ - "asset" - ], - "schema": { - "type": "object", - "properties": { - "fileTypes": { - "type": "array", - "title": "File types", - "items": { - "type": "string", - "enum": [ - "image", - "video" - ] - }, - "description": "Allowed file types" - } - }, - "required": [ - "fileTypes" - ] - } - }, - { - "methodName": "filterPerson", - "title": "Filter by person", - "description": "Filter by detected person", - "supportedContexts": [ - "person" - ], - "schema": { - "type": "object", - "properties": { - "personIds": { - "type": "array", - "title": "Person IDs", - "items": { - "type": "string" - }, - "description": "List of person to match", - "subType": "people-picker" - }, - "matchAny": { - "type": "boolean", - "default": true, - "description": "Match any name (true) or require all names (false)" - } - }, - "required": [ - "personIds" - ] - } - } - ], - "actions": [ - { - "methodName": "actionArchive", - "title": "Archive", - "description": "Move the asset to archive", - "supportedContexts": [ - "asset" - ], - "schema": {} - }, - { - "methodName": "actionFavorite", - "title": "Favorite", - "description": "Mark the asset as favorite or unfavorite", - "supportedContexts": [ - "asset" - ], - "schema": { - "type": "object", - "properties": { - "favorite": { - "type": "boolean", - "default": true, - "description": "Set favorite (true) or unfavorite (false)" - } - } - } - }, - { - "methodName": "actionAddToAlbum", - "title": "Add to Album", - "description": "Add the item to a specified album", - "supportedContexts": [ - "asset", - "person" - ], - "schema": { - "type": "object", - "properties": { - "albumId": { - "type": "string", - "title": "Album ID", - "description": "Target album ID", - "subType": "album-picker" - } - }, - "required": [ - "albumId" - ] - } - } - ] -} diff --git a/packages/plugins/mise.toml b/packages/plugins/mise.toml deleted file mode 100644 index 66a107674d..0000000000 --- a/packages/plugins/mise.toml +++ /dev/null @@ -1,11 +0,0 @@ -[tools] -"github:extism/cli" = "1.6.3" -"github:webassembly/binaryen" = "version_124" -"github:extism/js-pdk" = "1.6.0" - -[tasks.install] -run = "pnpm install --frozen-lockfile" - -[tasks.build] -depends = ["install"] -run = "pnpm run build" diff --git a/packages/plugins/package-lock.json b/packages/plugins/package-lock.json deleted file mode 100644 index d74b830799..0000000000 --- a/packages/plugins/package-lock.json +++ /dev/null @@ -1,533 +0,0 @@ -{ - "name": "plugins", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "plugins", - "version": "1.0.0", - "license": "AGPL-3.0", - "devDependencies": { - "@extism/js-pdk": "^1.0.1", - "esbuild": "^0.28.0", - "typescript": "^6.0.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", - "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", - "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", - "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", - "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", - "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", - "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", - "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", - "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", - "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", - "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", - "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", - "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", - "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", - "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", - "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", - "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", - "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", - "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", - "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", - "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", - "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", - "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", - "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", - "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", - "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", - "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@extism/js-pdk": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.1.1.tgz", - "integrity": "sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==", - "dev": true, - "license": "BSD-Clause-3", - "dependencies": { - "urlpattern-polyfill": "^8.0.2" - } - }, - "node_modules/esbuild": { - "version": "0.28.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", - "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.0", - "@esbuild/android-arm": "0.28.0", - "@esbuild/android-arm64": "0.28.0", - "@esbuild/android-x64": "0.28.0", - "@esbuild/darwin-arm64": "0.28.0", - "@esbuild/darwin-x64": "0.28.0", - "@esbuild/freebsd-arm64": "0.28.0", - "@esbuild/freebsd-x64": "0.28.0", - "@esbuild/linux-arm": "0.28.0", - "@esbuild/linux-arm64": "0.28.0", - "@esbuild/linux-ia32": "0.28.0", - "@esbuild/linux-loong64": "0.28.0", - "@esbuild/linux-mips64el": "0.28.0", - "@esbuild/linux-ppc64": "0.28.0", - "@esbuild/linux-riscv64": "0.28.0", - "@esbuild/linux-s390x": "0.28.0", - "@esbuild/linux-x64": "0.28.0", - "@esbuild/netbsd-arm64": "0.28.0", - "@esbuild/netbsd-x64": "0.28.0", - "@esbuild/openbsd-arm64": "0.28.0", - "@esbuild/openbsd-x64": "0.28.0", - "@esbuild/openharmony-arm64": "0.28.0", - "@esbuild/sunos-x64": "0.28.0", - "@esbuild/win32-arm64": "0.28.0", - "@esbuild/win32-ia32": "0.28.0", - "@esbuild/win32-x64": "0.28.0" - } - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/urlpattern-polyfill": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz", - "integrity": "sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/packages/plugins/src/index.d.ts b/packages/plugins/src/index.d.ts deleted file mode 100644 index 7f805aafe6..0000000000 --- a/packages/plugins/src/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module 'main' { - export function filterFileName(): I32; - export function actionAddToAlbum(): I32; - export function actionArchive(): I32; -} - -declare module 'extism:host' { - interface user { - updateAsset(ptr: PTR): I32; - addAssetToAlbum(ptr: PTR): I32; - } -} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts deleted file mode 100644 index 5cf666fc87..0000000000 --- a/packages/plugins/src/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -const { updateAsset, addAssetToAlbum } = Host.getFunctions(); - -function parseInput() { - return JSON.parse(Host.inputString()); -} - -function returnOutput(output: any) { - Host.outputString(JSON.stringify(output)); - return 0; -} - -export function filterFileName() { - const input = parseInput(); - const { data, config } = input; - const { pattern, matchType = 'contains', caseSensitive = false } = config; - - const fileName = data.asset.originalFileName || data.asset.fileName || ''; - const searchName = caseSensitive ? fileName : fileName.toLowerCase(); - const searchPattern = caseSensitive ? pattern : pattern.toLowerCase(); - - let passed = false; - - if (matchType === 'exact') { - passed = searchName === searchPattern; - } else if (matchType === 'regex') { - const flags = caseSensitive ? '' : 'i'; - const regex = new RegExp(searchPattern, flags); - passed = regex.test(fileName); - } else { - // contains - passed = searchName.includes(searchPattern); - } - - return returnOutput({ passed }); -} - -export function actionAddToAlbum() { - const input = parseInput(); - const { authToken, config, data } = input; - const { albumId } = config; - - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - assetId: data.asset.id, - albumId: albumId, - }), - ); - - addAssetToAlbum(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); -} - -export function actionArchive() { - const input = parseInput(); - const { authToken, data } = input; - const ptr = Memory.fromString( - JSON.stringify({ - authToken, - id: data.asset.id, - visibility: 'archive', - }), - ); - - updateAsset(ptr.offset); - ptr.free(); - - return returnOutput({ success: true }); -} diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 6157154bec..e82074d02c 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1478,72 +1478,33 @@ export type PersonStatisticsResponseDto = { /** Number of assets */ assets: number; }; -export type PluginJsonSchemaProperty = { - additionalProperties?: boolean | PluginJsonSchemaProperty; - "default"?: any; - description?: string; - "enum"?: string[]; - items?: PluginJsonSchemaProperty; - properties?: { - [key: string]: PluginJsonSchemaProperty; - }; - required?: string[]; - "type"?: PluginJsonSchemaType; -}; -export type PluginJsonSchema = { - additionalProperties?: boolean; - description?: string; - properties?: { - [key: string]: PluginJsonSchemaProperty; - }; - required?: string[]; - "type"?: PluginJsonSchemaType; -}; -export type PluginActionResponseDto = { - /** Action description */ +export type PluginMethodResponseDto = { + /** Description */ description: string; - /** Action ID */ - id: string; - /** Method name */ - methodName: string; - /** Plugin ID */ - pluginId: string; - /** Action schema */ - schema: (PluginJsonSchema) | null; - /** Supported contexts */ - supportedContexts: PluginContextType[]; - /** Action title */ - title: string; -}; -export type PluginFilterResponseDto = { - /** Filter description */ - description: string; - /** Filter ID */ - id: string; - /** Method name */ - methodName: string; - /** Plugin ID */ - pluginId: string; - /** Filter schema */ - schema: (PluginJsonSchema) | null; - /** Supported contexts */ - supportedContexts: PluginContextType[]; - /** Filter title */ + hostFunctions: boolean; + /** Key */ + key: string; + /** Name */ + name: string; + schema?: {}; + /** Title */ title: string; + /** Workflow types */ + types: WorkflowType[]; + /** Ui hints */ + uiHints: string[]; }; export type PluginResponseDto = { - /** Plugin actions */ - actions: PluginActionResponseDto[]; /** Plugin author */ author: string; /** Creation date */ createdAt: string; /** Plugin description */ description: string; - /** Plugin filters */ - filters: PluginFilterResponseDto[]; /** Plugin ID */ id: string; + /** Plugin methods */ + methods: PluginMethodResponseDto[]; /** Plugin name */ name: string; /** Plugin title */ @@ -1553,10 +1514,6 @@ export type PluginResponseDto = { /** Plugin version */ version: string; }; -export type PluginTriggerResponseDto = { - contextType: PluginContextType; - "type": PluginTriggerType; -}; export type QueueResponseDto = { /** Whether the queue is paused */ isPaused: boolean; @@ -2710,89 +2667,81 @@ export type CreateProfileImageResponseDto = { /** User ID */ userId: string; }; -export type PluginConfigValue = any; -export type WorkflowActionConfig = { - [key: string]: PluginConfigValue; -}; -export type WorkflowActionResponseDto = { - actionConfig: (WorkflowActionConfig) | null; - /** Action ID */ - id: string; - /** Action order */ - order: number; - /** Plugin action ID */ - pluginActionId: string; - /** Workflow ID */ - workflowId: string; -}; -export type WorkflowFilterConfig = { - [key: string]: PluginConfigValue; -}; -export type WorkflowFilterResponseDto = { - filterConfig: (WorkflowFilterConfig) | null; - /** Filter ID */ - id: string; - /** Filter order */ - order: number; - /** Plugin filter ID */ - pluginFilterId: string; - /** Workflow ID */ - workflowId: string; +export type WorkflowStepDto = { + /** Step configuration */ + config: { + [key: string]: any; + } | null; + /** Step is enabled */ + enabled?: boolean; + /** Step plugin method */ + method: string; }; export type WorkflowResponseDto = { - /** Workflow actions */ - actions: WorkflowActionResponseDto[]; /** Creation date */ createdAt: string; /** Workflow description */ - description: string; + description: string | null; /** Workflow enabled */ enabled: boolean; - /** Workflow filters */ - filters: WorkflowFilterResponseDto[]; /** Workflow ID */ id: string; /** Workflow name */ name: string | null; - /** Owner user ID */ - ownerId: string; - triggerType: PluginTriggerType; -}; -export type WorkflowActionItemDto = { - actionConfig?: WorkflowActionConfig; - /** Plugin action ID */ - pluginActionId: string; -}; -export type WorkflowFilterItemDto = { - filterConfig?: WorkflowFilterConfig; - /** Plugin filter ID */ - pluginFilterId: string; + /** Workflow steps */ + steps: WorkflowStepDto[]; + /** Workflow trigger type */ + trigger: WorkflowTrigger; + /** Update date */ + updatedAt: string; }; export type WorkflowCreateDto = { - /** Workflow actions */ - actions: WorkflowActionItemDto[]; /** Workflow description */ - description?: string; + description?: string | null; /** Workflow enabled */ enabled?: boolean; - /** Workflow filters */ - filters: WorkflowFilterItemDto[]; /** Workflow name */ - name: string; - triggerType: PluginTriggerType; + name?: string | null; + steps?: WorkflowStepDto[]; + /** Workflow trigger type */ + trigger: WorkflowTrigger; +}; +export type WorkflowTriggerResponseDto = { + /** Trigger type */ + trigger: WorkflowTrigger; + /** Workflow types */ + types: WorkflowType[]; }; export type WorkflowUpdateDto = { - /** Workflow actions */ - actions?: WorkflowActionItemDto[]; /** Workflow description */ - description?: string; + description?: string | null; /** Workflow enabled */ enabled?: boolean; - /** Workflow filters */ - filters?: WorkflowFilterItemDto[]; /** Workflow name */ - name?: string; - triggerType?: PluginTriggerType; + name?: string | null; + steps?: WorkflowStepDto[]; + /** Workflow trigger type */ + trigger?: WorkflowTrigger; +}; +export type WorkflowShareStepDto = { + /** Step configuration */ + config: { + [key: string]: any; + } | null; + /** Step is enabled */ + enabled?: boolean; + /** Step plugin method */ + method: string; +}; +export type WorkflowShareResponseDto = { + /** Workflow description */ + description: string | null; + /** Workflow name */ + name: string | null; + /** Workflow steps */ + steps: WorkflowShareStepDto[]; + /** Workflow trigger type */ + trigger: WorkflowTrigger; }; export type LicenseResponseDto = UserLicense; export type SyncAckV1 = {}; @@ -5240,22 +5189,56 @@ export function getPersonThumbnail({ id }: { /** * List all plugins */ -export function getPlugins(opts?: Oazapfts.RequestOpts) { +export function searchPlugins({ description, enabled, id, name, title, version }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + title?: string; + version?: string; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: PluginResponseDto[]; - }>("/plugins", { + }>(`/plugins${QS.query(QS.explode({ + description, + enabled, + id, + name, + title, + version + }))}`, { ...opts })); } /** - * List all plugin triggers + * Retrieve plugin methods */ -export function getPluginTriggers(opts?: Oazapfts.RequestOpts) { +export function searchPluginMethods({ description, enabled, id, name, pluginName, pluginVersion, title, trigger, $type }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + pluginName?: string; + pluginVersion?: string; + title?: string; + trigger?: WorkflowTrigger; + $type?: WorkflowType; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; - data: PluginTriggerResponseDto[]; - }>("/plugins/triggers", { + data: PluginMethodResponseDto[]; + }>(`/plugins/methods${QS.query(QS.explode({ + description, + enabled, + id, + name, + pluginName, + pluginVersion, + title, + trigger, + "type": $type + }))}`, { ...opts })); } @@ -6631,11 +6614,23 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) { /** * List all workflows */ -export function getWorkflows(opts?: Oazapfts.RequestOpts) { +export function searchWorkflows({ description, enabled, id, name, trigger }: { + description?: string; + enabled?: boolean; + id?: string; + name?: string; + trigger?: WorkflowTrigger; +}, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: WorkflowResponseDto[]; - }>("/workflows", { + }>(`/workflows${QS.query(QS.explode({ + description, + enabled, + id, + name, + trigger + }))}`, { ...opts })); } @@ -6654,6 +6649,17 @@ export function createWorkflow({ workflowCreateDto }: { body: workflowCreateDto }))); } +/** + * List all workflow triggers + */ +export function getWorkflowTriggers(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowTriggerResponseDto[]; + }>("/workflows/triggers", { + ...opts + })); +} /** * Delete a workflow */ @@ -6694,6 +6700,19 @@ export function updateWorkflow({ id, workflowUpdateDto }: { body: workflowUpdateDto }))); } +/** + * Retrieve a workflow + */ +export function getWorkflowForShare({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: WorkflowShareResponseDto; + }>(`/workflows/${encodeURIComponent(id)}/share`, { + ...opts + })); +} export enum ReactionLevel { Album = "album", Asset = "asset" @@ -7017,21 +7036,11 @@ export enum PartnerDirection { SharedBy = "shared-by", SharedWith = "shared-with" } -export enum PluginJsonSchemaType { - String = "string", - Number = "number", - Integer = "integer", - Boolean = "boolean", - Object = "object", - Array = "array", - Null = "null" +export enum WorkflowType { + AssetV1 = "AssetV1", + AssetPersonV1 = "AssetPersonV1" } -export enum PluginContextType { - Asset = "asset", - Album = "album", - Person = "person" -} -export enum PluginTriggerType { +export enum WorkflowTrigger { AssetCreate = "AssetCreate", PersonRecognized = "PersonRecognized" } @@ -7098,7 +7107,7 @@ export enum JobName { VersionCheck = "VersionCheck", OcrQueueAll = "OcrQueueAll", Ocr = "Ocr", - WorkflowRun = "WorkflowRun" + WorkflowAssetCreate = "WorkflowAssetCreate" } export enum SearchSuggestionType { Country = "country", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b405a1090d..40a5987807 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,11 +312,14 @@ importers: specifier: ^4.20.6 version: 4.21.0 - packages/plugins: + packages/plugin-core: devDependencies: '@extism/js-pdk': specifier: ^1.0.1 version: 1.1.1 + '@immich/plugin-sdk': + specifier: workspace:* + version: link:../plugin-sdk esbuild: specifier: ^0.28.0 version: 0.28.0 @@ -324,6 +327,24 @@ importers: specifier: ^6.0.0 version: 6.0.3 + packages/plugin-sdk: + devDependencies: + '@extism/js-pdk': + specifier: ^1.1.1 + version: 1.1.1 + '@types/node': + specifier: ^24.11.0 + version: 24.12.2 + esbuild: + specifier: ^0.27.3 + version: 0.27.4 + tsc-alias: + specifier: ^1.8.16 + version: 1.8.16 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/sdk: dependencies: '@oazapfts/runtime': @@ -342,6 +363,9 @@ importers: '@extism/extism': specifier: 2.0.0-rc13 version: 2.0.0-rc13 + '@immich/plugin-sdk': + specifier: workspace:* + version: link:../packages/plugin-sdk '@immich/sql-tools': specifier: ^0.5.1 version: 0.5.2 @@ -453,6 +477,9 @@ importers: fluent-ffmpeg: specifier: ^2.1.2 version: 2.1.3 + generic-pool: + specifier: ^3.9.0 + version: 3.9.0 geo-tz: specifier: ^8.0.0 version: 8.1.6 @@ -6446,6 +6473,10 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + comment-json@5.0.0: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} @@ -7817,6 +7848,10 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -9462,6 +9497,10 @@ packages: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} + mylas@2.1.14: + resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} + engines: {node: '>=16.0.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -10000,6 +10039,10 @@ packages: engines: {node: '>=18'} hasBin: true + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -10642,6 +10685,10 @@ packages: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -11868,6 +11915,11 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsc-alias@1.8.16: + resolution: {integrity: sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==} + engines: {node: '>=16.20.2'} + hasBin: true + tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} @@ -18998,6 +19050,8 @@ snapshots: commander@8.3.0: {} + commander@9.5.0: {} + comment-json@5.0.0: dependencies: array-timsort: 1.0.3 @@ -20643,6 +20697,8 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} geo-coordinates-parser@1.7.4: {} @@ -22684,6 +22740,8 @@ snapshots: mute-stream@2.0.0: {} + mylas@2.1.14: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -23241,6 +23299,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + pluralize@8.0.0: {} pmtiles@3.2.1: @@ -23925,6 +23987,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -25518,6 +25582,16 @@ snapshots: ts-interface-checker@0.1.13: {} + tsc-alias@1.8.16: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.13.0 + globby: 11.1.0 + mylas: 2.1.14 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + tsconfck@3.1.6(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 57aeb9c7bf..f53cb0d406 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - plugins - web - .github + - packages/* ignoredBuiltDependencies: - '@nestjs/core' - '@parcel/watcher' diff --git a/server/Dockerfile b/server/Dockerfile index d35d029958..ea8ddba885 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -13,12 +13,13 @@ FROM builder AS server WORKDIR /usr/src/app COPY ./server ./server/ +COPY ./packages/plugin-sdk ./packages/plugin-sdk/ RUN --mount=type=cache,id=pnpm-server,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ - SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \ + SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --filter @immich/plugin-sdk --frozen-lockfile build && \ SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned FROM builder AS web @@ -56,22 +57,26 @@ ARG TARGETPLATFORM COPY --from=ghcr.io/jdx/mise:2026.3.12@sha256:0210678cbf58413806531a27adb2c7daf1c37238e56e8f7ea381d73521571775 /usr/local/bin/mise /usr/local/bin/mise -WORKDIR /usr/src/app -COPY ./packages/plugins/mise.toml ./packages/plugins/ -ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/packages/plugins/mise.toml +WORKDIR /app +COPY ./mise.toml ./mise.toml +COPY ./packages/plugin-core/mise.toml ./packages/plugin-core/ +ENV MISE_TRUSTED_CONFIG_PATHS=/app/mise.toml ENV MISE_DATA_DIR=/buildcache/mise +ENV MISE_DISABLE_TOOLS=flutter RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - mise install --cd packages/plugins + mise install + +COPY ./packages/plugin-core ./packages/plugin-core/ +COPY ./packages/plugin-sdk ./packages/plugin-sdk/ -COPY ./packages/plugins ./packages/plugins/ # Build plugins -RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \ +RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ --mount=type=bind,source=package.json,target=package.json \ --mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \ --mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \ --mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ - cd packages/plugins && mise run build + mise //:plugins FROM ghcr.io/immich-app/base-server-prod:202605051129@sha256:50f7ffe4ed31e360c90c4905bd5f6658f2a121297544e3fe9368e338b3f76bcd @@ -83,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 /usr/src/app/packages/plugins/dist /build/corePlugin/dist -COPY --from=plugins /usr/src/app/packages/plugins/manifest.json /build/corePlugin/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 diff --git a/server/mise.toml b/server/mise.toml index b8236c60c6..f7e4f92a26 100644 --- a/server/mise.toml +++ b/server/mise.toml @@ -58,6 +58,7 @@ run = "email dev -p 3050 --dir src/emails" [tasks.ci-unit] run = [ { task = ":install" }, + { task = "//:plugins" }, { task = ":format" }, { task = ":lint" }, { task = ":check" }, @@ -67,6 +68,7 @@ run = [ [tasks.ci-medium] run = [ { task = ":install" }, + { task = "//packages/plugin-core:build" }, { task = ":test-medium --run" }, ] diff --git a/server/package.json b/server/package.json index 904cb3c6c8..d85f47c4ad 100644 --- a/server/package.json +++ b/server/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", + "@immich/plugin-sdk": "workspace:*", "@immich/sql-tools": "^0.5.1", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", @@ -74,6 +75,7 @@ "express": "^5.1.0", "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", + "generic-pool": "^3.9.0", "geo-tz": "^8.0.0", "handlebars": "^4.7.8", "helmet": "^8.1.0", diff --git a/server/src/controllers/plugin.controller.spec.ts b/server/src/controllers/plugin.controller.spec.ts new file mode 100644 index 0000000000..881a7dd953 --- /dev/null +++ b/server/src/controllers/plugin.controller.spec.ts @@ -0,0 +1,56 @@ +import { PluginController } from 'src/controllers/plugin.controller'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginService } from 'src/services/plugin.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(PluginController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(PluginService); + + beforeAll(async () => { + ctx = await controllerSetup(PluginController, [ + { provide: PluginService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /plugins', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/plugins'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); + + describe('GET /plugins/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/plugins/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); +}); diff --git a/server/src/controllers/plugin.controller.ts b/server/src/controllers/plugin.controller.ts index 52c833e93d..fb1bda287e 100644 --- a/server/src/controllers/plugin.controller.ts +++ b/server/src/controllers/plugin.controller.ts @@ -1,7 +1,12 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; -import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; +import { + PluginMethodResponseDto, + PluginMethodSearchDto, + PluginResponseDto, + PluginSearchDto, +} from 'src/dtos/plugin.dto'; import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { PluginService } from 'src/services/plugin.service'; @@ -12,26 +17,26 @@ import { UUIDParamDto } from 'src/validation'; export class PluginController { constructor(private service: PluginService) {} - @Get('triggers') - @Authenticated({ permission: Permission.PluginRead }) - @Endpoint({ - summary: 'List all plugin triggers', - description: 'Retrieve a list of all available plugin triggers.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), - }) - getPluginTriggers(): PluginTriggerResponseDto[] { - return this.service.getTriggers(); - } - @Get() @Authenticated({ permission: Permission.PluginRead }) @Endpoint({ summary: 'List all plugins', description: 'Retrieve a list of plugins available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getPlugins(): Promise { - return this.service.getAll(); + searchPlugins(@Query() dto: PluginSearchDto): Promise { + return this.service.search(dto); + } + + @Get('methods') + @Authenticated({ permission: Permission.PluginRead }) + @Endpoint({ + summary: 'Retrieve plugin methods', + description: 'Retrieve a list of plugin methods', + history: HistoryBuilder.v3(), + }) + searchPluginMethods(@Query() dto: PluginMethodSearchDto): Promise { + return this.service.searchMethods(dto); } @Get(':id') @@ -39,7 +44,7 @@ export class PluginController { @Endpoint({ summary: 'Retrieve a plugin', description: 'Retrieve information about a specific plugin by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getPlugin(@Param() { id }: UUIDParamDto): Promise { return this.service.get(id); diff --git a/server/src/controllers/workflow.controller.spec.ts b/server/src/controllers/workflow.controller.spec.ts new file mode 100644 index 0000000000..7bc164e285 --- /dev/null +++ b/server/src/controllers/workflow.controller.spec.ts @@ -0,0 +1,113 @@ +import { WorkflowController } from 'src/controllers/workflow.controller'; +import { WorkflowTrigger } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { WorkflowService } from 'src/services/workflow.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(WorkflowController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(WorkflowService); + + beforeAll(async () => { + ctx = await controllerSetup(WorkflowController, [ + { provide: WorkflowService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('POST /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/workflows').send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require a valid trigger`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([ + { path: ['trigger'], message: expect.stringContaining('Invalid option: expected one of') }, + ]), + ); + }); + + it(`should require a valid enabled value`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ enabled: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.validationError([{ path: ['enabled'], message: 'Invalid input: expected boolean, received string' }]), + ); + }); + + it(`should not require a name`, async () => { + const { status } = await request(ctx.getHttpServer()) + .post(`/workflows`) + .send({ trigger: WorkflowTrigger.AssetCreate }) + .set('Authorization', `Bearer token`); + expect(status).toBe(201); + expect(service.create).toHaveBeenCalled(); + }); + }); + + describe('GET /workflows', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/workflows'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows`) + .query({ id: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); + + describe('GET /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/workflows/invalid`) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); + + describe('PUT /workflows/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({}); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require id to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/workflows/invalid`) + .set('Authorization', `Bearer token`) + .send({}); + expect(status).toBe(400); + expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }])); + }); + }); +}); diff --git a/server/src/controllers/workflow.controller.ts b/server/src/controllers/workflow.controller.ts index e07b6443f4..59fa2eee27 100644 --- a/server/src/controllers/workflow.controller.ts +++ b/server/src/controllers/workflow.controller.ts @@ -1,8 +1,15 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto'; +import { + WorkflowCreateDto, + WorkflowResponseDto, + WorkflowSearchDto, + WorkflowShareResponseDto, + WorkflowTriggerResponseDto, + WorkflowUpdateDto, +} from 'src/dtos/workflow.dto'; import { Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { WorkflowService } from 'src/services/workflow.service'; @@ -18,7 +25,7 @@ export class WorkflowController { @Endpoint({ summary: 'Create a workflow', description: 'Create a new workflow, the workflow can also be created with empty filters and actions.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise { return this.service.create(auth, dto); @@ -29,10 +36,21 @@ export class WorkflowController { @Endpoint({ summary: 'List all workflows', description: 'Retrieve a list of workflows available to the authenticated user.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) - getWorkflows(@Auth() auth: AuthDto): Promise { - return this.service.getAll(auth); + searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get('triggers') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'List all workflow triggers', + description: 'Retrieve a list of all available workflow triggers.', + history: HistoryBuilder.v3(), + }) + getWorkflowTriggers(): WorkflowTriggerResponseDto[] { + return this.service.getTriggers(); } @Get(':id') @@ -40,19 +58,30 @@ export class WorkflowController { @Endpoint({ summary: 'Retrieve a workflow', description: 'Retrieve information about a specific workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.get(auth, id); } + @Get(':id/share') + @Authenticated({ permission: Permission.WorkflowRead }) + @Endpoint({ + summary: 'Retrieve a workflow', + description: 'Retrieve a workflow details without ids, default values, etc.', + history: HistoryBuilder.v3(), + }) + getWorkflowForShare(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.share(auth, id); + } + @Put(':id') @Authenticated({ permission: Permission.WorkflowUpdate }) @Endpoint({ summary: 'Update a workflow', description: 'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) updateWorkflow( @Auth() auth: AuthDto, @@ -68,7 +97,7 @@ export class WorkflowController { @Endpoint({ summary: 'Delete a workflow', description: 'Delete a workflow by its ID.', - history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'), + history: HistoryBuilder.v3(), }) deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { return this.service.delete(auth, id); diff --git a/server/src/database.ts b/server/src/database.ts index 934854e5c4..08f080e0ae 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -8,8 +8,6 @@ import { ChecksumAlgorithm, MemoryType, Permission, - PluginContext, - PluginTriggerType, SharedLinkType, SourceType, UserAvatarColor, @@ -18,10 +16,8 @@ import { import { AlbumTable } from 'src/schema/tables/album.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { UserMetadataItem } from 'src/types'; -import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types'; export type AuthUser = { id: string; @@ -276,42 +272,7 @@ export type AssetFace = { isVisible: boolean; }; -export type PluginFilter = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type PluginAction = Selectable & { - methodName: string; - title: string; - description: string; - supportedContexts: PluginContext[]; - schema: JSONSchema | null; -}; - -export type Workflow = Selectable & { - triggerType: PluginTriggerType; - name: string | null; - description: string; - enabled: boolean; -}; - -export type WorkflowFilter = Selectable & { - workflowId: string; - pluginFilterId: string; - filterConfig: FilterConfig | null; - order: number; -}; - -export type WorkflowAction = Selectable & { - workflowId: string; - pluginActionId: string; - actionConfig: ActionConfig | null; - order: number; -}; +export type Plugin = Selectable; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; const userWithPrefixColumns = [ @@ -343,6 +304,32 @@ export const columns = { 'asset.height', 'asset.isEdited', ], + workflowAssetV1: [ + 'asset.id', + 'asset.ownerId', + 'asset.stackId', + 'asset.livePhotoVideoId', + 'asset.libraryId', + 'asset.duplicateId', + 'asset.createdAt', + 'asset.updatedAt', + 'asset.deletedAt', + 'asset.fileCreatedAt', + 'asset.fileModifiedAt', + 'asset.localDateTime', + 'asset.type', + 'asset.status', + 'asset.visibility', + 'asset.duration', + 'asset.checksum', + 'asset.originalPath', + 'asset.originalFileName', + 'asset.isOffline', + 'asset.isFavorite', + 'asset.isExternal', + 'asset.isEdited', + 'asset.isFavorite', + ], assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'], assetFilesForThumbnail: [ 'asset_file.id', @@ -374,6 +361,15 @@ export const columns = { tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'], apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'], notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'], + pluginMethod: [ + 'plugin_method.name', + 'plugin_method.title', + 'plugin_method.description', + 'plugin_method.types', + 'plugin_method.schema', + 'plugin_method.hostFunctions', + 'plugin_method.uiHints', + ], syncAsset: [ 'asset.id', 'asset.ownerId', @@ -487,17 +483,6 @@ export const columns = { 'asset_exif.tags', 'asset_exif.timeZone', ], - plugin: [ - 'plugin.id as id', - 'plugin.name as name', - 'plugin.title as title', - 'plugin.description as description', - 'plugin.author as author', - 'plugin.version as version', - 'plugin.wasmPath as wasmPath', - 'plugin.createdAt as createdAt', - 'plugin.updatedAt as updatedAt', - ], } as const; export type LockableProperty = (typeof lockableProperties)[number]; diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 75a3463ee7..c8cf1f9221 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -200,6 +200,10 @@ export class HistoryBuilder { private hasDeprecated = false; private items: HistoryEntry[] = []; + static v3() { + return new HistoryBuilder().added('v3.0.0'); + } + added(version: string, description?: string) { return this.push({ version, state: 'Added', description }); } diff --git a/server/src/dtos/json-schema.dto.ts b/server/src/dtos/json-schema.dto.ts new file mode 100644 index 0000000000..65b92277d5 --- /dev/null +++ b/server/src/dtos/json-schema.dto.ts @@ -0,0 +1,32 @@ +import { createZodDto } from 'nestjs-zod'; +import z from 'zod'; + +export const JsonSchemaTypeSchema = z + .enum(['string', 'number', 'integer', 'boolean', 'object']) + .meta({ id: 'JsonSchemaType' }); + +const JsonSchemaPropertySchema = z + .object({ + type: JsonSchemaTypeSchema.optional().default('object').describe('Type'), + title: z.string().describe('Title'), + description: z.string().describe('Description'), + default: z.any().optional().describe('Default value'), + enum: z.array(z.string()).optional().describe('Valid choices for enum types'), + array: z.boolean().optional().describe('Type is an array type'), + required: z.array(z.string()).optional().describe('A list of required properties'), + uiHint: z.string().optional(), + get properties() { + return z.record(z.string(), JsonSchemaPropertySchema).optional(); + }, + }) + .meta({ id: 'JsonSchemaPropertyDto' }); + +export const JsonSchemaSchema = z + .object({ + ...JsonSchemaPropertySchema.shape, + title: z.string().optional().describe('Title'), + description: z.string().optional().describe('Description'), + }) + .meta({ id: 'JsonSchemaDto' }); + +export class JsonSchemaDto extends createZodDto(JsonSchemaSchema) {} diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts index 30aa8c0a68..4d004a375d 100644 --- a/server/src/dtos/plugin-manifest.dto.ts +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -1,39 +1,29 @@ import { createZodDto } from 'nestjs-zod'; -import { PluginContextSchema } from 'src/enum'; -import { JSONSchemaSchema } from 'src/types/plugin-schema.types'; +import { JsonSchemaSchema } from 'src/dtos/json-schema.dto'; +import { WorkflowTypeSchema } from 'src/enum'; import z from 'zod'; const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/; const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; -const PluginManifestWasmSchema = z - .object({ - path: z.string().describe('WASM file path'), - }) - .meta({ id: 'PluginManifestWasmDto' }); +export const PluginManifestMethodSchemaSchema = JsonSchemaSchema.nullable() + .optional() + .transform((value) => (value && Object.keys(value).length === 0 ? null : value)); -const PluginManifestFilterSchema = z +const PluginManifestMethodSchema = z .object({ - methodName: z.string().describe('Filter method name'), - title: z.string().describe('Filter title'), - description: z.string().describe('Filter description'), - supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'), - schema: JSONSchemaSchema.optional(), + name: z.string().min(1).describe('Method name'), + title: z.string().min(1).describe('Method title'), + description: z.string().min(1).describe('Method description'), + types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'), + hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'), + schema: PluginManifestMethodSchemaSchema.describe('Schema'), + uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'), }) - .meta({ id: 'PluginManifestFilterDto' }); + .meta({ id: 'PluginManifestMethodDto' }); -const PluginManifestActionSchema = z - .object({ - methodName: z.string().describe('Action method name'), - title: z.string().describe('Action title'), - description: z.string().describe('Action description'), - supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'), - schema: JSONSchemaSchema.optional(), - }) - .meta({ id: 'PluginManifestActionDto' }); - -export const PluginManifestSchema = z +const PluginManifestSchema = z .object({ name: z .string() @@ -44,12 +34,11 @@ export const PluginManifestSchema = z ) .describe('Plugin name (lowercase, numbers, hyphens only)'), version: z.string().regex(semverRegex).describe('Plugin version (semver)'), - title: z.string().describe('Plugin title'), - description: z.string().describe('Plugin description'), - author: z.string().describe('Plugin author'), - wasm: PluginManifestWasmSchema, - filters: z.array(PluginManifestFilterSchema).optional().describe('Plugin filters'), - actions: z.array(PluginManifestActionSchema).optional().describe('Plugin actions'), + title: z.string().min(1).describe('Plugin title'), + description: z.string().min(1).describe('Plugin description'), + wasmPath: z.string().min(1).describe('WASM file path'), + author: z.string().min(1).describe('Plugin author'), + methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'), }) .meta({ id: 'PluginManifestDto' }); diff --git a/server/src/dtos/plugin.dto.ts b/server/src/dtos/plugin.dto.ts index 2f928841cb..410ada4b53 100644 --- a/server/src/dtos/plugin.dto.ts +++ b/server/src/dtos/plugin.dto.ts @@ -1,39 +1,33 @@ import { createZodDto } from 'nestjs-zod'; -import { PluginAction, PluginFilter } from 'src/database'; -import { PluginContextSchema, PluginTriggerTypeSchema } from 'src/enum'; -import { JSONSchemaSchema } from 'src/types/plugin-schema.types'; +import { JsonSchemaDto } from 'src/dtos/json-schema.dto'; +import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum'; +import { asMethodString } from 'src/utils/workflow'; import z from 'zod'; -const PluginTriggerResponseSchema = z +const PluginSearchSchema = z .object({ - type: PluginTriggerTypeSchema, - contextType: PluginContextSchema, + id: z.uuidv4().optional().describe('Plugin ID'), + enabled: z.boolean().optional().describe('Whether the plugin is enabled'), + name: z.string().optional(), + version: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), }) - .meta({ id: 'PluginTriggerResponseDto' }); + .meta({ id: 'PluginSearchDto' }); -const PluginFilterResponseSchema = z +const PluginMethodResponseSchema = z .object({ - id: z.string().describe('Filter ID'), - pluginId: z.string().describe('Plugin ID'), - methodName: z.string().describe('Method name'), - title: z.string().describe('Filter title'), - description: z.string().describe('Filter description'), - supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'), - schema: JSONSchemaSchema.nullable().describe('Filter schema'), + key: z.string().describe('Key'), + name: z.string().describe('Name'), + title: z.string().describe('Title'), + description: z.string().describe('Description'), + types: z.array(WorkflowTypeSchema).describe('Workflow types'), + uiHints: z.array(z.string()).describe('Ui hints'), + // TODO fix this + schema: z.object().optional(), + hostFunctions: z.boolean(), }) - .meta({ id: 'PluginFilterResponseDto' }); - -const PluginActionResponseSchema = z - .object({ - id: z.string().describe('Action ID'), - pluginId: z.string().describe('Plugin ID'), - methodName: z.string().describe('Method name'), - title: z.string().describe('Action title'), - description: z.string().describe('Action description'), - supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'), - schema: JSONSchemaSchema.nullable().describe('Action schema'), - }) - .meta({ id: 'PluginActionResponseDto' }); + .meta({ id: 'PluginMethodResponseDto' }); const PluginResponseSchema = z .object({ @@ -45,29 +39,53 @@ const PluginResponseSchema = z version: z.string().describe('Plugin version'), createdAt: z.string().describe('Creation date'), updatedAt: z.string().describe('Last update date'), - filters: z.array(PluginFilterResponseSchema).describe('Plugin filters'), - actions: z.array(PluginActionResponseSchema).describe('Plugin actions'), + methods: z.array(PluginMethodResponseSchema).describe('Plugin methods'), }) .meta({ id: 'PluginResponseDto' }); -export class PluginTriggerResponseDto extends createZodDto(PluginTriggerResponseSchema) {} -export class PluginResponseDto extends createZodDto(PluginResponseSchema) {} +const PluginMethodSearchSchema = z + .object({ + id: z.uuidv4().optional().describe('Plugin method ID'), + enabled: z.boolean().optional().describe('Whether the plugin method is enabled'), + name: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + type: WorkflowTypeSchema.optional().describe('Workflow types'), + trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger'), + pluginName: z.string().optional().describe('Plugin name'), + pluginVersion: z.string().optional().describe('Plugin version'), + }) + .meta({ id: 'PluginMethodSearchDto' }); -type MapPlugin = { +export class PluginSearchDto extends createZodDto(PluginSearchSchema) {} +export class PluginResponseDto extends createZodDto(PluginResponseSchema) {} +export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {} +export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {} + +type Plugin = { id: string; name: string; title: string; description: string; author: string; version: string; - wasmPath: string; createdAt: Date; updatedAt: Date; - filters: PluginFilter[]; - actions: PluginAction[]; + methods: PluginMethod[]; }; -export function mapPlugin(plugin: MapPlugin): PluginResponseDto { +type PluginMethod = { + pluginName: string; + name: string; + title: string; + description: string; + types: WorkflowType[]; + schema: JsonSchemaDto | null; + hostFunctions: boolean; + uiHints: string[]; +}; + +export function mapPlugin(plugin: Plugin): PluginResponseDto { return { id: plugin.id, name: plugin.name, @@ -77,7 +95,19 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto { version: plugin.version, createdAt: plugin.createdAt.toISOString(), updatedAt: plugin.updatedAt.toISOString(), - filters: plugin.filters, - actions: plugin.actions, + methods: plugin.methods.map((method) => mapMethod(method)), }; } + +export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => { + return { + key: asMethodString({ pluginName: method.pluginName, methodName: method.name }), + name: method.name, + title: method.title, + hostFunctions: method.hostFunctions, + uiHints: method.uiHints, + description: method.description, + types: method.types, + schema: method.schema as any, + }; +}; diff --git a/server/src/dtos/workflow.dto.ts b/server/src/dtos/workflow.dto.ts index f94e4bed92..8a5960470d 100644 --- a/server/src/dtos/workflow.dto.ts +++ b/server/src/dtos/workflow.dto.ts @@ -1,101 +1,135 @@ +import type { WorkflowStepConfig } from '@immich/plugin-sdk'; import { createZodDto } from 'nestjs-zod'; -import type { WorkflowAction, WorkflowFilter } from 'src/database'; -import { PluginTriggerTypeSchema } from 'src/enum'; -import { ActionConfigSchema, FilterConfigSchema } from 'src/types/plugin-schema.types'; +import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum'; import z from 'zod'; -const WorkflowFilterItemSchema = z +const WorkflowTriggerResponseSchema = z .object({ - pluginFilterId: z.uuidv4().describe('Plugin filter ID'), - filterConfig: FilterConfigSchema.optional(), + trigger: WorkflowTriggerSchema.describe('Trigger type'), + types: z.array(WorkflowTypeSchema).describe('Workflow types'), }) - .meta({ id: 'WorkflowFilterItemDto' }); + .meta({ id: 'WorkflowTriggerResponseDto' }); -const WorkflowActionItemSchema = z +const WorkflowSearchSchema = z .object({ - pluginActionId: z.uuidv4().describe('Plugin action ID'), - actionConfig: ActionConfigSchema.optional(), + id: z.uuidv4().optional().describe('Workflow ID'), + trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'), + name: z.string().optional().describe('Workflow name'), + description: z.string().optional().describe('Workflow description'), + enabled: z.boolean().optional().describe('Workflow enabled'), }) - .meta({ id: 'WorkflowActionItemDto' }); + .meta({ id: 'WorkflowSearchDto' }); + +const WorkflowStepSchema = z + .object({ + method: z.string().describe('Step plugin method'), + config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'), + enabled: z.boolean().optional().describe('Step is enabled'), + }) + .meta({ id: 'WorkflowStepDto' }); + +const WorkflowShareStepSchema = z + .object({ + method: z.string().describe('Step plugin method'), + config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'), + enabled: z.boolean().optional().describe('Step is enabled'), + }) + .meta({ id: 'WorkflowShareStepDto' }); const WorkflowCreateSchema = z .object({ - triggerType: PluginTriggerTypeSchema, - name: z.string().describe('Workflow name'), - description: z.string().optional().describe('Workflow description'), + trigger: WorkflowTriggerSchema.describe('Workflow trigger type'), + name: z.string().nullable().optional().describe('Workflow name'), + description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), - filters: z.array(WorkflowFilterItemSchema).describe('Workflow filters'), - actions: z.array(WorkflowActionItemSchema).describe('Workflow actions'), + steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowCreateDto' }); const WorkflowUpdateSchema = z .object({ - triggerType: PluginTriggerTypeSchema.optional(), - name: z.string().optional().describe('Workflow name'), - description: z.string().optional().describe('Workflow description'), + trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'), + name: z.string().nullable().optional().describe('Workflow name'), + description: z.string().nullable().optional().describe('Workflow description'), enabled: z.boolean().optional().describe('Workflow enabled'), - filters: z.array(WorkflowFilterItemSchema).optional().describe('Workflow filters'), - actions: z.array(WorkflowActionItemSchema).optional().describe('Workflow actions'), + steps: z.array(WorkflowStepSchema).optional(), }) .meta({ id: 'WorkflowUpdateDto' }); -const WorkflowFilterResponseSchema = z - .object({ - id: z.string().describe('Filter ID'), - workflowId: z.string().describe('Workflow ID'), - pluginFilterId: z.string().describe('Plugin filter ID'), - filterConfig: FilterConfigSchema.nullable(), - order: z.int().describe('Filter order'), - }) - .meta({ id: 'WorkflowFilterResponseDto' }); - -const WorkflowActionResponseSchema = z - .object({ - id: z.string().describe('Action ID'), - workflowId: z.string().describe('Workflow ID'), - pluginActionId: z.string().describe('Plugin action ID'), - actionConfig: ActionConfigSchema.nullable(), - order: z.int().describe('Action order'), - }) - .meta({ id: 'WorkflowActionResponseDto' }); - const WorkflowResponseSchema = z .object({ id: z.string().describe('Workflow ID'), - ownerId: z.string().describe('Owner user ID'), - triggerType: PluginTriggerTypeSchema, + trigger: WorkflowTriggerSchema.describe('Workflow trigger type'), name: z.string().nullable().describe('Workflow name'), - description: z.string().describe('Workflow description'), + description: z.string().nullable().describe('Workflow description'), createdAt: z.string().describe('Creation date'), + updatedAt: z.string().describe('Update date'), enabled: z.boolean().describe('Workflow enabled'), - filters: z.array(WorkflowFilterResponseSchema).describe('Workflow filters'), - actions: z.array(WorkflowActionResponseSchema).describe('Workflow actions'), + steps: z.array(WorkflowStepSchema).describe('Workflow steps'), }) .meta({ id: 'WorkflowResponseDto' }); +const WorkflowShareResponseSchema = z + .object({ + trigger: WorkflowTriggerSchema.describe('Workflow trigger type'), + name: z.string().nullable().describe('Workflow name'), + description: z.string().nullable().describe('Workflow description'), + steps: z.array(WorkflowShareStepSchema).describe('Workflow steps'), + }) + .meta({ id: 'WorkflowShareResponseDto' }); + +export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {} +export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {} export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {} export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {} export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {} -class WorkflowFilterResponseDto extends createZodDto(WorkflowFilterResponseSchema) {} -class WorkflowActionResponseDto extends createZodDto(WorkflowActionResponseSchema) {} +export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {} -export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto { - return { - id: filter.id, - workflowId: filter.workflowId, - pluginFilterId: filter.pluginFilterId, - filterConfig: filter.filterConfig, - order: filter.order, - }; -} +type Workflow = { + id: string; + createdAt: Date; + updatedAt: Date; + trigger: WorkflowTrigger; + name: string | null; + description: string | null; + enabled: boolean; +}; -export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto { +type WorkflowStep = { + enabled: boolean; + methodName: string; + config: WorkflowStepConfig | null; + pluginName: string; +}; + +export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => { return { - id: action.id, - workflowId: action.workflowId, - pluginActionId: action.pluginActionId, - actionConfig: action.actionConfig, - order: action.order, + id: workflow.id, + enabled: workflow.enabled, + trigger: workflow.trigger, + name: workflow.name, + description: workflow.description, + createdAt: workflow.createdAt.toISOString(), + updatedAt: workflow.updatedAt.toISOString(), + steps: workflow.steps.map((step) => ({ + method: `${step.pluginName}#${step.methodName}`, + // TODO fix this + config: step.config as any, + enabled: step.enabled, + })), }; -} +}; + +export const mapWorkflowShare = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowShareResponseDto => { + return { + trigger: workflow.trigger, + name: workflow.name, + description: workflow.description, + steps: workflow.steps.map((step) => ({ + method: `${step.pluginName}#${step.methodName}`, + // TODO fix this + config: step.config as any, + enabled: step.enabled ? undefined : false, + })), + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 636db8adab..bc52e65f83 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -749,8 +749,11 @@ export enum BootstrapEventPriority { StorageService = -195, // Other services may need to queue jobs on bootstrap. JobService = -190, - // Initialise config after other bootstrap services, stop other services from using config on bootstrap + // Initialize config after other bootstrap services, stop other services from using config on bootstrap SystemConfig = 100, + PluginSync = 190, + // Load plugins into memory after sync + PluginLoad = 200, } export enum QueueName { @@ -863,7 +866,7 @@ export enum JobName { Ocr = 'Ocr', // Workflow - WorkflowRun = 'WorkflowRun', + WorkflowAssetCreate = 'WorkflowAssetCreate', } export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' }); @@ -909,6 +912,7 @@ export enum DatabaseLock { CLIPDimSize = 512, Library = 1337, NightlyJobs = 600, + PluginImport = 666, MediaLocation = 700, GetSystemConfig = 69, BackupDatabase = 42, @@ -1160,12 +1164,19 @@ export enum PluginContext { export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' }); -export enum PluginTriggerType { +export enum WorkflowTrigger { AssetCreate = 'AssetCreate', PersonRecognized = 'PersonRecognized', } -export const PluginTriggerTypeSchema = z - .enum(PluginTriggerType) +export const WorkflowTriggerSchema = z + .enum(WorkflowTrigger) .describe('Plugin trigger type') - .meta({ id: 'PluginTriggerType' }); + .meta({ id: 'WorkflowTrigger' }); + +export enum WorkflowType { + AssetV1 = 'AssetV1', + AssetPersonV1 = 'AssetPersonV1', +} + +export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' }); diff --git a/server/src/plugins.ts b/server/src/plugins.ts deleted file mode 100644 index 77f35e79f6..0000000000 --- a/server/src/plugins.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { PluginContext, PluginTriggerType } from 'src/enum'; - -export type PluginTrigger = { - type: PluginTriggerType; - contextType: PluginContext; -}; - -export const pluginTriggers: PluginTrigger[] = [ - { - type: PluginTriggerType.AssetCreate, - contextType: PluginContext.Asset, - }, - { - type: PluginTriggerType.PersonRecognized, - contextType: PluginContext.Person, - }, -]; diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql index 82c203dafd..2c920b7ab0 100644 --- a/server/src/queries/plugin.repository.sql +++ b/server/src/queries/plugin.repository.sql @@ -1,159 +1,159 @@ -- NOTE: This file is auto generated by ./sql-generator --- PluginRepository.getPlugin +-- PluginRepository.getForLoad select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", + "plugin"."id", + "plugin"."name", + "plugin"."version", + "plugin"."wasmBytes", ( select coalesce(json_agg(agg), '[]') from ( select - * + "plugin_method"."name", + "plugin_method"."hostFunctions" from - "plugin_filter" + "plugin_method" where - "plugin_filter"."pluginId" = "plugin"."id" + "plugin_method"."pluginId" = "plugin"."id" ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + ) as "methods" from "plugin" where - "plugin"."id" = $1 + "enabled" = $1 --- PluginRepository.getPluginByName +-- PluginRepository.search select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", + "plugin"."id", + "plugin"."name", + "plugin"."title", + "plugin"."description", + "plugin"."author", + "plugin"."version", + "plugin"."createdAt", + "plugin"."updatedAt", ( select coalesce(json_agg(agg), '[]') from ( select - * + "plugin_method"."name", + "plugin_method"."title", + "plugin_method"."description", + "plugin_method"."types", + "plugin_method"."schema", + "plugin_method"."hostFunctions", + "plugin_method"."uiHints", + "plugin"."name" as "pluginName" from - "plugin_filter" + "plugin_method" where - "plugin_filter"."pluginId" = "plugin"."id" + "plugin_method"."pluginId" = "plugin"."id" ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" -from - "plugin" -where - "plugin"."name" = $1 - --- PluginRepository.getAllPlugins -select - "plugin"."id" as "id", - "plugin"."name" as "name", - "plugin"."title" as "title", - "plugin"."description" as "description", - "plugin"."author" as "author", - "plugin"."version" as "version", - "plugin"."wasmPath" as "wasmPath", - "plugin"."createdAt" as "createdAt", - "plugin"."updatedAt" as "updatedAt", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_filter" - where - "plugin_filter"."pluginId" = "plugin"."id" - ) as agg - ) as "filters", - ( - select - coalesce(json_agg(agg), '[]') - from - ( - select - * - from - "plugin_action" - where - "plugin_action"."pluginId" = "plugin"."id" - ) as agg - ) as "actions" + ) as "methods" from "plugin" order by "plugin"."name" --- PluginRepository.getFilter +-- PluginRepository.getByName select - * + "plugin"."id", + "plugin"."name", + "plugin"."title", + "plugin"."description", + "plugin"."author", + "plugin"."version", + "plugin"."createdAt", + "plugin"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin_method"."name", + "plugin_method"."title", + "plugin_method"."description", + "plugin_method"."types", + "plugin_method"."schema", + "plugin_method"."hostFunctions", + "plugin_method"."uiHints", + "plugin"."name" as "pluginName" + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" from - "plugin_filter" + "plugin" where - "id" = $1 + "plugin"."name" = $1 --- PluginRepository.getFiltersByPlugin +-- PluginRepository.get select - * + "plugin"."id", + "plugin"."name", + "plugin"."title", + "plugin"."description", + "plugin"."author", + "plugin"."version", + "plugin"."createdAt", + "plugin"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin_method"."name", + "plugin_method"."title", + "plugin_method"."description", + "plugin_method"."types", + "plugin_method"."schema", + "plugin_method"."hostFunctions", + "plugin_method"."uiHints", + "plugin"."name" as "pluginName" + from + "plugin_method" + where + "plugin_method"."pluginId" = "plugin"."id" + ) as agg + ) as "methods" from - "plugin_filter" + "plugin" where - "pluginId" = $1 + "plugin"."id" = $1 --- PluginRepository.getAction +-- PluginRepository.getForValidation select - * + "plugin_method"."id", + "plugin_method"."name", + "plugin"."name" as "pluginName", + "plugin_method"."types" from - "plugin_action" -where - "id" = $1 + "plugin_method" + inner join "plugin" on "plugin_method"."pluginId" = "plugin"."id" --- PluginRepository.getActionsByPlugin +-- PluginRepository.searchMethods select - * + "plugin"."name" as "pluginName", + "plugin_method"."pluginId", + "plugin_method"."id", + "plugin_method"."name", + "plugin_method"."title", + "plugin_method"."description", + "plugin_method"."types", + "plugin_method"."schema", + "plugin_method"."hostFunctions", + "plugin_method"."uiHints" from - "plugin_action" -where - "pluginId" = $1 + "plugin_method" + inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" +order by + "plugin_method"."name" diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index 27dc21dffe..fb62a51a73 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -1,70 +1,101 @@ -- NOTE: This file is auto generated by ./sql-generator --- WorkflowRepository.getWorkflow +-- WorkflowRepository.search select - * + "workflow"."id", + "workflow"."name", + "workflow"."description", + "workflow"."trigger", + "workflow"."enabled", + "workflow"."createdAt", + "workflow"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin"."name" as "pluginName", + "plugin_method"."name" as "methodName", + "workflow_step"."config", + "workflow_step"."enabled" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" + where + "workflow"."id" = "workflow_step"."workflowId" + ) as agg + ) as "steps" +from + "workflow" +order by + "createdAt" desc + +-- WorkflowRepository.get +select + "workflow"."id", + "workflow"."name", + "workflow"."description", + "workflow"."trigger", + "workflow"."enabled", + "workflow"."createdAt", + "workflow"."updatedAt", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "plugin"."name" as "pluginName", + "plugin_method"."name" as "methodName", + "workflow_step"."config", + "workflow_step"."enabled" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId" + where + "workflow"."id" = "workflow_step"."workflowId" + ) as agg + ) as "steps" from "workflow" where "id" = $1 -order by - "createdAt" desc --- WorkflowRepository.getWorkflowsByOwner +-- WorkflowRepository.getForWorkflowRun select - * + "workflow"."id", + "workflow"."name", + "workflow"."trigger", + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select + "workflow_step"."id", + "workflow_step"."config", + "plugin_method"."pluginId" as "pluginId", + "plugin_method"."name" as "methodName", + "plugin_method"."types" as "types", + "plugin_method"."hostFunctions" + from + "workflow_step" + inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId" + where + "workflow_step"."workflowId" = "workflow"."id" + and "workflow_step"."enabled" = $1 + ) as agg + ) as "steps" from "workflow" where - "ownerId" = $1 -order by - "createdAt" desc - --- WorkflowRepository.getWorkflowsByTrigger -select - * -from - "workflow" -where - "triggerType" = $1 - and "enabled" = $2 - --- WorkflowRepository.getWorkflowByOwnerAndTrigger -select - * -from - "workflow" -where - "ownerId" = $1 - and "triggerType" = $2 + "id" = $2 and "enabled" = $3 --- WorkflowRepository.deleteWorkflow +-- WorkflowRepository.delete delete from "workflow" where "id" = $1 - --- WorkflowRepository.getFilters -select - * -from - "workflow_filter" -where - "workflowId" = $1 -order by - "order" asc - --- WorkflowRepository.deleteFiltersByWorkflow -delete from "workflow_filter" -where - "workflowId" = $1 - --- WorkflowRepository.getActions -select - * -from - "workflow_action" -where - "workflowId" = $1 -order by - "order" asc diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 240197e9ab..f88deca09d 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -346,7 +346,7 @@ const getEnv = (): EnvData => { root: folders.web, indexHtml: join(folders.web, 'index.html'), }, - corePlugin: join(buildFolder, 'corePlugin'), + corePlugin: join(buildFolder, 'plugins', 'immich-plugin-core'), }, setup: { diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 39867b14d0..c1df648f09 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -119,6 +119,10 @@ export class LoggingRepository { logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; } + getLogLevel(): LogLevel { + return logLevels[0] || LogLevel.Fatal; + } + verbose(message: string, ...details: LogDetails) { this.handleMessage(LogLevel.Verbose, message, details); } diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 6217237947..a07f433541 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -1,176 +1,254 @@ +import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { Injectable } from '@nestjs/common'; -import { Kysely } from 'kysely'; +import { createPool, Pool } from 'generic-pool'; +import { Insertable, Kysely } from 'kysely'; import { jsonArrayFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; -import { readdir } from 'node:fs/promises'; import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { PluginMethodSearchDto, PluginSearchDto } from 'src/dtos/plugin.dto'; +import { LogLevel, WorkflowType } from 'src/enum'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { DB } from 'src/schema'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; + +type PluginMethod = { pluginKey: string; methodName: string }; +type PluginLoad = { key: string; label: string; wasmBytes: Buffer }; + +export type PluginHostFunction = (callContext: CallContext, input: bigint) => Promise | bigint; +export type PluginLoadOptions = { + runInWorker?: boolean; + functions?: Record; +}; + +export type PluginMethodSearchResponse = { + id: string; + name: string; + pluginName: string; + types: WorkflowType[]; +}; + +const levels = { + [LogLevel.Verbose]: 'trace', + [LogLevel.Debug]: 'debug', + [LogLevel.Log]: 'info', + [LogLevel.Warn]: 'warn', + [LogLevel.Error]: 'error', + [LogLevel.Fatal]: 'error', +} as const; + +const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info'; @Injectable() export class PluginRepository { - constructor(@InjectKysely() private db: Kysely) {} + private pluginMap: Map }> = new Map(); - /** - * Loads a plugin from a validated manifest file in a transaction. - * This ensures all plugin, filter, and action operations are atomic. - * @param manifest The validated plugin manifest - * @param basePath The base directory path where the plugin is located - */ - async loadPlugin(manifest: PluginManifestDto, basePath: string) { - return this.db.transaction().execute(async (tx) => { - // Upsert the plugin - const plugin = await tx - .insertInto('plugin') - .values({ - name: manifest.name, - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }) - .onConflict((oc) => - oc.column('name').doUpdateSet({ - title: manifest.title, - description: manifest.description, - author: manifest.author, - version: manifest.version, - wasmPath: `${basePath}/${manifest.wasm.path}`, - }), - ) - .returningAll() - .executeTakeFirstOrThrow(); - - const filters = manifest.filters - ? await tx - .insertInto('plugin_filter') - .values( - manifest.filters.map((filter) => ({ - pluginId: plugin.id, - methodName: filter.methodName, - title: filter.title, - description: filter.description, - supportedContexts: filter.supportedContexts, - schema: filter.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - const actions = manifest.actions - ? await tx - .insertInto('plugin_action') - .values( - manifest.actions.map((action) => ({ - pluginId: plugin.id, - methodName: action.methodName, - title: action.title, - description: action.description, - supportedContexts: action.supportedContexts, - schema: action.schema, - })), - ) - .onConflict((oc) => - oc.column('methodName').doUpdateSet((eb) => ({ - pluginId: eb.ref('excluded.pluginId'), - title: eb.ref('excluded.title'), - description: eb.ref('excluded.description'), - supportedContexts: eb.ref('excluded.supportedContexts'), - schema: eb.ref('excluded.schema'), - })), - ) - .returningAll() - .execute() - : []; - - return { plugin, filters, actions }; - }); - } - - async readDirectory(path: string) { - return readdir(path, { withFileTypes: true }); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getPlugin(id: string) { - return this.db - .selectFrom('plugin') - .select((eb) => [ - ...columns.plugin, - jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), - ]) - .where('plugin.id', '=', id) - .executeTakeFirst(); - } - - @GenerateSql({ params: [DummyValue.STRING] }) - getPluginByName(name: string) { - return this.db - .selectFrom('plugin') - .select((eb) => [ - ...columns.plugin, - jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), - ]) - .where('plugin.name', '=', name) - .executeTakeFirst(); + constructor( + @InjectKysely() private db: Kysely, + private logger: LoggingRepository, + ) { + this.logger.setContext(PluginRepository.name); } @GenerateSql() - getAllPlugins() { + getForLoad() { return this.db .selectFrom('plugin') .select((eb) => [ - ...columns.plugin, + 'plugin.id', + 'plugin.name', + 'plugin.version', + 'plugin.wasmBytes', jsonArrayFrom( - eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'), - ).as('filters'), - jsonArrayFrom( - eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'), - ).as('actions'), + eb + .selectFrom('plugin_method') + .whereRef('plugin_method.pluginId', '=', 'plugin.id') + .select(['plugin_method.name', 'plugin_method.hostFunctions']), + ).as('methods'), ]) + .where('enabled', '=', true) + .execute(); + } + + private queryBuilder() { + return this.db.selectFrom('plugin').select((eb) => [ + 'plugin.id', + 'plugin.name', + 'plugin.title', + 'plugin.description', + 'plugin.author', + 'plugin.version', + 'plugin.createdAt', + 'plugin.updatedAt', + jsonArrayFrom( + eb + .selectFrom('plugin_method') + .select([...columns.pluginMethod, 'plugin.name as pluginName']) + .whereRef('plugin_method.pluginId', '=', 'plugin.id'), + ).as('methods'), + ]); + } + + @GenerateSql() + search(dto: PluginSearchDto = {}) { + return this.queryBuilder() + .$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!)) + .$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!)) + .$if(!!dto.title, (qb) => qb.where('plugin.title', '=', dto.title!)) + .$if(!!dto.description, (qb) => qb.where('plugin.description', '=', dto.description!)) + .$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!)) .orderBy('plugin.name') .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFilter(id: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst(); + @GenerateSql({ params: [DummyValue.STRING] }) + getByName(name: string) { + return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFiltersByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute(); + get(id: string) { + return this.queryBuilder().where('plugin.id', '=', id).executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getAction(id: string) { - return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst(); + @GenerateSql() + getForValidation(): Promise { + return this.db + .selectFrom('plugin_method') + .innerJoin('plugin', 'plugin_method.pluginId', 'plugin.id') + .select(['plugin_method.id', 'plugin_method.name', 'plugin.name as pluginName', 'plugin_method.types']) + .execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getActionsByPlugin(pluginId: string) { - return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute(); + @GenerateSql() + searchMethods(dto: PluginMethodSearchDto = {}) { + return this.db + .selectFrom('plugin_method') + .innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId') + .select(['plugin.name as pluginName', 'plugin_method.pluginId', 'plugin_method.id', ...columns.pluginMethod]) + .$if(!!dto.id, (qb) => qb.where('plugin_method.id', '=', dto.id!)) + .$if(!!dto.name, (qb) => qb.where('plugin_method.name', '=', dto.name!)) + .$if(!!dto.title, (qb) => qb.where('plugin_method.title', '=', dto.title!)) + .$if(!!dto.type, (qb) => qb.where('plugin_method.types', '@>', [dto.type!])) + .$if(!!dto.description, (qb) => qb.where('plugin_method.description', '=', dto.description!)) + .$if(!!dto.pluginVersion, (qb) => qb.where('plugin.version', '=', dto.pluginVersion!)) + .$if(!!dto.pluginName, (qb) => qb.where('plugin.name', '=', dto.pluginName!)) + .orderBy('plugin_method.name') + .execute(); + } + + async upsert(dto: Insertable, initialMethods: Omit, 'pluginId'>[]) { + return this.db.transaction().execute(async (tx) => { + // Upsert the plugin + const plugin = await tx + .insertInto('plugin') + .values(dto) + .onConflict((oc) => + oc.columns(['name', 'version']).doUpdateSet((eb) => ({ + title: eb.ref('excluded.title'), + description: eb.ref('excluded.description'), + author: eb.ref('excluded.author'), + version: eb.ref('excluded.version'), + wasmBytes: eb.ref('excluded.wasmBytes'), + })), + ) + .returning(['id', 'name']) + .executeTakeFirstOrThrow(); + + // prune methods that no longer exist + if (initialMethods.length > 0) { + await tx + .deleteFrom('plugin_method') + .where('plugin_method.pluginId', '=', plugin.id) + .where( + 'name', + 'not in', + initialMethods.map((method) => method.name), + ) + .execute(); + } + + const methods = + initialMethods.length > 0 + ? await tx + .insertInto('plugin_method') + .values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id }))) + .onConflict((oc) => + oc.columns(['pluginId', 'name']).doUpdateSet(({ ref }) => ({ + pluginId: ref('excluded.pluginId'), + name: ref('excluded.name'), + title: ref('excluded.title'), + description: ref('excluded.description'), + types: ref('excluded.types'), + hostFunctions: ref('excluded.hostFunctions'), + uiHints: ref('excluded.uiHints'), + schema: ref('excluded.schema'), + })), + ) + .returningAll() + .execute() + : []; + + return { ...plugin, methods }; + }); + } + + async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) { + const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength); + const logger = LoggingRepository.create(`Plugin:${label}`); + const pool = createPool( + { + create: () => + newPlugin( + { wasm: [{ data }] }, + { + useWasi: true, + runInWorker, + functions: { + 'extism:host/user': functions ?? {}, + }, + logger: { + trace: (message) => logger.verbose(message), + info: (message) => logger.log(message), + debug: (message) => logger.debug(message), + warn: (message) => logger.warn(message), + error: (message) => logger.error(message), + } as Console, + logLevel: asExtismLogLevel(logger.getLogLevel()), + }, + ), + destroy: (plugin) => plugin.close(), + }, + { min: 1, max: 5 }, + ); + + try { + await pool.ready(); + this.pluginMap.set(key, { pool, label }); + } catch (error: Error | any) { + throw new Error(`Unable to instantiate plugin: ${key}`, { cause: error }); + } + } + + async callMethod({ pluginKey, methodName }: PluginMethod, input: unknown) { + const item = this.pluginMap.get(pluginKey); + if (!item) { + throw new Error(`No loaded plugin found for ${pluginKey}`); + } + + const { pool, label } = item; + + try { + const plugin = await pool.acquire(); + try { + const result = await plugin.call(methodName, JSON.stringify(input)); + return (result ? result.json() : result) as T; + } finally { + await pool.release(plugin); + } + } catch (error: Error | any) { + throw new Error(`Plugin method call failed: ${label}#${methodName}`, { cause: error }); + } } } diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index c7ba4ab6cc..1d3971fd28 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { ChokidarOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; -import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs'; +import { + constants, + createReadStream, + createWriteStream, + Dirent, + existsSync, + mkdirSync, + ReadOptionsWithBuffer, +} from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { PassThrough, Readable, Writable } from 'node:stream'; @@ -50,6 +58,10 @@ export class StorageRepository { return fs.readdir(folder); } + readdirWithTypes(folder: string): Promise { + return fs.readdir(folder, { withFileTypes: true }); + } + copyFile(source: string, target: string) { return fs.copyFile(source, target); } @@ -117,17 +129,24 @@ export class StorageRepository { } async readFile(filepath: string, options?: ReadOptionsWithBuffer): Promise { - const file = await fs.open(filepath); - try { - const { buffer } = await file.read(options); - return buffer as Buffer; - } finally { - await file.close(); + // read a slice + if (options) { + const file = await fs.open(filepath); + try { + const { buffer } = await file.read(options); + return buffer as Buffer; + } finally { + await file.close(); + } } + + // read everything + return fs.readFile(filepath); } - async readTextFile(filepath: string): Promise { - return fs.readFile(filepath, 'utf8'); + async readJsonFile(filepath: string): Promise { + const file = await fs.readFile(filepath, 'utf8'); + return JSON.parse(file) as T; } async checkFileExists(filepath: string, mode = constants.F_OK): Promise { diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index deaf2aa2fc..cd5aa6431f 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -1,149 +1,180 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable } from 'kysely'; +import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; +import { columns } from 'src/database'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { PluginTriggerType } from 'src/enum'; +import { WorkflowSearchDto } from 'src/dtos/workflow.dto'; import { DB } from 'src/schema'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; + +export type WorkflowStepUpsert = Omit, 'workflowId' | 'order'>; @Injectable() export class WorkflowRepository { constructor(@InjectKysely() private db: Kysely) {} + private queryBuilder(db?: Kysely) { + return (db ?? this.db) + .selectFrom('workflow') + .select([ + 'workflow.id', + 'workflow.name', + 'workflow.description', + 'workflow.trigger', + 'workflow.enabled', + 'workflow.createdAt', + 'workflow.updatedAt', + ]) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('workflow_step') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId') + .whereRef('workflow.id', '=', 'workflow_step.workflowId') + .select([ + 'plugin.name as pluginName', + 'plugin_method.name as methodName', + 'workflow_step.config', + 'workflow_step.enabled', + ]), + ).as('steps'), + ]); + } + @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflow(id: string) { + search(dto: WorkflowSearchDto & { ownerId?: string }) { + return this.queryBuilder() + .$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!)) + .$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!)) + .$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!)) + .orderBy('createdAt', 'desc') + .execute(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + get(id: string) { + return this.queryBuilder().where('id', '=', id).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getForWorkflowRun(id: string) { return this.db .selectFrom('workflow') - .selectAll() + .select(['workflow.id', 'workflow.name', 'workflow.trigger']) + .select((eb) => [ + jsonArrayFrom( + eb + .selectFrom('workflow_step') + .innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId') + .whereRef('workflow_step.workflowId', '=', 'workflow.id') + .where('workflow_step.enabled', '=', true) + .select([ + 'workflow_step.id', + 'workflow_step.config', + 'plugin_method.pluginId as pluginId', + 'plugin_method.name as methodName', + 'plugin_method.types as types', + 'plugin_method.hostFunctions', + ]), + ).as('steps'), + ]) .where('id', '=', id) - .orderBy('createdAt', 'desc') + .where('enabled', '=', true) .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getWorkflowsByOwner(ownerId: string) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .orderBy('createdAt', 'desc') - .execute(); - } - - @GenerateSql({ params: [PluginTriggerType.AssetCreate] }) - getWorkflowsByTrigger(type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] }) - getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) { - return this.db - .selectFrom('workflow') - .selectAll() - .where('ownerId', '=', ownerId) - .where('triggerType', '=', type) - .where('enabled', '=', true) - .execute(); - } - - async createWorkflow( - workflow: Insertable, - filters: Insertable[], - actions: Insertable[], - ) { - return await this.db.transaction().execute(async (tx) => { - const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow(); - - if (filters.length > 0) { - const newFilters = filters.map((filter) => ({ - ...filter, - workflowId: createdWorkflow.id, - })); - - await tx.insertInto('workflow_filter').values(newFilters).execute(); - } - - if (actions.length > 0) { - const newActions = actions.map((action) => ({ - ...action, - workflowId: createdWorkflow.id, - })); - await tx.insertInto('workflow_action').values(newActions).execute(); - } - - return createdWorkflow; + create(dto: Insertable, steps?: WorkflowStepUpsert[]) { + return this.db.transaction().execute(async (tx) => { + const { id } = await tx.insertInto('workflow').values(dto).returning(['id']).executeTakeFirstOrThrow(); + return this.replaceAndReturn(tx, id, steps); }); } - async updateWorkflow( - id: string, - workflow: Updateable, - filters: Insertable[] | undefined, - actions: Insertable[] | undefined, - ) { - return await this.db.transaction().execute(async (trx) => { - if (Object.keys(workflow).length > 0) { - await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute(); + update(id: string, dto: Updateable, steps?: WorkflowStepUpsert[]) { + return this.db.transaction().execute(async (tx) => { + if (Object.values(dto).some((prop) => prop !== undefined)) { + await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow(); } - - if (filters !== undefined) { - await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute(); - if (filters.length > 0) { - const filtersWithWorkflowId = filters.map((filter) => ({ - ...filter, - workflowId: id, - })); - await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute(); - } - } - - if (actions !== undefined) { - await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute(); - if (actions.length > 0) { - const actionsWithWorkflowId = actions.map((action) => ({ - ...action, - workflowId: id, - })); - await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute(); - } - } - - return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow(); + return this.replaceAndReturn(tx, id, steps); }); } + private async replaceAndReturn(tx: Kysely, workflowId: string, steps?: WorkflowStepUpsert[]) { + if (steps) { + await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute(); + if (steps.length > 0) { + await tx + .insertInto('workflow_step') + .values( + steps.map((step, i) => ({ + workflowId, + enabled: step.enabled ?? true, + pluginMethodId: step.pluginMethodId, + config: step.config, + order: i, + })), + ) + .returningAll() + .execute(); + } + } + + return this.queryBuilder(tx).where('id', '=', workflowId).executeTakeFirstOrThrow(); + } + @GenerateSql({ params: [DummyValue.UUID] }) - async deleteWorkflow(id: string) { + async delete(id: string) { await this.db.deleteFrom('workflow').where('id', '=', id).execute(); } - @GenerateSql({ params: [DummyValue.UUID] }) - getFilters(workflowId: string) { + getForAssetV1(assetId: string) { return this.db - .selectFrom('workflow_filter') - .selectAll() - .where('workflowId', '=', workflowId) - .orderBy('order', 'asc') - .execute(); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - async deleteFiltersByWorkflow(workflowId: string) { - await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute(); - } - - @GenerateSql({ params: [DummyValue.UUID] }) - getActions(workflowId: string) { - return this.db - .selectFrom('workflow_action') - .selectAll() - .where('workflowId', '=', workflowId) - .orderBy('order', 'asc') - .execute(); + .selectFrom('asset') + .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') + .select((eb) => [ + ...columns.workflowAssetV1, + jsonObjectFrom( + eb + .selectFrom('asset_exif') + .select([ + 'asset_exif.make', + 'asset_exif.model', + 'asset_exif.orientation', + 'asset_exif.dateTimeOriginal', + 'asset_exif.modifyDate', + 'asset_exif.exifImageWidth', + 'asset_exif.exifImageHeight', + 'asset_exif.fileSizeInByte', + 'asset_exif.lensModel', + 'asset_exif.fNumber', + 'asset_exif.focalLength', + 'asset_exif.iso', + 'asset_exif.latitude', + 'asset_exif.longitude', + 'asset_exif.city', + 'asset_exif.state', + 'asset_exif.country', + 'asset_exif.description', + 'asset_exif.fps', + 'asset_exif.exposureTime', + 'asset_exif.livePhotoCID', + 'asset_exif.timeZone', + 'asset_exif.projectionType', + 'asset_exif.profileDescription', + 'asset_exif.colorspace', + 'asset_exif.bitsPerSample', + 'asset_exif.autoStackId', + 'asset_exif.rating', + 'asset_exif.tags', + 'asset_exif.updatedAt', + ]) + .whereRef('asset_exif.assetId', '=', 'asset.id'), + ).as('exifInfo'), + ]) + .where('id', '=', assetId) + .executeTakeFirstOrThrow(); } } diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 3bb7caf5ff..033f0acd11 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -60,7 +60,8 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonAuditTable } from 'src/schema/tables/person-audit.table'; import { PersonTable } from 'src/schema/tables/person.table'; -import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { PluginTable } from 'src/schema/tables/plugin.table'; import { SessionTable } from 'src/schema/tables/session.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; @@ -82,7 +83,8 @@ import { VideoStreamSessionTable, VideoStreamVariantTable, } from 'src/schema/tables/video-stream.table'; -import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table'; +import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; @Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql']) @Database({ name: 'immich' }) @@ -143,11 +145,9 @@ export class ImmichDatabase { VideoStreamVariantTable, VideoStreamSegmentTable, PluginTable, - PluginFilterTable, - PluginActionTable, + PluginMethodTable, WorkflowTable, - WorkflowFilterTable, - WorkflowActionTable, + WorkflowStepTable, ]; functions = [ @@ -264,10 +264,8 @@ export interface DB { video_stream_segment: VideoStreamSegmentTable; plugin: PluginTable; - plugin_filter: PluginFilterTable; - plugin_action: PluginActionTable; + plugin_method: PluginMethodTable; workflow: WorkflowTable; - workflow_filter: WorkflowFilterTable; - workflow_action: WorkflowActionTable; + workflow_step: WorkflowStepTable; } diff --git a/server/src/schema/migrations/1773175313374-Test.ts b/server/src/schema/migrations/1773175313374-Test.ts new file mode 100644 index 0000000000..03479a760c --- /dev/null +++ b/server/src/schema/migrations/1773175313374-Test.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db); +} diff --git a/server/src/schema/migrations/1778614946174-UpdateWorkflowTables.ts b/server/src/schema/migrations/1778614946174-UpdateWorkflowTables.ts new file mode 100644 index 0000000000..087c9a55ab --- /dev/null +++ b/server/src/schema/migrations/1778614946174-UpdateWorkflowTables.ts @@ -0,0 +1,83 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // take #2... + await sql`DROP TABLE "workflow_action";`.execute(db); + await sql`DROP TABLE "workflow_filter";`.execute(db); + await sql`DROP TABLE "workflow";`.execute(db); + await sql`DROP TABLE "plugin_action";`.execute(db); + await sql`DROP TABLE "plugin_filter";`.execute(db); + await sql`DROP TABLE "plugin";`.execute(db); + + await sql`CREATE TABLE "plugin" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "name" character varying NOT NULL, + "version" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "author" character varying NOT NULL, + "wasmBytes" bytea NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"), + CONSTRAINT "plugin_name_uq" UNIQUE ("name"), + CONSTRAINT "plugin_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db); + await sql`CREATE TABLE "plugin_method" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "pluginId" uuid NOT NULL, + "name" character varying NOT NULL, + "title" character varying NOT NULL, + "description" character varying NOT NULL, + "types" character varying[] NOT NULL, + "hostFunctions" boolean NOT NULL DEFAULT false, + "uiHints" character varying[] NOT NULL DEFAULT '{}', + "schema" jsonb, + CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"), + CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db); + await sql`CREATE TABLE "workflow" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "trigger" character varying NOT NULL, + "name" character varying, + "description" character varying, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + "enabled" boolean NOT NULL DEFAULT true, + CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "workflow_updatedAt" + BEFORE UPDATE ON "workflow" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`CREATE TABLE "workflow_step" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "enabled" boolean NOT NULL DEFAULT true, + "workflowId" uuid NOT NULL, + "pluginMethodId" uuid NOT NULL, + "config" jsonb, + "order" integer NOT NULL, + CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db); + await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_workflow_updatedAt', '{"type":"trigger","name":"workflow_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"workflow_updatedAt\\"\\n BEFORE UPDATE ON \\"workflow\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute( + db, + ); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db); +} + +export async function down(): Promise { + // not supported +} diff --git a/server/src/schema/tables/asset-exif.table.ts b/server/src/schema/tables/asset-exif.table.ts index ae47ecfb10..d725189e6d 100644 --- a/server/src/schema/tables/asset-exif.table.ts +++ b/server/src/schema/tables/asset-exif.table.ts @@ -111,7 +111,7 @@ export class AssetExifTable { tags!: string[] | null; @UpdateDateColumn({ default: () => 'clock_timestamp()' }) - updatedAt!: Generated; + updatedAt!: Generated; @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/schema/tables/plugin-method.table.ts b/server/src/schema/tables/plugin-method.table.ts new file mode 100644 index 0000000000..10cd7b449e --- /dev/null +++ b/server/src/schema/tables/plugin-method.table.ts @@ -0,0 +1,35 @@ +import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools'; +import { JsonSchemaDto } from 'src/dtos/json-schema.dto'; +import { WorkflowType } from 'src/enum'; +import { PluginTable } from 'src/schema/tables/plugin.table'; + +@Unique({ columns: ['pluginId', 'name'] }) +@Table('plugin_method') +export class PluginMethodTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginId!: string; + + @Column() + name!: string; + + @Column() + title!: string; + + @Column() + description!: string; + + @Column({ type: 'character varying', array: true }) + types!: Generated; + + @Column({ type: 'boolean', default: false }) + hostFunctions!: Generated; + + @Column({ type: 'jsonb', nullable: true }) + schema!: JsonSchemaDto | null; + + @Column({ type: 'character varying', default: [], array: true }) + uiHints!: Generated; +} diff --git a/server/src/schema/tables/plugin.table.ts b/server/src/schema/tables/plugin.table.ts index 5f82807f23..99763a4d43 100644 --- a/server/src/schema/tables/plugin.table.ts +++ b/server/src/schema/tables/plugin.table.ts @@ -1,25 +1,29 @@ import { Column, CreateDateColumn, - ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + Unique, UpdateDateColumn, } from '@immich/sql-tools'; -import { PluginContext } from 'src/enum'; -import type { JSONSchema } from 'src/types/plugin-schema.types'; +@Unique({ columns: ['name', 'version'] }) @Table('plugin') export class PluginTable { @PrimaryGeneratedColumn('uuid') id!: Generated; + @Column({ type: 'boolean', default: true }) + enabled!: Generated; + @Column({ index: true, unique: true }) name!: string; + @Column() + version!: string; + @Column() title!: string; @@ -29,11 +33,8 @@ export class PluginTable { @Column() author!: string; - @Column() - version!: string; - - @Column() - wasmPath!: string; + @Column({ type: 'bytea' }) + wasmBytes!: Buffer; @CreateDateColumn() createdAt!: Generated; @@ -41,55 +42,3 @@ export class PluginTable { @UpdateDateColumn() updatedAt!: Generated; } - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_filter') -export class PluginFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} - -@Index({ columns: ['supportedContexts'], using: 'gin' }) -@Table('plugin_action') -export class PluginActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - @Column({ index: true }) - pluginId!: string; - - @Column({ index: true, unique: true }) - methodName!: string; - - @Column() - title!: string; - - @Column() - description!: string; - - @Column({ type: 'character varying', array: true }) - supportedContexts!: Generated; - - @Column({ type: 'jsonb', nullable: true }) - schema!: JSONSchema | null; -} diff --git a/server/src/schema/tables/workflow-step.table.ts b/server/src/schema/tables/workflow-step.table.ts new file mode 100644 index 0000000000..49cb58c731 --- /dev/null +++ b/server/src/schema/tables/workflow-step.table.ts @@ -0,0 +1,26 @@ +import { WorkflowStepConfig } from '@immich/plugin-sdk'; +import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools'; +import { Generated } from 'kysely'; +import { PluginMethodTable } from 'src/schema/tables/plugin-method.table'; +import { WorkflowTable } from 'src/schema/tables/workflow.table'; + +@Table('workflow_step') +export class WorkflowStepTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @Column({ type: 'boolean', default: true }) + enabled!: boolean; + + @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + workflowId!: string; + + @ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + pluginMethodId!: string; + + @Column({ type: 'jsonb', nullable: true }) + config!: WorkflowStepConfig | null; + + @Column({ type: 'integer' }) + order!: number; +} diff --git a/server/src/schema/tables/workflow.table.ts b/server/src/schema/tables/workflow.table.ts index 163518e039..8ac89d4b65 100644 --- a/server/src/schema/tables/workflow.table.ts +++ b/server/src/schema/tables/workflow.table.ts @@ -3,17 +3,17 @@ import { CreateDateColumn, ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + UpdateDateColumn, } from '@immich/sql-tools'; -import { PluginTriggerType } from 'src/enum'; -import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { WorkflowTrigger } from 'src/enum'; import { UserTable } from 'src/schema/tables/user.table'; -import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types'; @Table('workflow') +@UpdatedAtTrigger('workflow_updatedAt') export class WorkflowTable { @PrimaryGeneratedColumn() id!: Generated; @@ -22,57 +22,23 @@ export class WorkflowTable { ownerId!: string; @Column() - triggerType!: PluginTriggerType; + trigger!: WorkflowTrigger; @Column({ nullable: true }) name!: string | null; - @Column() - description!: string; + @Column({ nullable: true }) + description!: string | null; @CreateDateColumn() createdAt!: Generated; + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn() + updateId!: Generated; + @Column({ type: 'boolean', default: true }) - enabled!: boolean; -} - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginFilterId'] }) -@Table('workflow_filter') -export class WorkflowFilterTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginFilterId!: string; - - @Column({ type: 'jsonb', nullable: true }) - filterConfig!: FilterConfig | null; - - @Column({ type: 'integer' }) - order!: number; -} - -@Index({ columns: ['workflowId', 'order'] }) -@Index({ columns: ['pluginActionId'] }) -@Table('workflow_action') -export class WorkflowActionTable { - @PrimaryGeneratedColumn('uuid') - id!: Generated; - - @ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - workflowId!: Generated; - - @ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) - pluginActionId!: string; - - @Column({ type: 'jsonb', nullable: true }) - actionConfig!: ActionConfig | null; - - @Column({ type: 'integer' }) - order!: number; + enabled!: Generated; } diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index d930dd0a31..33534f16de 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -58,6 +58,7 @@ import { ViewRepository } from 'src/repositories/view-repository'; import { WebsocketRepository } from 'src/repositories/websocket.repository'; import { WorkflowRepository } from 'src/repositories/workflow.repository'; import { UserTable } from 'src/schema/tables/user.table'; +import { ClassConstructor } from 'src/types'; import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access'; import { getConfig, updateConfig } from 'src/utils/config'; @@ -187,6 +188,66 @@ export class BaseService { ); } + static create(Service: ClassConstructor, ctx: BaseService) { + const service = new Service( + LoggingRepository.create(), + ctx.accessRepository, + ctx.activityRepository, + ctx.albumRepository, + ctx.albumUserRepository, + ctx.apiKeyRepository, + ctx.appRepository, + ctx.assetRepository, + ctx.assetEditRepository, + ctx.assetJobRepository, + ctx.configRepository, + ctx.cronRepository, + ctx.cryptoRepository, + ctx.databaseRepository, + ctx.downloadRepository, + ctx.duplicateRepository, + ctx.emailRepository, + ctx.eventRepository, + ctx.jobRepository, + ctx.libraryRepository, + ctx.machineLearningRepository, + ctx.mapRepository, + ctx.mediaRepository, + ctx.memoryRepository, + ctx.metadataRepository, + ctx.moveRepository, + ctx.notificationRepository, + ctx.oauthRepository, + ctx.ocrRepository, + ctx.partnerRepository, + ctx.personRepository, + ctx.pluginRepository, + ctx.processRepository, + ctx.searchRepository, + ctx.serverInfoRepository, + ctx.sessionRepository, + ctx.sharedLinkRepository, + ctx.sharedLinkAssetRepository, + ctx.stackRepository, + ctx.storageRepository, + ctx.syncRepository, + ctx.syncCheckpointRepository, + ctx.systemMetadataRepository, + ctx.tagRepository, + ctx.telemetryRepository, + ctx.trashRepository, + ctx.userRepository, + ctx.versionRepository, + ctx.viewRepository, + ctx.websocketRepository, + ctx.workflowRepository, + ); + + service.logger.setContext(this.name); + + return service as T; + } + get worker() { return this.configRepository.getWorker(); } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index f4e82b13a4..b733483aa8 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -44,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service'; import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; import { ViewService } from 'src/services/view.service'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; import { WorkflowService } from 'src/services/workflow.service'; export const services = [ @@ -93,5 +94,6 @@ export const services = [ UserService, VersionService, ViewService, + WorkflowExecutionService, WorkflowService, ]; diff --git a/server/src/services/plugin-host.functions.ts b/server/src/services/plugin-host.functions.ts deleted file mode 100644 index 50b1052b54..0000000000 --- a/server/src/services/plugin-host.functions.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { CurrentPlugin } from '@extism/extism'; -import { UnauthorizedException } from '@nestjs/common'; -import { Updateable } from 'kysely'; -import { Permission } from 'src/enum'; -import { AccessRepository } from 'src/repositories/access.repository'; -import { AlbumRepository } from 'src/repositories/album.repository'; -import { AssetRepository } from 'src/repositories/asset.repository'; -import { CryptoRepository } from 'src/repositories/crypto.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { AssetTable } from 'src/schema/tables/asset.table'; -import { requireAccess } from 'src/utils/access'; - -/** - * Plugin host functions that are exposed to WASM plugins via Extism. - * These functions allow plugins to interact with the Immich system. - */ -export class PluginHostFunctions { - constructor( - private assetRepository: AssetRepository, - private albumRepository: AlbumRepository, - private accessRepository: AccessRepository, - private cryptoRepository: CryptoRepository, - private logger: LoggingRepository, - private pluginJwtSecret: string, - ) {} - - /** - * Creates Extism host function bindings for the plugin. - * These are the functions that WASM plugins can call. - */ - getHostFunctions() { - return { - 'extism:host/user': { - updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs), - addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs), - }, - }; - } - - /** - * Host function wrapper for updateAsset. - * Reads the input from the plugin, parses it, and calls the actual update function. - */ - private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.updateAsset(input); - } - - /** - * Host function wrapper for addAssetToAlbum. - * Reads the input from the plugin, parses it, and calls the actual add function. - */ - private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) { - const input = JSON.parse(cp.read(offs)!.text()); - await this.addAssetToAlbum(input); - } - - /** - * Validates the JWT token and returns the auth context. - */ - private validateToken(authToken: string): { userId: string } { - try { - const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret); - if (!auth.userId) { - throw new UnauthorizedException('Invalid token: missing userId'); - } - return auth; - } catch (error) { - this.logger.error('Token validation failed:', error); - throw new UnauthorizedException('Invalid token'); - } - } - - /** - * Updates an asset with the given properties. - */ - async updateAsset(input: { authToken: string } & Updateable & { id: string }) { - const { authToken, id, ...assetData } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to the asset - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetUpdate, - ids: [id], - }); - - this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`); - await this.assetRepository.update({ id, ...assetData }); - } - - /** - * Adds an asset to an album. - */ - async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) { - const { authToken, assetId, albumId } = input; - - // Validate token - const auth = this.validateToken(authToken); - - // Check access to both the asset and the album - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AssetRead, - ids: [assetId], - }); - - await requireAccess(this.accessRepository, { - auth: { user: { id: auth.userId } } as any, - permission: Permission.AlbumUpdate, - ids: [albumId], - }); - - this.logger.log(`Adding asset ${assetId} to album ${albumId}`); - await this.albumRepository.addAssetIds(albumId, [assetId]); - return 0; - } -} diff --git a/server/src/services/plugin.service.ts b/server/src/services/plugin.service.ts index 7209a613fe..62edf8438b 100644 --- a/server/src/services/plugin.service.ts +++ b/server/src/services/plugin.service.ts @@ -1,313 +1,34 @@ -import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { join } from 'node:path'; -import { Asset, WorkflowAction, WorkflowFilter } from 'src/database'; -import { OnEvent, OnJob } from 'src/decorators'; -import { PluginManifestDto, PluginManifestSchema } from 'src/dtos/plugin-manifest.dto'; -import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto'; -import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum'; -import { pluginTriggers } from 'src/plugins'; -import { ArgOf } from 'src/repositories/event.repository'; +import { + mapMethod, + mapPlugin, + PluginMethodResponseDto, + PluginMethodSearchDto, + PluginResponseDto, + PluginSearchDto, +} from 'src/dtos/plugin.dto'; import { BaseService } from 'src/services/base.service'; -import { PluginHostFunctions } from 'src/services/plugin-host.functions'; -import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types'; - -interface WorkflowContext { - authToken: string; - asset: Asset; -} - -interface PluginInput { - authToken: string; - config: T; - data: { - asset: Asset; - }; -} +import { isMethodCompatible } from 'src/utils/workflow'; @Injectable() export class PluginService extends BaseService { - private pluginJwtSecret!: string; - private loadedPlugins: Map = new Map(); - private hostFunctions!: PluginHostFunctions; - - @OnEvent({ name: 'AppBootstrap' }) - async onBootstrap() { - this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32); - - await this.loadPluginsFromManifests(); - - this.hostFunctions = new PluginHostFunctions( - this.assetRepository, - this.albumRepository, - this.accessRepository, - this.cryptoRepository, - this.logger, - this.pluginJwtSecret, - ); - - await this.loadPlugins(); - } - - getTriggers(): PluginTriggerResponseDto[] { - return pluginTriggers; - } - - // - // CRUD operations for plugins - // - async getAll(): Promise { - const plugins = await this.pluginRepository.getAllPlugins(); + async search(dto: PluginSearchDto): Promise { + const plugins = await this.pluginRepository.search(dto); return plugins.map((plugin) => mapPlugin(plugin)); } async get(id: string): Promise { - const plugin = await this.pluginRepository.getPlugin(id); + const plugin = await this.pluginRepository.get(id); if (!plugin) { throw new BadRequestException('Plugin not found'); } return mapPlugin(plugin); } - /////////////////////////////////////////// - // Plugin Loader - ////////////////////////////////////////// - async loadPluginsFromManifests(): Promise { - // Load core plugin - const { resourcePaths, plugins } = this.configRepository.getEnv(); - const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`; - - const coreManifest = await this.readAndValidateManifest(coreManifestPath); - await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin); - - this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`); - - // Load external plugins - if (plugins.external.allow && plugins.external.installFolder) { - await this.loadExternalPlugins(plugins.external.installFolder); - } - } - - private async loadExternalPlugins(installFolder: string): Promise { - try { - const entries = await this.pluginRepository.readDirectory(installFolder); - - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - - const pluginFolder = join(installFolder, entry.name); - const manifestPath = join(pluginFolder, 'manifest.json'); - try { - const manifest = await this.readAndValidateManifest(manifestPath); - await this.loadPluginToDatabase(manifest, pluginFolder); - - this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`); - } catch (error) { - this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error); - } - } - } catch (error) { - this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error); - } - } - - private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise { - const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name); - if (currentPlugin != null && currentPlugin.version === manifest.version) { - this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`); - return; - } - - const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath); - - this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`); - - for (const filter of filters) { - this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`); - } - - for (const action of actions) { - this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`); - } - } - - private async readAndValidateManifest(manifestPath: string): Promise { - const content = await this.storageRepository.readTextFile(manifestPath); - const manifestData = JSON.parse(content); - return PluginManifestSchema.parse(manifestData); - } - - /////////////////////////////////////////// - // Plugin Execution - /////////////////////////////////////////// - private async loadPlugins() { - const plugins = await this.pluginRepository.getAllPlugins(); - for (const plugin of plugins) { - try { - this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`); - - const extismPlugin = await newPlugin(plugin.wasmPath, { - useWasi: true, - functions: this.hostFunctions.getHostFunctions(), - }); - - this.loadedPlugins.set(plugin.id, extismPlugin); - this.logger.log(`Successfully loaded plugin: ${plugin.name}`); - } catch (error) { - this.logger.error(`Failed to load plugin ${plugin.name}:`, error); - } - } - } - - @OnEvent({ name: 'AssetCreate' }) - async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) { - await this.handleTrigger(PluginTriggerType.AssetCreate, { - ownerId: asset.ownerId, - event: { userId: asset.ownerId, asset }, - }); - } - - private async handleTrigger( - triggerType: T, - params: { ownerId: string; event: WorkflowData[T] }, - ): Promise { - const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType); - if (workflows.length === 0) { - return; - } - - const jobs: JobItem[] = workflows.map((workflow) => ({ - name: JobName.WorkflowRun, - data: { - id: workflow.id, - type: triggerType, - event: params.event, - } as IWorkflowJob, - })); - - await this.jobRepository.queueAll(jobs); - this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`); - } - - @OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow }) - async handleWorkflowRun({ id: workflowId, type, event }: JobOf): Promise { - try { - const workflow = await this.workflowRepository.getWorkflow(workflowId); - if (!workflow) { - this.logger.error(`Workflow ${workflowId} not found`); - return JobStatus.Failed; - } - - const workflowFilters = await this.workflowRepository.getFilters(workflowId); - const workflowActions = await this.workflowRepository.getActions(workflowId); - - switch (type) { - case PluginTriggerType.AssetCreate: { - const data = event as WorkflowData[PluginTriggerType.AssetCreate]; - const asset = data.asset; - - const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret); - - const context = { - authToken, - asset, - }; - - const filtersPassed = await this.executeFilters(workflowFilters, context); - if (!filtersPassed) { - return JobStatus.Skipped; - } - - await this.executeActions(workflowActions, context); - this.logger.debug(`Workflow ${workflowId} executed successfully`); - return JobStatus.Success; - } - - case PluginTriggerType.PersonRecognized: { - this.logger.error('unimplemented'); - return JobStatus.Skipped; - } - - default: { - this.logger.error(`Unknown workflow trigger type: ${type}`); - return JobStatus.Failed; - } - } - } catch (error) { - this.logger.error(`Error executing workflow ${workflowId}:`, error); - return JobStatus.Failed; - } - } - - private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise { - for (const workflowFilter of workflowFilters) { - const filter = await this.pluginRepository.getFilter(workflowFilter.pluginFilterId); - if (!filter) { - this.logger.error(`Filter ${workflowFilter.pluginFilterId} not found`); - return false; - } - - const pluginInstance = this.loadedPlugins.get(filter.pluginId); - if (!pluginInstance) { - this.logger.error(`Plugin ${filter.pluginId} not loaded`); - return false; - } - - const filterInput: PluginInput = { - authToken: context.authToken, - config: workflowFilter.filterConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`); - - const filterResult = await pluginInstance.call( - filter.methodName, - new TextEncoder().encode(JSON.stringify(filterInput)), - ); - - if (!filterResult) { - this.logger.error(`Filter ${filter.methodName} returned null`); - return false; - } - - const result = JSON.parse(filterResult.text()); - if (result.passed === false) { - this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`); - return false; - } - } - - return true; - } - - private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise { - for (const workflowAction of workflowActions) { - const action = await this.pluginRepository.getAction(workflowAction.pluginActionId); - if (!action) { - throw new Error(`Action ${workflowAction.pluginActionId} not found`); - } - - const pluginInstance = this.loadedPlugins.get(action.pluginId); - if (!pluginInstance) { - throw new Error(`Plugin ${action.pluginId} not loaded`); - } - - const actionInput: PluginInput = { - authToken: context.authToken, - config: workflowAction.actionConfig, - data: { - asset: context.asset, - }, - }; - - this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`); - - await pluginInstance.call(action.methodName, JSON.stringify(actionInput)); - } + async searchMethods(dto: PluginMethodSearchDto): Promise { + const methods = await this.pluginRepository.searchMethods(dto); + return methods + .filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger)) + .map((method) => mapMethod(method)); } } diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts new file mode 100644 index 0000000000..b2e106a251 --- /dev/null +++ b/server/src/services/workflow-execution.service.ts @@ -0,0 +1,344 @@ +import { CurrentPlugin } from '@extism/extism'; +import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk'; +import { HttpException, UnauthorizedException } from '@nestjs/common'; +import _ from 'lodash'; +import { join } from 'node:path'; +import { OnEvent, OnJob } from 'src/decorators'; +import { AlbumsAddAssetsDto } from 'src/dtos/album.dto'; +import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { + BootstrapEventPriority, + DatabaseLock, + ImmichWorker, + JobName, + JobStatus, + QueueName, + WorkflowTrigger, + WorkflowType, +} from 'src/enum'; +import { ArgOf } from 'src/repositories/event.repository'; +import { AlbumService } from 'src/services/album.service'; +import { BaseService } from 'src/services/base.service'; +import { JobOf } from 'src/types'; + +const dummy = () => { + throw new Error( + `Calling host functions is not allowed without setting methods[].hostFunctions=true in the plugin manifest`, + ); +}; + +type ExecuteOptions = { + read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData }>; + write: (changes: WorkflowChanges) => Promise; +}; + +export class WorkflowExecutionService extends BaseService { + private jwtSecret!: string; + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] }) + async onPluginSync() { + await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => { + // TODO avoid importing plugins in each worker + // Can this use system metadata similar to geocoding? + + const { resourcePaths, plugins } = this.configRepository.getEnv(); + await this.importFolder(resourcePaths.corePlugin, { force: true }); + + if (plugins.external.allow && plugins.external.installFolder) { + await this.importFolders(plugins.external.installFolder); + } + }); + } + + @OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] }) + async onPluginLoad() { + this.jwtSecret = this.cryptoRepository.randomBytesAsText(32); + + const albumService = BaseService.create(AlbumService, this); + + const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) => + albumService.addAssets(authDto, ...args), + ); + + const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) => + albumService.addAssetsToAlbums(authDto, ...args), + ); + + const functions = { + albumAddAssets, + addAssetsToAlbums, + }; + + const stubs = { + albumAddAssets: dummy, + addAssetsToAlbums: dummy, + }; + + const plugins = await this.pluginRepository.getForLoad(); + for (const { id, name, version, wasmBytes, methods } of plugins) { + const method = methods.some(({ hostFunctions }) => !hostFunctions); + if (method) { + const label = `${name}@${version}`; + const key = this.getPluginKey({ id, hostFunctions: false }); + try { + await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: false, functions: stubs }); + this.logger.log(`Loaded plugin: ${label}`); + } catch (error) { + this.logger.error(`Unable to load plugin ${label} (${id})`, error); + } + } + + const methodWithFunction = methods.some(({ hostFunctions }) => hostFunctions); + if (methodWithFunction) { + const label = `${name}@${version}/worker`; + const key = this.getPluginKey({ id, hostFunctions: true }); + try { + await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions }); + this.logger.log(`Loaded plugin with host functions: ${label}`); + } catch (error) { + this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error); + } + } + } + } + + private getPluginKey({ id, hostFunctions }: { id: string; hostFunctions: boolean }) { + return id + (hostFunctions ? '/worker' : ''); + } + + private wrap(fn: (authDto: AuthDto, args: T) => Promise) { + return async (plugin: CurrentPlugin, offset: bigint) => { + try { + const handle = plugin.read(offset); + if (!handle) { + return plugin.store( + JSON.stringify({ success: false, status: 400, message: 'Called host function without input' }), + ); + } + + const { authToken, args } = handle.json() as { authToken: string; args: T }; + if (!authToken) { + throw new Error('authToken is required'); + } + + const authDto = this.validate(authToken); + const response = await fn(authDto, args); + + return plugin.store(JSON.stringify({ success: true, response })); + } catch (error: Error | any) { + if (error instanceof HttpException) { + this.logger.error(`Plugin host exception: ${error}`); + return plugin.store( + JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }), + ); + } + + this.logger.error(`Plugin host exception: ${error}`, error?.stack); + + return plugin.store( + JSON.stringify({ + success: false, + status: 500, + message: `Internal server error: ${error}`, + }), + ); + } + }; + } + + private async importFolders(installFolder: string): Promise { + try { + const entries = await this.storageRepository.readdirWithTypes(installFolder); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + await this.importFolder(join(installFolder, entry.name)); + } + } catch (error) { + this.logger.error(`Failed to import plugins folder ${installFolder}:`, error); + } + } + + private async importFolder(folder: string, options?: { force?: boolean }) { + try { + const manifestPath = join(folder, 'manifest.json'); + const dto = await this.storageRepository.readJsonFile(manifestPath); + const result = PluginManifestDto.schema.safeParse(dto); + if (!result.success) { + const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n'); + this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${issues}`); + return; + } + const manifest = result.data; + + const existing = await this.pluginRepository.getByName(manifest.name); + if (existing && existing.version === manifest.version && options?.force !== true) { + return; + } + + const wasmPath = `${folder}/${manifest.wasmPath}`; + const wasmBytes = await this.storageRepository.readFile(wasmPath); + + const plugin = await this.pluginRepository.upsert( + { + enabled: true, + name: manifest.name, + title: manifest.title, + description: manifest.description, + author: manifest.author, + version: manifest.version, + wasmBytes, + }, + manifest.methods, + ); + + if (existing) { + this.logger.log( + `Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `, + ); + } else { + this.logger.log( + `Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`, + ); + } + + return manifest; + } catch { + this.logger.warn(`Failed to import plugin from ${folder}:`); + } + } + + private validate(authToken: string): AuthDto { + try { + const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret); + if (!jwt.userId) { + throw new UnauthorizedException('Invalid token: missing userId'); + } + + return { + user: { + id: jwt.userId, + }, + } as AuthDto; + } catch (error) { + this.logger.error('Token validation failed:', error); + throw new UnauthorizedException('Invalid token'); + } + } + + private sign(userId: string) { + return this.cryptoRepository.signJwt({ userId }, this.jwtSecret); + } + + @OnEvent({ name: 'AssetCreate' }) + async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) { + const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate }; + const items = await this.workflowRepository.search(dto); + await this.jobRepository.queueAll( + items.map((workflow) => ({ + name: JobName.WorkflowAssetCreate, + data: { workflowId: workflow.id, assetId: asset.id }, + })), + ); + } + + @OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow }) + handleAssetCreate({ workflowId, assetId }: JobOf) { + return this.execute(workflowId, (type) => { + switch (type) { + case WorkflowType.AssetV1: { + return { + read: async () => { + const asset = await this.workflowRepository.getForAssetV1(assetId); + return { + data: { asset } as any, + authUserId: asset.ownerId, + }; + }, + write: async (changes) => { + if (changes.asset) { + await this.assetRepository.update({ + id: assetId, + ..._.omitBy( + { + isFavorite: changes.asset?.isFavorite, + visibility: changes.asset?.visibility, + }, + _.isUndefined, + ), + }); + } + }, + } satisfies ExecuteOptions; + } + } + }); + } + + private async execute( + workflowId: string, + getHandler: (type: T) => ExecuteOptions | undefined, + ) { + const workflow = await this.workflowRepository.getForWorkflowRun(workflowId); + if (!workflow) { + return; + } + + // TODO infer from steps + const type = 'AssetV1' as T; + const handler = getHandler(type); + if (!handler) { + this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`); + return; + } + + try { + const { read, write } = handler; + const readResult = await read(type); + let data = readResult.data; + for (const step of workflow.steps) { + const payload: WorkflowEventPayload = { + trigger: workflow.trigger, + type, + config: step.config ?? {}, + workflow: { + id: workflowId, + authToken: this.sign(readResult.authUserId), + stepId: step.id, + }, + data, + }; + + if (step.methodName.startsWith('noop')) { + continue; + } + + const result = await this.pluginRepository.callMethod>( + { + pluginKey: this.getPluginKey({ id: step.pluginId, hostFunctions: step.hostFunctions }), + methodName: step.methodName, + }, + payload, + ); + if (result?.changes) { + await write(result.changes); + ({ data } = await read(type)); + } + + const shouldContinue = result?.workflow?.continue ?? true; + if (!shouldContinue) { + break; + } + } + + this.logger.debug(`Workflow ${workflowId} executed successfully`); + } catch (error) { + this.logger.error(`Error executing workflow ${workflowId}:`, error); + return JobStatus.Failed; + } + } +} diff --git a/server/src/services/workflow.service.ts b/server/src/services/workflow.service.ts index 1a65182b1f..0a62a60887 100644 --- a/server/src/services/workflow.service.ts +++ b/server/src/services/workflow.service.ts @@ -1,159 +1,114 @@ +import { WorkflowStepConfig } from '@immich/plugin-sdk'; import { BadRequestException, Injectable } from '@nestjs/common'; -import { Workflow } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { - mapWorkflowAction, - mapWorkflowFilter, + mapWorkflow, + mapWorkflowShare, WorkflowCreateDto, WorkflowResponseDto, + WorkflowSearchDto, + WorkflowShareResponseDto, + WorkflowTriggerResponseDto, WorkflowUpdateDto, } from 'src/dtos/workflow.dto'; -import { Permission, PluginContext, PluginTriggerType } from 'src/enum'; -import { pluginTriggers } from 'src/plugins'; - +import { Permission, WorkflowTrigger } from 'src/enum'; +import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; import { BaseService } from 'src/services/base.service'; +import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow'; @Injectable() export class WorkflowService extends BaseService { - async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { - const context = this.getContextForTrigger(dto.triggerType); - - const filterInserts = await this.validateAndMapFilters(dto.filters, context); - const actionInserts = await this.validateAndMapActions(dto.actions, context); - - const workflow = await this.workflowRepository.createWorkflow( - { - ownerId: auth.user.id, - triggerType: dto.triggerType, - name: dto.name, - description: dto.description || '', - enabled: dto.enabled ?? true, - }, - filterInserts, - actionInserts, - ); - - return this.mapWorkflow(workflow); + getTriggers(): WorkflowTriggerResponseDto[] { + return getWorkflowTriggers(); } - async getAll(auth: AuthDto): Promise { - const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id); - - return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow))); + async search(auth: AuthDto, dto: WorkflowSearchDto): Promise { + const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id }); + return workflows.map((workflow) => mapWorkflow(workflow)); } async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); const workflow = await this.findOrFail(id); - return this.mapWorkflow(workflow); + return mapWorkflow(workflow); + } + + async share(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] }); + const workflow = await this.findOrFail(id); + return mapWorkflowShare(workflow); + } + + async create(auth: AuthDto, dto: WorkflowCreateDto): Promise { + const { steps: stepsDto, ...workflowDto } = dto; + const steps = await this.resolveAndValidateSteps(stepsDto ?? [], workflowDto.trigger); + + const workflow = await this.workflowRepository.create( + { + ...workflowDto, + ownerId: auth.user.id, + }, + steps.map((step) => ({ + enabled: step.enabled ?? true, + config: step.config as WorkflowStepConfig, + pluginMethodId: step.pluginMethod.id, + })), + ); + + return mapWorkflow({ ...workflow, steps: [] }); } async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] }); - if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) { - throw new BadRequestException('No fields to update'); - } - - const workflow = await this.findOrFail(id); - const context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType); - - const { filters, actions, ...workflowUpdate } = dto; - const filterInserts = filters && (await this.validateAndMapFilters(filters, context)); - const actionInserts = actions && (await this.validateAndMapActions(actions, context)); - - const updatedWorkflow = await this.workflowRepository.updateWorkflow( + const { steps: stepsDto, ...workflowDto } = dto; + const current = await this.findOrFail(id); + const steps = stepsDto ? await this.resolveAndValidateSteps(stepsDto, dto.trigger ?? current.trigger) : undefined; + const workflow = await this.workflowRepository.update( id, - workflowUpdate, - filterInserts, - actionInserts, + workflowDto, + steps?.map((step) => ({ + enabled: step.enabled ?? true, + config: step.config as WorkflowStepConfig, + pluginMethodId: step.pluginMethod.id, + })), ); - return this.mapWorkflow(updatedWorkflow); + return mapWorkflow(workflow); } async delete(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] }); - await this.workflowRepository.deleteWorkflow(id); + await this.workflowRepository.delete(id); } - private async validateAndMapFilters( - filters: Array<{ pluginFilterId: string; filterConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of filters) { - const filter = await this.pluginRepository.getFilter(dto.pluginFilterId); - if (!filter) { - throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`); + private async resolveAndValidateSteps(steps: T[], trigger: WorkflowTrigger) { + const methods = await this.pluginRepository.getForValidation(); + const results: Array = []; + + for (const step of steps) { + const pluginMethod = resolveMethod(methods, step.method); + if (!pluginMethod) { + throw new BadRequestException(`Unknown method ${step.method}`); } - if (!filter.supportedContexts.includes(requiredContext)) { - throw new BadRequestException( - `Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`, - ); + if (!isMethodCompatible(pluginMethod, trigger)) { + throw new BadRequestException(`Method "${step.method}" is incompatible with workflow trigger: "${trigger}"`); } + + results.push({ ...step, pluginMethod }); } - return filters.map((dto, index) => ({ - pluginFilterId: dto.pluginFilterId, - filterConfig: dto.filterConfig || null, - order: index, - })); - } + // TODO make sure all steps can use a common WorkflowType - private async validateAndMapActions( - actions: Array<{ pluginActionId: string; actionConfig?: any }>, - requiredContext: PluginContext, - ) { - for (const dto of actions) { - const action = await this.pluginRepository.getAction(dto.pluginActionId); - if (!action) { - throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`); - } - if (!action.supportedContexts.includes(requiredContext)) { - throw new BadRequestException( - `Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`, - ); - } - } - - return actions.map((dto, index) => ({ - pluginActionId: dto.pluginActionId, - actionConfig: dto.actionConfig || null, - order: index, - })); - } - - private getContextForTrigger(type: PluginTriggerType) { - const trigger = pluginTriggers.find((t) => t.type === type); - if (!trigger) { - throw new BadRequestException(`Invalid trigger type: ${type}`); - } - return trigger.contextType; + return results; } private async findOrFail(id: string) { - const workflow = await this.workflowRepository.getWorkflow(id); + const workflow = await this.workflowRepository.get(id); if (!workflow) { throw new BadRequestException('Workflow not found'); } return workflow; } - - private async mapWorkflow(workflow: Workflow): Promise { - const filters = await this.workflowRepository.getFilters(workflow.id); - const actions = await this.workflowRepository.getActions(workflow.id); - - return { - id: workflow.id, - ownerId: workflow.ownerId, - triggerType: workflow.triggerType, - name: workflow.name, - description: workflow.description, - createdAt: workflow.createdAt.toISOString(), - enabled: workflow.enabled, - filters: filters.map((f) => mapWorkflowFilter(f)), - actions: actions.map((a) => mapWorkflowAction(a)), - }; - } } diff --git a/server/src/types.ts b/server/src/types.ts index aa6bb820cc..c7dc1f5e18 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -1,7 +1,7 @@ import { ShallowDehydrateObject } from 'kysely'; import { SystemConfig } from 'src/config'; import { VECTOR_EXTENSIONS } from 'src/constants'; -import { Asset, AssetFile } from 'src/database'; +import { AssetFile } from 'src/database'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; @@ -22,7 +22,6 @@ import { ImageFormat, JobName, MemoryType, - PluginTriggerType, QueueName, StorageFolder, SyncEntityType, @@ -30,10 +29,13 @@ import { TranscodeTarget, UserMetadataKey, VideoCodec, + WorkflowTrigger, + WorkflowType, } from 'src/enum'; -export type DeepPartial = - T extends Record +export type DeepPartial = T extends Date + ? T + : T extends Record ? { [K in keyof T]?: DeepPartial } : T extends Array ? DeepPartial[] @@ -288,22 +290,11 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { recipientId: string; } -export interface WorkflowData { - [PluginTriggerType.AssetCreate]: { - userId: string; - asset: Asset; - }; - [PluginTriggerType.PersonRecognized]: { - personId: string; - assetId: string; - }; -} - -export interface IWorkflowJob { +export type IWorkflowJob = { id: string; + trigger: WorkflowTrigger; type: T; - event: WorkflowData[T]; -} +}; export interface JobCounts { active: number; @@ -413,7 +404,7 @@ export type JobItem = | { name: JobName.Ocr; data: IEntityJob } // Workflow - | { name: JobName.WorkflowRun; data: IWorkflowJob } + | { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } } // Editor | { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob }; @@ -574,3 +565,20 @@ export interface UserMetadata extends Record = T | ShallowDehydrateObject; + +export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object'; + +export type JSONSchemaProperty = { + type: JSONSchemaType; + description?: string; + default?: any; + enum?: string[]; + array?: boolean; + properties?: Record; + required?: string[]; +}; + +// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type +export interface ClassConstructor extends Function { + new (...args: any[]): T; +} diff --git a/server/src/types/plugin-schema.types.ts b/server/src/types/plugin-schema.types.ts deleted file mode 100644 index da1f6da935..0000000000 --- a/server/src/types/plugin-schema.types.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * JSON Schema types for plugin configuration schemas - * Based on JSON Schema Draft 7 - */ - -import z from 'zod'; - -const JSONSchemaTypeSchema = z - .enum(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']) - .meta({ id: 'PluginJsonSchemaType' }); - -const JSONSchemaPropertySchema = z - .object({ - type: JSONSchemaTypeSchema.optional(), - description: z.string().optional(), - default: z.any().optional(), - enum: z.array(z.string()).optional(), - - get items() { - return JSONSchemaPropertySchema.optional(); - }, - - get properties() { - return z.record(z.string(), JSONSchemaPropertySchema).optional(); - }, - - required: z.array(z.string()).optional(), - - get additionalProperties() { - return z.union([z.boolean(), JSONSchemaPropertySchema]).optional(); - }, - }) - .meta({ id: 'PluginJsonSchemaProperty' }); - -export type JSONSchemaProperty = z.infer; - -export const JSONSchemaSchema = z - .object({ - type: JSONSchemaTypeSchema.optional(), - properties: z.record(z.string(), JSONSchemaPropertySchema).optional(), - required: z.array(z.string()).optional(), - additionalProperties: z.boolean().optional(), - description: z.string().optional(), - }) - .meta({ id: 'PluginJsonSchema' }); -export type JSONSchema = z.infer; - -type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }; - -const ConfigValueSchema: z.ZodType = z.any().meta({ id: 'PluginConfigValue' }); - -export const FilterConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowFilterConfig' }); -export type FilterConfig = z.infer; - -export const ActionConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowActionConfig' }); -export type ActionConfig = z.infer; diff --git a/server/src/utils/workflow.spec.ts b/server/src/utils/workflow.spec.ts new file mode 100644 index 0000000000..86bdd94e5b --- /dev/null +++ b/server/src/utils/workflow.spec.ts @@ -0,0 +1,36 @@ +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import { isMethodCompatible } from 'src/utils/workflow'; + +const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [ + { + trigger: WorkflowTrigger.AssetCreate, + types: [WorkflowType.AssetV1], + expected: true, + }, + { + trigger: WorkflowTrigger.AssetCreate, + types: [WorkflowType.AssetPersonV1], + expected: true, + }, + { + trigger: WorkflowTrigger.PersonRecognized, + types: [WorkflowType.AssetPersonV1], + expected: true, + }, + { + trigger: WorkflowTrigger.PersonRecognized, + types: [WorkflowType.AssetV1], + expected: false, + }, + { + trigger: WorkflowTrigger.PersonRecognized, + types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1], + expected: true, + }, +]; + +describe(isMethodCompatible.name, () => { + it.each(tests)('should return $expected for trigger $trigger with types $types', ({ trigger, types, expected }) => { + expect(isMethodCompatible({ types }, trigger)).toBe(expected); + }); +}); diff --git a/server/src/utils/workflow.ts b/server/src/utils/workflow.ts new file mode 100644 index 0000000000..5803fca342 --- /dev/null +++ b/server/src/utils/workflow.ts @@ -0,0 +1,68 @@ +import { WorkflowTrigger, WorkflowType } from 'src/enum'; +import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository'; + +export const triggerMap: Record = { + [WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1], + [WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1], +}; + +export const getWorkflowTriggers = () => + Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types })); + +/** some types extend other types and have implied compatibility */ +const inferredMap: Record = { + [WorkflowType.AssetV1]: [], + [WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1], +}; + +const withImpliedItems = (type: WorkflowType): WorkflowType[] => { + const childTypes = inferredMap[type]; + const results = [type]; + for (const child of childTypes) { + results.push(...withImpliedItems(child)); + } + + return results; +}; + +export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => { + const validTypes = triggerMap[trigger]; + const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type)); + for (const requested of validTypes) { + for (const pluginCompatibilityGroup of pluginCompatibility) { + if (pluginCompatibilityGroup.includes(requested)) { + return true; + } + } + } + + return false; +}; + +export const resolveMethod = (methods: PluginMethodSearchResponse[], method: string) => { + const result = parseMethodString(method); + if (!result) { + return; + } + + const { pluginName, methodName } = result; + + return methods.find((method) => method.pluginName === pluginName && method.name === methodName); +}; + +export const asMethodString = (method: { pluginName: string; methodName: string }) => { + return `${method.pluginName}#${method.methodName}`; +}; + +const METHOD_REGEX = /^(?[^@#\s]+)(?:@(?[^#\s]*))?#(?[^@#\s]+)$/; +export const parseMethodString = (method: string) => { + const matches = METHOD_REGEX.exec(method); + if (!matches) { + return; + } + + const pluginName = matches.groups?.name; + const version = matches.groups?.version; + const methodName = matches.groups?.method; + return { pluginName, version, methodName }; +}; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 8e3372011a..e7915d3f1c 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ import { Insertable, Kysely } from 'kysely'; import { DateTime } from 'luxon'; import { createHash, randomBytes } from 'node:crypto'; @@ -75,7 +74,7 @@ import { UserTable } from 'src/schema/tables/user.table'; import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service'; import { MetadataService } from 'src/services/metadata.service'; import { SyncService } from 'src/services/sync.service'; -import { UploadFile } from 'src/types'; +import { ClassConstructor, UploadFile } from 'src/types'; import { mockEnvData } from 'test/repositories/config.repository.mock'; import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock'; import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory'; @@ -85,10 +84,6 @@ import { Mocked } from 'vitest'; // eslint-disable-next-line unicorn/prefer-module export const testAssetsDir = resolve(__dirname, '../../e2e/test-assets'); -interface ClassConstructor extends Function { - new (...args: any[]): T; -} - type MediumTestOptions = { mock: ClassConstructor[]; real: ClassConstructor[]; @@ -425,7 +420,6 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SearchRepository: case SessionRepository: case SharedLinkRepository: @@ -458,6 +452,10 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { return new key(LoggingRepository.create()); } + case PluginRepository: { + return new key(db, LoggingRepository.create()); + } + case StorageRepository: { return new key(LoggingRepository.create()); } @@ -489,7 +487,6 @@ const newMockRepository = (key: ClassConstructor) => { case OcrRepository: case PartnerRepository: case PersonRepository: - case PluginRepository: case SessionRepository: case SyncRepository: case SyncCheckpointRepository: diff --git a/server/test/medium/specs/services/plugin.service.spec.ts b/server/test/medium/specs/services/plugin.service.spec.ts index b70e8e8d54..88254db17e 100644 --- a/server/test/medium/specs/services/plugin.service.spec.ts +++ b/server/test/medium/specs/services/plugin.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext } from 'src/enum'; +import { WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -9,7 +9,8 @@ import { newMediumService } from 'test/medium.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; -let pluginRepo: PluginRepository; + +const wasmBytes = Buffer.from('some-wasm-binary-data'); const setup = (db?: Kysely) => { return newMediumService(PluginService, { @@ -21,7 +22,6 @@ const setup = (db?: Kysely) => { beforeAll(async () => { defaultDatabase = await getKyselyDB(); - pluginRepo = new PluginRepository(defaultDatabase); }); afterEach(async () => { @@ -32,214 +32,195 @@ describe(PluginService.name, () => { describe('getAll', () => { it('should return empty array when no plugins exist', async () => { const { sut } = setup(); - - const plugins = await sut.getAll(); - - expect(plugins).toEqual([]); + await expect(sut.search({})).resolves.toEqual([]); }); - it('should return plugin without filters and actions', async () => { - const { sut } = setup(); + it('should return plugin without methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'test-plugin', title: 'Test Plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/test.wasm' }, + wasmBytes, }, - '/test/base/path', + [], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, + id: result.id, name: 'test-plugin', description: 'A test plugin', author: 'Test Author', version: '1.0.0', - filters: [], - actions: [], + methods: [], }); }); - it('should return plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'full-plugin', title: 'Full Plugin', - description: 'A plugin with filters and actions', + description: 'A plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/full.wasm' }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: { type: 'object', properties: {} }, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: { type: 'object', properties: {} }, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); expect(plugins[0]).toMatchObject({ - id: result.plugin.id, name: 'full-plugin', - filters: [ + methods: [ { - id: result.filters[0].id, - pluginId: result.plugin.id, - methodName: 'test-filter', + name: 'test-filter', title: 'Test Filter', description: 'A test filter', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, - ], - actions: [ { - id: result.actions[0].id, - pluginId: result.plugin.id, - methodName: 'test-action', + name: 'test-action', title: 'Test Action', description: 'A test action', - supportedContexts: [PluginContext.Asset], + types: [WorkflowType.AssetV1], schema: { type: 'object', properties: {} }, }, ], }); }); - it('should return multiple plugins with their respective filters and actions', async () => { - const { sut } = setup(); + it('should return multiple plugins with their respective methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'plugin-1', title: 'Plugin 1', description: 'First plugin', author: 'Author 1', version: '1.0.0', - wasm: { path: '/path/to/plugin1.wasm' }, - filters: [ - { - methodName: 'filter-1', - title: 'Filter 1', - description: 'Filter for plugin 1', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-1', + title: 'Filter 1', + description: 'Filter for plugin 1', + types: [WorkflowType.AssetV1], + }, + ], ); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'plugin-2', title: 'Plugin 2', description: 'Second plugin', author: 'Author 2', version: '2.0.0', - wasm: { path: '/path/to/plugin2.wasm' }, - actions: [ - { - methodName: 'action-2', - title: 'Action 2', - description: 'Action for plugin 2', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'action-2', + title: 'Action 2', + description: 'Action for plugin 2', + types: [WorkflowType.AssetV1], + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(2); expect(plugins[0].name).toBe('plugin-1'); - expect(plugins[0].filters).toHaveLength(1); - expect(plugins[0].actions).toHaveLength(0); + expect(plugins[0].methods).toHaveLength(1); expect(plugins[1].name).toBe('plugin-2'); - expect(plugins[1].filters).toHaveLength(0); - expect(plugins[1].actions).toHaveLength(1); + expect(plugins[1].methods).toHaveLength(1); }); - it('should handle plugin with multiple filters and actions', async () => { - const { sut } = setup(); + it('should handle plugin with multiple methods', async () => { + const { ctx, sut } = setup(); - await pluginRepo.loadPlugin( + await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'multi-plugin', title: 'Multi Plugin', - description: 'Plugin with multiple items', + description: 'Plugin with multiple methods', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/multi.wasm' }, - filters: [ - { - methodName: 'filter-a', - title: 'Filter A', - description: 'First filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'filter-b', - title: 'Filter B', - description: 'Second filter', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'action-x', - title: 'Action X', - description: 'First action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - { - methodName: 'action-y', - title: 'Action Y', - description: 'Second action', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', + [ + { + name: 'filter-a', + title: 'Filter A', + description: 'First filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'filter-b', + title: 'Filter B', + description: 'Second filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-x', + title: 'Action X', + description: 'First action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'action-y', + title: 'Action Y', + description: 'Second action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - const plugins = await sut.getAll(); + const plugins = await sut.search({}); expect(plugins).toHaveLength(1); - expect(plugins[0].filters).toHaveLength(2); - expect(plugins[0].actions).toHaveLength(2); + expect(plugins[0].methods).toHaveLength(4); }); }); @@ -250,55 +231,49 @@ describe(PluginService.name, () => { await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found'); }); - it('should return single plugin with filters and actions', async () => { - const { sut } = setup(); + it('should return single plugin with methods', async () => { + const { ctx, sut } = setup(); - const result = await pluginRepo.loadPlugin( + const result = await ctx.get(PluginRepository).upsert( { + enabled: true, name: 'single-plugin', title: 'Single Plugin', description: 'A single plugin', author: 'Test Author', version: '1.0.0', - wasm: { path: '/path/to/single.wasm' }, - filters: [ - { - methodName: 'single-filter', - title: 'Single Filter', - description: 'A single filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'single-action', - title: 'Single Action', - description: 'A single action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/test/base/path', - ); - - const pluginResult = await sut.get(result.plugin.id); - - expect(pluginResult).toMatchObject({ - id: result.plugin.id, - name: 'single-plugin', - filters: [ + [ { - id: result.filters[0].id, - methodName: 'single-filter', + name: 'single-filter', title: 'Single Filter', + description: 'A single filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'single-action', + title: 'Single Action', + description: 'A single action', + types: [WorkflowType.AssetV1], + schema: undefined, }, ], - actions: [ + ); + + const pluginResult = await sut.get(result.id); + + expect(pluginResult).toMatchObject({ + id: result.id, + name: 'single-plugin', + methods: [ { - id: result.actions[0].id, - methodName: 'single-action', + name: 'single-filter', + title: 'Single Filter', + }, + { + name: 'single-action', title: 'Single Action', }, ], diff --git a/server/test/medium/specs/services/workflow.service.spec.ts b/server/test/medium/specs/services/workflow.service.spec.ts index 229737c531..b16b359b85 100644 --- a/server/test/medium/specs/services/workflow.service.spec.ts +++ b/server/test/medium/specs/services/workflow.service.spec.ts @@ -1,5 +1,5 @@ import { Kysely } from 'kysely'; -import { PluginContext, PluginTriggerType } from 'src/enum'; +import { WorkflowTrigger, WorkflowType } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PluginRepository } from 'src/repositories/plugin.repository'; @@ -20,53 +20,48 @@ const setup = (db?: Kysely) => { }); }; +const wasmBytes = Buffer.from('random-wasm-bytes'); + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(WorkflowService.name, () => { let testPluginId: string; - let testFilterId: string; - let testActionId: string; beforeAll(async () => { - // Create a test plugin with filters and actions once for all tests - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( + const { ctx } = setup(); + // Create a test plugin with methods and actions once for all tests + const pluginRepo = ctx.get(PluginRepository); + const result = await pluginRepo.upsert( { + enabled: true, name: 'test-core-plugin', title: 'Test Core Plugin', description: 'A test core plugin for workflow tests', author: 'Test Author', version: '1.0.0', - wasm: { - path: '/test/path.wasm', - }, - filters: [ - { - methodName: 'test-filter', - title: 'Test Filter', - description: 'A test filter', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], - actions: [ - { - methodName: 'test-action', - title: 'Test Action', - description: 'A test action', - supportedContexts: [PluginContext.Asset], - schema: undefined, - }, - ], + wasmBytes, }, - '/plugins/test-core-plugin', + [ + { + name: 'test-filter', + title: 'Test Filter', + description: 'A test filter', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + { + name: 'test-action', + title: 'Test Action', + description: 'A test action', + types: [WorkflowType.AssetV1], + schema: undefined, + }, + ], ); - testPluginId = result.plugin.id; - testFilterId = result.filters[0].id; - testActionId = result.actions[0].id; + testPluginId = result.id; }); afterAll(async () => { @@ -74,230 +69,27 @@ describe(WorkflowService.name, () => { }); describe('create', () => { - it('should create a workflow without filters or actions', async () => { + it('should create a workflow', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const auth = factory.auth({ user }); const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); expect(workflow).toMatchObject({ id: expect.any(String), - ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'test-workflow', description: 'A test workflow', enabled: true, - filters: [], - actions: [], }); }); - - it('should create a workflow with filters and actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow-with-relations', - description: 'A test workflow with filters and actions', - enabled: true, - filters: [ - { - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - }, - ], - actions: [ - { - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - }, - ], - }); - - expect(workflow).toMatchObject({ - id: expect.any(String), - ownerId: user.id, - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow-with-relations', - enabled: true, - }); - - expect(workflow.filters).toHaveLength(1); - expect(workflow.filters[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginFilterId: testFilterId, - filterConfig: { key: 'value' }, - order: 0, - }); - - expect(workflow.actions).toHaveLength(1); - expect(workflow.actions[0]).toMatchObject({ - id: expect.any(String), - workflowId: workflow.id, - pluginActionId: testActionId, - actionConfig: { action: 'test' }, - order: 0, - }); - }); - - it('should throw error when creating workflow with invalid filter', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid filter', - enabled: true, - filters: [{ pluginFilterId: factory.uuid(), filterConfig: { key: 'value' } }], - actions: [], - }), - ).rejects.toThrow('Invalid filter ID'); - }); - - it('should throw error when creating workflow with invalid action', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-workflow', - description: 'A workflow with invalid action', - enabled: true, - filters: [], - actions: [{ pluginActionId: factory.uuid(), actionConfig: { action: 'test' } }], - }), - ).rejects.toThrow('Invalid action ID'); - }); - - it('should throw error when filter does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with a filter that only supports Album context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'album-only-plugin', - title: 'Album Only Plugin', - description: 'Plugin with album-only filter', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/album-plugin.wasm' }, - filters: [ - { - methodName: 'album-filter', - title: 'Album Filter', - description: 'A filter that only works with albums', - supportedContexts: [PluginContext.Album], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [{ pluginFilterId: result.filters[0].id }], - actions: [], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should throw error when action does not support trigger context', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - // Create a plugin with an action that only supports Person context - const pluginRepo = new PluginRepository(defaultDatabase); - const result = await pluginRepo.loadPlugin( - { - name: 'person-only-plugin', - title: 'Person Only Plugin', - description: 'Plugin with person-only action', - author: 'Test Author', - version: '1.0.0', - wasm: { path: '/test/person-plugin.wasm' }, - actions: [ - { - methodName: 'person-action', - title: 'Person Action', - description: 'An action that only works with persons', - supportedContexts: [PluginContext.Person], - schema: undefined, - }, - ], - }, - '/plugins/test-core-plugin', - ); - - await expect( - sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'invalid-context-workflow', - description: 'A workflow with context mismatch', - enabled: true, - filters: [], - actions: [{ pluginActionId: result.actions[0].id }], - }), - ).rejects.toThrow('does not support asset context'); - }); - - it('should create workflow with multiple filters and actions in correct order', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'multi-step-workflow', - description: 'A workflow with multiple filters and actions', - enabled: true, - filters: [ - { pluginFilterId: testFilterId, filterConfig: { step: 1 } }, - { pluginFilterId: testFilterId, filterConfig: { step: 2 } }, - ], - actions: [ - { pluginActionId: testActionId, actionConfig: { step: 1 } }, - { pluginActionId: testActionId, actionConfig: { step: 2 } }, - { pluginActionId: testActionId, actionConfig: { step: 3 } }, - ], - }); - - expect(workflow.filters).toHaveLength(2); - expect(workflow.filters[0].order).toBe(0); - expect(workflow.filters[0].filterConfig).toEqual({ step: 1 }); - expect(workflow.filters[1].order).toBe(1); - expect(workflow.filters[1].filterConfig).toEqual({ step: 2 }); - - expect(workflow.actions).toHaveLength(3); - expect(workflow.actions[0].order).toBe(0); - expect(workflow.actions[1].order).toBe(1); - expect(workflow.actions[2].order).toBe(2); - }); }); describe('getAll', () => { @@ -307,24 +99,20 @@ describe(WorkflowService.name, () => { const auth = factory.auth({ user }); const workflow1 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-1', description: 'First workflow', enabled: true, - filters: [], - actions: [], }); const workflow2 = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'workflow-2', description: 'Second workflow', enabled: false, - filters: [], - actions: [], }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toHaveLength(2); expect(workflows).toEqual( @@ -340,7 +128,7 @@ describe(WorkflowService.name, () => { const { user } = await ctx.newUser(); const auth = factory.auth({ user }); - const workflows = await sut.getAll(auth); + const workflows = await sut.search(auth, {}); expect(workflows).toEqual([]); }); @@ -353,424 +141,15 @@ describe(WorkflowService.name, () => { const auth2 = factory.auth({ user: user2 }); await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, + trigger: WorkflowTrigger.AssetCreate, name: 'user1-workflow', description: 'User 1 workflow', enabled: true, - filters: [], - actions: [], }); - const user2Workflows = await sut.getAll(auth2); + const user2Workflows = await sut.search(auth2, {}); expect(user2Workflows).toEqual([]); }); }); - - describe('get', () => { - it('should return a specific workflow by id', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'A test workflow', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }], - actions: [{ pluginActionId: testActionId, actionConfig: { action: 'test' } }], - }); - - const workflow = await sut.get(auth, created.id); - - expect(workflow).toMatchObject({ - id: created.id, - name: 'test-workflow', - description: 'A test workflow', - enabled: true, - }); - expect(workflow.filters).toHaveLength(1); - expect(workflow.actions).toHaveLength(1); - }); - - it('should throw error when workflow does not exist', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow(); - }); - - it('should throw error when user does not have access to workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private workflow', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.get(auth2, workflow.id)).rejects.toThrow(); - }); - }); - - describe('update', () => { - it('should update workflow basic fields', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'original-workflow', - description: 'Original description', - enabled: true, - filters: [], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - - expect(updated).toMatchObject({ - id: created.id, - name: 'updated-workflow', - description: 'Updated description', - enabled: false, - }); - }); - - it('should update workflow filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { old: 'config' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [ - { pluginFilterId: testFilterId, filterConfig: { new: 'config' } }, - { pluginFilterId: testFilterId, filterConfig: { second: 'filter' } }, - ], - }); - - expect(updated.filters).toHaveLength(2); - expect(updated.filters[0].filterConfig).toEqual({ new: 'config' }); - expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' }); - }); - - it('should update workflow actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }], - }); - - const updated = await sut.update(auth, created.id, { - actions: [ - { pluginActionId: testActionId, actionConfig: { new: 'config' } }, - { pluginActionId: testActionId, actionConfig: { second: 'action' } }, - ], - }); - - expect(updated.actions).toHaveLength(2); - expect(updated.actions[0].actionConfig).toEqual({ new: 'config' }); - expect(updated.actions[1].actionConfig).toEqual({ second: 'action' }); - }); - - it('should clear filters when updated with empty array', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }], - actions: [], - }); - - const updated = await sut.update(auth, created.id, { - filters: [], - }); - - expect(updated.filters).toHaveLength(0); - }); - - it('should throw error when no fields to update', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update'); - }); - - it('should throw error when updating non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow(); - }); - - it('should throw error when user does not have access to update workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth2, workflow.id, { - name: 'hacked-workflow', - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid filter', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth, created.id, { - filters: [{ pluginFilterId: factory.uuid(), filterConfig: {} }], - }), - ).rejects.toThrow(); - }); - - it('should throw error when updating with invalid action', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await expect( - sut.update(auth, created.id, { actions: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }), - ).rejects.toThrow(); - }); - - it('should update trigger type', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.PersonRecognized, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.update(auth, created.id, { - triggerType: PluginTriggerType.AssetCreate, - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate); - }); - - it('should add filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [ - { pluginFilterId: testFilterId, filterConfig: { first: true } }, - { pluginFilterId: testFilterId, filterConfig: { second: true } }, - ], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(2); - expect(fetched.filters[0].filterConfig).toEqual({ first: true }); - expect(fetched.filters[1].filterConfig).toEqual({ second: true }); - }); - - it('should replace existing filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(1); - expect(fetched.filters[0].filterConfig).toEqual({ replaced: true }); - }); - - it('should remove existing filters', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const created = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }], - actions: [], - }); - - await sut.update(auth, created.id, { - filters: [], - }); - - const fetched = await sut.get(auth, created.id); - expect(fetched.filters).toHaveLength(0); - }); - }); - - describe('delete', () => { - it('should delete a workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [], - actions: [], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should delete workflow with filters and actions', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - const workflow = await sut.create(auth, { - triggerType: PluginTriggerType.AssetCreate, - name: 'test-workflow', - description: 'Test', - enabled: true, - filters: [{ pluginFilterId: testFilterId, filterConfig: {} }], - actions: [{ pluginActionId: testActionId, actionConfig: {} }], - }); - - await sut.delete(auth, workflow.id); - - await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access'); - }); - - it('should throw error when deleting non-existent workflow', async () => { - const { sut, ctx } = setup(); - const { user } = await ctx.newUser(); - const auth = factory.auth({ user }); - - await expect(sut.delete(auth, factory.uuid())).rejects.toThrow(); - }); - - it('should throw error when user does not have access to delete workflow', async () => { - const { sut, ctx } = setup(); - const { user: user1 } = await ctx.newUser(); - const { user: user2 } = await ctx.newUser(); - const auth1 = factory.auth({ user: user1 }); - const auth2 = factory.auth({ user: user2 }); - - const workflow = await sut.create(auth1, { - triggerType: PluginTriggerType.AssetCreate, - name: 'private-workflow', - description: 'Private', - enabled: true, - filters: [], - actions: [], - }); - - await expect(sut.delete(auth2, workflow.id)).rejects.toThrow(); - }); - }); }); diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts new file mode 100644 index 0000000000..99f6c67d5c --- /dev/null +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -0,0 +1,287 @@ +import { WorkflowStepConfig } from '@immich/plugin-sdk'; +import { Kysely } from 'kysely'; +import { readFileSync } from 'node:fs'; +import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto'; +import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { AlbumRepository } from 'src/repositories/album.repository'; +import { AssetRepository } from 'src/repositories/asset.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; +import { CryptoRepository } from 'src/repositories/crypto.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PluginRepository } from 'src/repositories/plugin.repository'; +import { StorageRepository } from 'src/repositories/storage.repository'; +import { WorkflowRepository } from 'src/repositories/workflow.repository'; +import { DB } from 'src/schema'; +import { WorkflowExecutionService } from 'src/services/workflow-execution.service'; +import { resolveMethod } from 'src/utils/workflow'; +import { MediumTestContext } from 'test/medium.factory'; +import { mockEnvData } from 'test/repositories/config.repository.mock'; +import { getKyselyDB } from 'test/utils'; + +let initialized = false; + +class WorkflowTestContext extends MediumTestContext { + constructor(database: Kysely) { + super(WorkflowExecutionService, { + database, + real: [ + AccessRepository, + AlbumRepository, + AssetRepository, + CryptoRepository, + DatabaseRepository, + LoggingRepository, + StorageRepository, + PluginRepository, + WorkflowRepository, + ], + mock: [ConfigRepository], + }); + } + + async init() { + if (initialized) { + return; + } + + const mockData = mockEnvData({}); + mockData.resourcePaths.corePlugin = '../packages/plugin-core'; + mockData.plugins.external.allow = false; + this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData); + this.get(LoggingRepository).setLogLevel(LogLevel.Verbose); + + await this.sut.onPluginSync(); + await this.sut.onPluginLoad(); + + initialized = true; + } +} + +type WorkflowTemplate = { + ownerId: string; + trigger: WorkflowTrigger; + steps: WorkflowTemplateStep[]; +}; + +type WorkflowTemplateStep = { + method: string; + config?: WorkflowStepConfig; +}; + +const createWorkflow = async (template: WorkflowTemplate) => { + const workflowRepo = ctx.get(WorkflowRepository); + const pluginRepo = ctx.get(PluginRepository); + + const methods = await pluginRepo.getForValidation(); + const steps = template.steps.map((step) => { + const pluginMethod = resolveMethod(methods, step.method); + if (!pluginMethod) { + throw new Error(`Plugin method not found: ${step.method}`); + } + + return { ...step, pluginMethod }; + }); + + return workflowRepo.create( + { + enabled: true, + name: 'Test workflow', + description: 'A workflow to test the core plugin', + ownerId: template.ownerId, + trigger: template.trigger, + }, + steps.map((step) => ({ + enabled: true, + pluginMethodId: step.pluginMethod.id, + config: step.config, + })), + ); +}; + +let ctx: WorkflowTestContext; + +beforeAll(async () => { + const db = await getKyselyDB(); + ctx = new WorkflowTestContext(db); + await ctx.init(); +}, 30_000); + +describe('core plugin', () => { + describe('validation', () => { + it('should have a valid manifest.json', () => { + const buffer = readFileSync('../packages/plugin-core/manifest.json'); + const result = PluginManifestDto.schema.safeParse(JSON.parse(buffer.toString())); + if (!result.success) { + const issues = + 'error' in result + ? result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n') + : ''; + const message = `Invalid packages/plugin-core/manifest.json:\n${issues}`; + expect(result.success, message).toBe(true); + } + + expect(result.success).toBe(true); + }); + }); + + describe('assetArchive', () => { + it('should archive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetArchive' }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Archive, + }); + }); + + it('should unarchive an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Archive }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Timeline, + }); + }); + }); + + describe('assetLock', () => { + it('should lock an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetLock' }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Locked, + }); + }); + + it('should unlock an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ + visibility: AssetVisibility.Timeline, + }); + }); + }); + + describe('assetFavorite', () => { + it('should favorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetFavorite' }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true }); + }); + + it('should unfavorite an asset', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false }); + }); + }); + + describe('assetAddToAlbums', () => { + it('should add an asset to an album', async () => { + const { user } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true }); + const { album } = await ctx.newAlbum({ ownerId: user.id }); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id); + }); + + it('should add an asset to multiple albums', async () => { + const { user } = await ctx.newUser(); + const [{ asset }, { album: album1 }, { album: album2 }] = await Promise.all([ + ctx.newAsset({ ownerId: user.id, isFavorite: true }), + ctx.newAlbum({ ownerId: user.id }), + ctx.newAlbum({ ownerId: user.id }), + ]); + + const workflow = await createWorkflow({ + ownerId: user.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined(); + + await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id); + await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id); + }); + + it('should require album access', async () => { + const { user: user1 } = await ctx.newUser(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user1.id, isFavorite: true }); + const { album } = await ctx.newAlbum({ ownerId: user2.id }); + + const workflow = await createWorkflow({ + ownerId: user1.id, + trigger: WorkflowTrigger.AssetCreate, + steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }], + }); + + await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy(); + + await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id); + }); + }); +}); diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index a78b01df35..76a4fe5cef 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -76,7 +76,7 @@ export const envData: EnvData = { root: '/build/www', indexHtml: '/build/www/index.html', }, - corePlugin: '/build/corePlugin', + corePlugin: '/build/plugins/immich-plugin-core', }, setup: { diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 85c72b6c10..334d7d0d53 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -53,7 +53,8 @@ export const newStorageRepositoryMock = (): Mocked['readJsonFile'], + readdirWithTypes: vitest.fn(), createFile: vitest.fn(), createWriteStream: vitest.fn(), createOrOverwriteFile: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index 791a457783..75ada7b551 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -326,7 +326,7 @@ export const getMocks = () => { oauth: automock(OAuthRepository, { args: [loggerMock] }), partner: automock(PartnerRepository, { strict: false }), person: automock(PersonRepository, { strict: false }), - plugin: automock(PluginRepository, { strict: true }), + plugin: automock(PluginRepository, { strict: true, args: [databaseMock, loggerMock] }), process: automock(ProcessRepository), search: automock(SearchRepository, { strict: false }), // eslint-disable-next-line no-sparse-arrays diff --git a/web/src/lib/components/HeaderActionButton.svelte b/web/src/lib/components/HeaderActionButton.svelte index 542c22ba43..e68a4a920d 100644 --- a/web/src/lib/components/HeaderActionButton.svelte +++ b/web/src/lib/components/HeaderActionButton.svelte @@ -1,18 +1,19 @@ {#if action.$if?.() ?? true} +{/snippet} + +
+
+
+ + {#if array} +
+ {#each albumIds as albumId, i (i)} + { + albumIds.splice(i, 1); + albumIds = [...albumIds]; + }} + /> + {/each} + {@render button()} +
+ {:else} + {@const albumId = albumIds[0]} + {#if albumId} + (albumIds = [])} /> + {:else} + {@render button()} + {/if} + {/if} +
diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte new file mode 100644 index 0000000000..e48f46a402 --- /dev/null +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -0,0 +1,99 @@ + + + +{#if Object.keys(schema).length === 0} + + +{:else if schema.type === 'object'} + {#if !root} +
+ + {#if description} + {description} + {/if} +
+ {/if} +
+ {#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)} + + {/each} +
+{:else if schema.uiHint === 'albumId'} + +{:else if schema.enum && schema.array} + + + +{:else if schema.enum} + + getValue(), setValue} /> + +{:else} + Unknown schema + +{/if} diff --git a/web/src/lib/components/album-page/AlbumCard.svelte b/web/src/lib/components/album-page/AlbumCard.svelte index cdb7e9e27c..e0151deda6 100644 --- a/web/src/lib/components/album-page/AlbumCard.svelte +++ b/web/src/lib/components/album-page/AlbumCard.svelte @@ -23,7 +23,7 @@ showDateRange = false, showItemCount = false, preload = false, - onShowContextMenu = undefined, + onShowContextMenu, }: Props = $props(); const showAlbumContextMenu = (e: MouseEvent) => { diff --git a/web/src/lib/components/album-page/AlbumThumbnail.svelte b/web/src/lib/components/album-page/AlbumThumbnail.svelte new file mode 100644 index 0000000000..037bb78ab9 --- /dev/null +++ b/web/src/lib/components/album-page/AlbumThumbnail.svelte @@ -0,0 +1,50 @@ + + +
+ {#await getAlbumInfo({ ...authManager.params, id: albumId })} + + {:then album} +
+ +

+ {album.albumName} +

+ {#if album.description} +

+ {album.description} +

+ {/if} +
+ +
+
+ {/await} +
diff --git a/web/src/lib/managers/plugin-manager.svelte.ts b/web/src/lib/managers/plugin-manager.svelte.ts new file mode 100644 index 0000000000..57ca8fded5 --- /dev/null +++ b/web/src/lib/managers/plugin-manager.svelte.ts @@ -0,0 +1,84 @@ +import { + getWorkflowTriggers, + searchPluginMethods, + WorkflowTrigger, + type PluginMethodResponseDto, + type WorkflowTriggerResponseDto, +} from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { SvelteMap } from 'svelte/reactivity'; +import { get } from 'svelte/store'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +import { eventManager } from '$lib/managers/event-manager.svelte'; + +class PluginManager { + #loading: Promise | undefined; + #methodMap = new SvelteMap(); + #methods = $state([]); + #triggers = $state([]); + + constructor() { + eventManager.on({ + AuthLogout: () => this.clearCache(), + AuthUserLoaded: () => this.initialize(), + }); + + // loaded event might have already happened + if (authManager.authenticated) { + void this.initialize(); + } + } + + get triggers() { + return this.#triggers; + } + + ready() { + return this.initialize(); + } + + getMethod(key: string) { + return this.#methodMap.get(key); + } + + getMethodLabel(key: string) { + const method = this.getMethod(key); + return method?.title ?? get(t)('unknown'); + } + + getTrigger(trigger: WorkflowTrigger) { + const result = this.#triggers.find((t) => t.trigger === trigger); + + if (!result) { + throw new Error(`Unknown trigger type: ${trigger}`); + } + + return result; + } + + private clearCache() { + this.#loading = undefined; + this.#methodMap = new SvelteMap(); + } + + private initialize() { + if (!this.#loading) { + this.#loading = this.load(); + } + + return this.#loading; + } + + private async load() { + const [methods, triggers] = await Promise.all([searchPluginMethods({}), getWorkflowTriggers()]); + + this.#methods = methods; + for (const method of this.#methods) { + this.#methodMap.set(method.key, method); + } + + this.#triggers = triggers; + } +} + +export const pluginManager = new PluginManager(); diff --git a/web/src/lib/modals/AddWorkflowStepModal.svelte b/web/src/lib/modals/AddWorkflowStepModal.svelte deleted file mode 100644 index 980a7fcaae..0000000000 --- a/web/src/lib/modals/AddWorkflowStepModal.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -{#snippet stepButton(title: string, description?: string, onclick?: () => void)} - -{/snippet} - - onClose()}> - -
- - {#if filters.length > 0 && (!type || type === 'filter')} -
- {#each filters as filter (filter.id)} - {@render stepButton(filter.title, filter.description, () => handleSelect('filter', filter))} - {/each} -
- {/if} - - - {#if actions.length > 0 && (!type || type === 'action')} -
- {#each actions as action (action.id)} - {@render stepButton(action.title, action.description, () => handleSelect('action', action))} - {/each} -
- {/if} -
-
-
diff --git a/web/src/lib/modals/AlbumPickerModal.svelte b/web/src/lib/modals/AlbumPickerModal.svelte index 5d0d5692da..43ed575eb2 100644 --- a/web/src/lib/modals/AlbumPickerModal.svelte +++ b/web/src/lib/modals/AlbumPickerModal.svelte @@ -21,9 +21,9 @@ let search = $state(''); let selectedRowIndex: number = $state(-1); - interface Props { + type Props = { onClose: (albums?: AlbumResponseDto[]) => void; - } + }; let { onClose }: Props = $props(); diff --git a/web/src/lib/modals/PluginMethodPicker.svelte b/web/src/lib/modals/PluginMethodPicker.svelte new file mode 100644 index 0000000000..7000fed2cc --- /dev/null +++ b/web/src/lib/modals/PluginMethodPicker.svelte @@ -0,0 +1,41 @@ + + + + {#await searchPluginMethods({ trigger })} +
+ +
+ {:then methods} + + {#each methods as method (method.key)} + onClose(method)}> +
+ {method.title} + {#if method.uiHints.includes('filter')} + {$t('plugin_method_filter_type')} + {/if} + + {#if method.description} + {method.description} + {/if} +
+
+ {/each} +
+ {/await} +
diff --git a/web/src/lib/modals/WorkflowAddStepModal.svelte b/web/src/lib/modals/WorkflowAddStepModal.svelte new file mode 100644 index 0000000000..889e175de0 --- /dev/null +++ b/web/src/lib/modals/WorkflowAddStepModal.svelte @@ -0,0 +1,75 @@ + + +{#if method} + +
+
+ {method.title} + {#if method.description} + {method.description} + {/if} +
+ +
+ + {#if method.schema} +
+
+ {$t('configuration')} + + + +
+ {/if} + + {#if debug} +
+ {$t('preview')} +