Skip to content

Latest commit

 

History

History
1534 lines (1207 loc) · 52.3 KB

File metadata and controls

1534 lines (1207 loc) · 52.3 KB

Oronts

oronts/asset-pilot-bundle

Intelligent Rule-Based Asset Organization for Pimcore 12

License Pimcore version PHP version Symfony version

FeaturesInstallationConfigurationExamplesPath TemplatesExpression LanguageCommandsREST APIPermissionsStudio UIExtendingTesting


Asset Pilot — Studio UI

Asset Pilot automates the organization of Pimcore assets based on configurable rules. When a DataObject is saved, Asset Pilot evaluates its asset fields against a priority-ordered rule set, resolves target paths from Twig templates, and moves files into a structured folder hierarchy. It handles localized fields, supports async processing via Symfony Messenger, logs every operation to an audit trail, and ships with a full Studio UI dashboard.


Features

Rule Engine — Priority-based rule matching with class filtering, field targeting, expression conditions, and asset filters (type, size, extension). Rules are evaluated top-down; all matching rules produce move operations.

Twig Path Templates — Target paths use full Twig syntax with pre-resolved context variables (object, asset, locale, className) and custom filters (safe_key, pluck, first_of, slug, fallback).

Expression Language Conditions — Rules support Symfony ExpressionLanguage conditions with 9 built-in functions: asset_type(), asset_size(), asset_extension(), object_class(), is_image(), is_video(), is_document(), has_property(), path_matches().

Move Strategies — Three conflict resolution strategies: always (move on every save), first_assignment (move only when no prior audit log exists), callback (delegate to a custom service).

Async Processing — Asset moves dispatch to Symfony Messenger queues with transport-level deduplication via DeduplicateStamp. Bulk operations run in configurable batch sizes.

Localized Field Support — Automatically detects localized asset fields and includes the locale in path resolution, producing per-language folder structures.

Audit Log — Every move operation is logged to asset_pilot_audit_log with source/target paths, duration, status, trigger type, and timestamps. Supports CSV export, operation reversal, and per-rule asset history.

Unused Asset Detection — Finds assets not referenced by any DataObject or Document. Filter by type, extension, date range, folder, file size, and confidence level. Bulk delete or move.

Confidence Scoring — Unused assets are classified into five confidence levels: definitely unused (>90 days), probably unused (30-90 days), recently uploaded (<30 days), historically used (has audit history), and protected (locked). Color-coded badges in the UI help prioritize cleanup.

Asset Protection — Lock individual assets from organization via the asset_pilot_locked property. Configure folder-level exclusions to protect entire directory trees.

Search by Related Object — Find all assets referenced by a specific DataObject via the Pimcore dependencies table. Browse assets moved by a specific rule with optional date and class filters.

Permissions Model — Three granular permission levels: asset_pilot_view (read-only), asset_pilot_operate (lock/unlock, bulk actions, organize), asset_pilot_admin (revert operations). Registered natively in Pimcore via the installer.

Idempotency Hardening — Multi-layer duplicate protection: Redis-backed loop guard (prevents re-entry and async ping-pong), stale job detection (compares dispatch time vs. object modification), already-at-target skip, and Symfony Messenger DeduplicateStamp (prevents transport-level duplicates).

Loop Prevention — Cache-backed guard prevents infinite recursion when asset moves trigger DataObject saves. Includes a 5-minute cooldown for recently-moved assets and 10-second dispatch deduplication.

Config Validation — CLI command validates all rules: class existence, field names, condition syntax, Twig template compilation, callback service registration, filter values, and duplicate priority warnings.

Rule Debugger — Step-by-step rule evaluation trace per object/asset pair. Shows why each rule matched or was skipped (disabled, class mismatch, field mismatch, condition failed, filter rejected).

Studio UI Integration — Full React dashboard integrated into Pimcore Studio via Module Federation. Six tabs: Dashboard, Rules, Operations, Audit Log, Unused Assets, Asset Management.


Architecture

flowchart TD
    subgraph Trigger
        A([DataObject saved / Asset uploaded])
    end

    subgraph EventListener
        B{Bundle enabled?}
        C{Object class allowed?}
        D{Async enabled?}
        E["Dispatch OrganizeAssetsMessage
        to Messenger queue
        with DeduplicateStamp"]
        F[Call AssetOrganizer directly]
    end

    subgraph AssetOrganizer
        G["Check LoopGuard (prevent re-entry)"]
        H{Already processing?}
        I["Check asset_pilot_locked property"]
        J{Asset locked?}
        K[Skip — log as skipped]
        L[Mark object as processing]
    end

    subgraph AssetFieldExtractor
        M[Scan all object fields]
        N["Return AssetFieldInfo[ ]"]
    end

    subgraph RuleEngine
        O[Load rules sorted by priority desc]
        P{Class matches?}
        Q{Field matches?}
        R{Condition passes?}
        S{Filters accept?}
        T["Create RuleMatch"]
        U["Return RuleMatch[ ]"]
    end

    subgraph PathResolver
        V["Render Twig template
        with object/asset context"]
        W[Sanitize path segments]
        X[Return resolved path]
    end

    subgraph StrategyResolver
        Y{Strategy type?}
        Z[Proceed with move]
        AA{Audit log exists?}
        AB[Skip — already assigned]
        AC[Delegate to custom service]
    end

    subgraph MoveExecution
        AD["Dispatch PRE_MOVE event"]
        AE{Event cancelled?}
        AF["Generate safe filename (NamingStrategy)"]
        AG[Move asset to target path]
        AH["Dispatch POST_MOVE event"]
    end

    subgraph AuditLogger
        AI["Log operation to
        asset_pilot_audit_log
        (asset, object, paths, status, duration)"]
    end

    A --> B
    B -- yes --> C
    B -- no --> STOP1([Stop])
    C -- yes --> D
    C -- no --> STOP2([Stop])
    D -- yes --> E
    D -- no --> F
    E --> G
    F --> G
    G --> H
    H -- yes --> STOP3([Stop])
    H -- no --> I
    I --> J
    J -- yes --> K --> STOP4([Stop])
    J -- no --> L

    L --> M
    M --> N

    N --> O
    O --> P
    P -- yes --> Q
    P -- no --> O
    Q -- yes --> R
    Q -- no --> O
    R -- yes --> S
    R -- no --> O
    S -- yes --> T --> O
    S -- no --> O
    O -. all assets and rules evaluated .-> U

    U --> V --> W --> X

    X --> Y
    Y -- always --> Z
    Y -- first_assignment --> AA
    Y -- callback --> AC
    AA -- yes --> AB --> STOP5([Stop])
    AA -- no --> Z
    AC --> Z

    Z --> AD --> AE
    AE -- yes --> STOP6([Stop])
    AE -- no --> AF --> AG --> AH

    AH --> AI

    style Trigger fill:#E8F4FD,stroke:#333
    style EventListener fill:#E8F4FD,stroke:#333
    style AssetOrganizer fill:#E8F4FD,stroke:#333
    style AssetFieldExtractor fill:#E8F4FD,stroke:#333
    style RuleEngine fill:#E8F4FD,stroke:#333
    style PathResolver fill:#E8F4FD,stroke:#333
    style StrategyResolver fill:#E8F4FD,stroke:#333
    style MoveExecution fill:#E8F4FD,stroke:#333
    style AuditLogger fill:#E8F4FD,stroke:#333
Loading

Note: Extracts assets from: image, video, document fields · gallery, hotspotimage fields · relation fields (many-to-one, many-to-many) · localized fields (all locales)

src/
├── Audit/                  AuditLogger — database-backed operation logging
├── Command/                CLI: organize, debug-rule, validate-config, status, audit, cleanup-unused
├── Condition/              ConditionEvaluatorInterface + ExpressionLanguage impl
├── Controller/Api/         REST API controllers (6 controllers, 25+ endpoints)
├── DependencyInjection/    Bundle configuration tree + service loading
├── Dto/                    API request/response DTOs
├── Engine/                 RuleEngine — core matching + explain logic
├── Enum/                   MoveStrategy, OperationStatus, TriggerType, AssetPilotPermission
├── Event/                  AssetMoveEvent + event constants
├── EventListener/          DataObject save + Asset upload listeners (with DeduplicateStamp)
├── Filter/                 AssetFilterInterface + type/size/extension/composite
├── Message/                Messenger messages: OrganizeAssets, BulkOrganize
├── MessageHandler/         Async handlers with stale job detection
├── Model/                  Rule, RuleMatch, RuleEvaluation, MoveOperation, OperationResult
├── Naming/                 NamingStrategyInterface + SafeNamingStrategy
├── PathResolver/           PathResolverInterface + Twig TemplatePathResolver
├── Service/                AssetOrganizer, AssetFieldExtractor, AssetSearchService,
│                           AssetPropertyService, UnusedAssetFinder, ConfidenceScorer,
│                           ConfigValidator, LoopGuard
├── Strategy/               ConflictStrategyInterface + Always/FirstAssignment/Callback
├── Webpack/                Module Federation entry point provider
├── Installer.php           Database schema + permission registration
└── OrontsAssetPilotBundle.php

Installation

1. Require the package

composer require oronts/asset-pilot-bundle

2. Enable the bundle

Add to config/bundles.php:

return [
    // ...
    Oronts\AssetPilotBundle\OrontsAssetPilotBundle::class => ['all' => true],
];

3. Install the database table and permissions

bin/console pimcore:bundle:install OrontsAssetPilotBundle

This creates:

  • The asset_pilot_audit_log table with indexes on asset_id, object_id, rule_name, status, and created_at
  • Three Pimcore permissions: asset_pilot_view, asset_pilot_operate, asset_pilot_admin

4. Configure Messenger transport

Add the transport and routing to config/packages/messenger.yaml:

framework:
    messenger:
        transports:
            asset_pilot:
                dsn: '%messenger.dsn%/asset_pilot'
                retry_strategy:
                    max_retries: 3
                    delay: 2000
                    multiplier: 3
                    max_delay: 30000

        routing:
            'Oronts\AssetPilotBundle\Message\OrganizeAssetsMessage': asset_pilot
            'Oronts\AssetPilotBundle\Message\BulkOrganizeMessage': asset_pilot

5. Configure Symfony Lock (required for message deduplication)

# config/packages/lock.yaml
framework:
    lock: 'redis://%env(REDIS_HOST)%'

6. Build the Studio UI assets

bin/console assets:install
bin/console cache:clear

Configuration

Create config/packages/oronts_asset_pilot.yaml:

oronts_asset_pilot:
    enabled: true

    rules:
        product_images:
            class: Product
            fields: [images, galleryImages]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: always
            priority: 100
            filters:
                types: [image]
                max_size: 52428800

        product_documents:
            class: Product
            fields: [datasheet, manual, brochure]
            target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'
            strategy: always
            priority: 70

    strategies:
        default: always

    naming:
        collision_pattern: counter
        slugify: true

    async:
        enabled: true
        batch_size: 50

    audit:
        enabled: true
        retention_days: 90

    protection:
        exclude_folders:
            - /Protected/
            - /Manual/
        lock_property: asset_pilot_locked

    logging:
        channel: asset_pilot

Configuration Reference

Key Type Default Description
enabled bool true Global on/off switch
rules map [] Named rule definitions (see below)
strategies.default enum always Default strategy: always, first_assignment, callback
naming.collision_pattern enum counter Filename collision resolution: counter, timestamp, uuid
naming.slugify bool true Slugify filenames during organization
async.enabled bool true Dispatch moves via Symfony Messenger
async.batch_size int 50 Operations per batch message
audit.enabled bool true Enable audit logging
audit.retention_days int 90 Days to retain audit entries
protection.exclude_folders string[] [] Folders excluded from organization (e.g., ["/Protected/"])
protection.lock_property string asset_pilot_locked Custom property name used to lock assets
logging.channel string asset_pilot Monolog channel name

Rule Options

Key Type Required Default Description
class string yes DataObject class name or * for wildcard
fields string[] no [] Field names to match. Empty = all asset fields
condition string no null ExpressionLanguage condition
target_path string yes Twig path template
strategy enum no always always, first_assignment, callback
callback string no null Service ID (required when strategy is callback)
priority int no 10 Higher values match first
enabled bool no true Enable/disable individual rules
filters.types string[] no [] Asset types: image, video, document, etc.
filters.min_size int no null Minimum file size in bytes
filters.max_size int no null Maximum file size in bytes
filters.extensions string[] no [] Allowed file extensions

Configuration Examples

E-commerce: Product Assets by Item Number

Organize product images and documents into folders named by item number. Localized documents (datasheets, manuals) get a locale subfolder.

oronts_asset_pilot:
    rules:
        product_images:
            class: Product
            fields: [images, galleryImages, thumbnails]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: always
            priority: 100
            filters:
                types: [image]
                extensions: [jpg, png, webp]
                max_size: 52428800

        product_documents:
            class: Product
            fields: [datasheet, manual, brochure]
            condition: 'object.getItemNumber() != null'
            target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'
            strategy: always
            priority: 80

        product_videos:
            class: Product
            fields: [productVideo, tutorialVideo]
            target_path: '/Products/{{ object.getItemNumber() }}/Media'
            strategy: always
            priority: 60
            filters:
                types: [video]

Resulting folder structure:

/Products/
├── ART-10042/
│   ├── Images/
│   │   ├── product-front.jpg
│   │   └── product-back.png
│   ├── Documents/
│   │   ├── en/
│   │   │   └── datasheet-en.pdf
│   │   └── de/
│   │       └── datasheet-de.pdf
│   └── Media/
│       └── tutorial.mp4
└── ART-10043/
    └── ...

Category-Based Hierarchy

Organize assets into folders derived from the object's category relation. Uses first_of to grab the category key, with a fallback for uncategorized products.

oronts_asset_pilot:
    rules:
        category_images:
            class: Product
            fields: [images]
            target_path: >-
                /Catalog/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber()|fallback("unknown") }}/Images
            strategy: always
            priority: 100
            filters:
                types: [image]

        category_documents:
            class: Product
            fields: [datasheet, certificate]
            target_path: >-
                /Catalog/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber()|fallback("unknown") }}/Docs{{ locale ? "/" ~ locale : "" }}
            strategy: always
            priority: 80

Resulting folder structure:

/Catalog/
├── Electronics/
│   ├── ART-10042/
│   │   ├── Images/
│   │   └── Docs/
│   │       ├── en/
│   │       └── de/
│   └── ART-10043/
│       └── ...
├── Furniture/
│   └── ...
└── Uncategorized/
    └── ...

Multi-Class Setup

Apply rules to different DataObject classes. Use the * wildcard to create a catch-all rule for any class that doesn't have a specific rule.

oronts_asset_pilot:
    rules:
        product_assets:
            class: Product
            target_path: '/Products/{{ object.getItemNumber()|fallback(object.getKey()) }}/Assets'
            strategy: always
            priority: 100
            filters:
                types: [image, document]

        category_banners:
            class: Category
            fields: [bannerImage, icon]
            target_path: '/Categories/{{ object.getKey() }}'
            strategy: first_assignment
            priority: 90
            filters:
                types: [image]

        brand_logos:
            class: Brand
            fields: [logo, headerImage]
            target_path: '/Brands/{{ object.getName()|slug }}'
            strategy: first_assignment
            priority: 80

        catch_all:
            class: '*'
            target_path: '/Assets/{{ className }}/{{ object.getKey()|safe_key }}'
            strategy: always
            priority: 1

First Assignment Strategy

Use first_assignment when assets should only be organized on first save. Once moved, they stay put even if the object is updated. Useful for QR codes, generated certificates, or any asset that should not be relocated after initial placement.

oronts_asset_pilot:
    rules:
        generated_qrcode:
            class: Product
            fields: [qrCode]
            target_path: '/Products/{{ object.getItemNumber() }}/QR{{ locale ? "/" ~ locale : "" }}'
            strategy: first_assignment
            priority: 50
            filters:
                types: [image]

        certificates:
            class: Product
            fields: [certificate, testReport]
            target_path: '/Products/{{ object.getItemNumber() }}/Certificates'
            strategy: first_assignment
            priority: 40
            filters:
                types: [document]
                extensions: [pdf]

Callback Strategy with Custom Logic

Delegate the move decision to a custom service. The service receives the asset, object, and rule, and returns true to proceed or false to skip.

oronts_asset_pilot:
    rules:
        conditional_move:
            class: Product
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: callback
            callback: App\AssetPilot\Strategy\ApprovalStrategy
            priority: 100
// src/AssetPilot/Strategy/ApprovalStrategy.php
class ApprovalStrategy implements ConflictStrategyInterface
{
    public function resolve(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        // Only move assets for published objects
        if ($object instanceof Concrete && !$object->isPublished()) {
            return false;
        }

        // Only move during business hours
        $hour = (int) date('H');
        return $hour >= 8 && $hour < 18;
    }

    public function supports(MoveStrategy $strategy): bool
    {
        return $strategy === MoveStrategy::Callback;
    }
}

Sync Mode (No Messenger Queue)

Disable async processing to move assets immediately during the save request. Suitable for development environments or small catalogs where move operations are fast.

oronts_asset_pilot:
    async:
        enabled: false

    rules:
        product_images:
            class: Product
            target_path: '/Products/{{ object.getKey() }}/Images'
            strategy: always
            priority: 10

Asset Protection

Lock specific folders from automation and configure the lock property name.

oronts_asset_pilot:
    protection:
        exclude_folders:
            - /Protected/
            - /Manual/
            - /Brand-Assets/
        lock_property: asset_pilot_locked

Assets in excluded folders are never processed. Individual assets can be locked/unlocked via the API (POST /assets/{id}/lock, DELETE /assets/{id}/lock) or the Studio UI.

Date-Based Organization

Organize uploads by year and month. Useful for editorial content, blog posts, or any time-based content.

oronts_asset_pilot:
    rules:
        blog_images:
            class: BlogPost
            fields: [heroImage, contentImages]
            target_path: '/Blog/{{ date.format("Y") }}/{{ date.format("m") }}/{{ object.getKey()|slug }}'
            strategy: always
            priority: 100
            filters:
                types: [image]

        news_attachments:
            class: NewsArticle
            target_path: '/News/{{ date.format("Y/m/d") }}/{{ object.getKey()|slug }}'
            strategy: always
            priority: 90

Restrictive Filters

Combine type, extension, and size filters to tightly control which assets a rule processes.

oronts_asset_pilot:
    rules:
        high_res_photos:
            class: Product
            fields: [images]
            target_path: '/Products/{{ object.getItemNumber() }}/HighRes'
            strategy: always
            priority: 100
            filters:
                types: [image]
                extensions: [jpg, tiff, png]
                min_size: 1048576      # at least 1 MB
                max_size: 104857600    # max 100 MB

        small_thumbnails:
            class: Product
            fields: [thumbnail]
            target_path: '/Products/{{ object.getItemNumber() }}/Thumbs'
            strategy: always
            priority: 90
            filters:
                types: [image]
                extensions: [jpg, png, webp]
                max_size: 1048576      # under 1 MB

Path Templates

Target paths use Twig syntax. The resolver provides these context variables:

Variable Type Description
object AbstractObject The DataObject being saved
asset Asset The asset being organized
locale ?string Locale code for localized fields (en, de, etc.) or null
date DateTimeImmutable Current date/time
className string DataObject class name

You can call any method on the object and asset variables directly in the template. The resolver handles null values gracefully and falls back to 'unknown' for empty segments.

Custom Filters

Filter Usage Description
safe_key {{ value|safe_key }} Replace non-alphanumeric chars with -
pluck {{ items|pluck('key') }} Extract a property from each array item
first_of {{ items|first_of('key') }} Get property from first item, fallback to 'unknown'
slug {{ value|slug }} URL-safe lowercase slug
fallback {{ value|fallback('default') }} Return fallback when value is empty/null
trim_path {{ value|trim_path }} Strip leading/trailing slashes

Custom Functions

Function Usage Description
coalesce {{ coalesce(a, b, c) }} First non-null, non-empty value
prop {{ prop(obj, 'method', arg1) }} Safely call a method on an object
rel {{ rel(object, 'categories', 0) }} Safely access a relation by index
has_relation {% if has_relation(object, 'categories') %} Check if relation has items

Path Template Examples

# Simple flat structure
target_path: '/Products/{{ object.getItemNumber() }}/Images'

# Category hierarchy
target_path: '/Products/{{ object.getCategories()|first_of("key", "Uncategorized") }}/{{ object.getItemNumber() }}/Images'

# Locale-aware paths for localized fields
target_path: '/Products/{{ object.getItemNumber() }}/Documents{{ locale ? "/" ~ locale : "" }}'

# Date-based organization
target_path: '/Uploads/{{ date.format("Y/m") }}/{{ className }}'

# Conditional logic with Twig
target_path: '{% if has_relation(object, "categories") %}/Products/{{ object.getCategories()|first_of("key") }}{% else %}/Products/Uncategorized{% endif %}/Assets'

# Joined relation path
target_path: '/Products/{{ object.getCategories()|pluck("key")|join("/") }}/Media'

# Coalesce multiple possible identifiers
target_path: '/Products/{{ coalesce(prop(object, "getItemNumber"), prop(object, "getSku"), object.getKey()) }}/Assets'

# Fallback with slug
target_path: '/{{ className }}/{{ object.getName()|slug|fallback("unnamed") }}'

Expression Language

Rule conditions use Symfony ExpressionLanguage. Three variables are available: object, asset, and rule.

Built-in Functions

Function Returns Example
asset_type(asset) string asset_type(asset) == "image"
asset_size(asset) int asset_size(asset) > 1048576
asset_extension(asset) string asset_extension(asset) == "pdf"
object_class(object) string object_class(object) == "Product"
is_image(asset) bool is_image(asset)
is_video(asset) bool is_video(asset)
is_document(asset) bool is_document(asset)
has_property(element, name) bool has_property(asset, "source")
path_matches(asset, pattern) bool path_matches(asset, "#/temp/#")

Condition Examples

# Only objects that have an item number set
condition: 'object.getItemNumber() != null'

# Only images under 10 MB
condition: 'is_image(asset) and asset_size(asset) < 10485760'

# Only PDF files
condition: 'asset_extension(asset) == "pdf"'

# Compound: images over 1 MB from Product class
condition: 'is_image(asset) and asset_size(asset) > 1048576 and object_class(object) == "Product"'

# Skip assets already in the target structure
condition: 'not path_matches(asset, "#^/Products/#")'

# Only process assets with a specific property
condition: 'has_property(asset, "approved") and has_property(asset, "reviewed")'

# Match only videos or documents (no images)
condition: 'is_video(asset) or is_document(asset)'

Commands

Organize Assets

# Organize a single object
bin/console asset-pilot:organize --object-id=42

# Dry run (preview without moving)
bin/console asset-pilot:organize --object-id=42 --dry-run

# Bulk organize all objects of a class
bin/console asset-pilot:organize --class=Product

# Async bulk (dispatch to messenger queue)
bin/console asset-pilot:organize --class=Product --async --batch-size=100

# Verbose dry run — shows full rule evaluation per asset
bin/console asset-pilot:organize --object-id=42 --dry-run -v

Validate Configuration

# Validate all configured rules
bin/console asset-pilot:validate-config

Checks performed:

  • Class exists in Pimcore (ClassDefinition::getByName())
  • Fields exist in the class definition (including localized fields)
  • ExpressionLanguage condition syntax is valid
  • Twig path template syntax is valid
  • Callback service exists in the DI container (when strategy=callback)
  • Filter type/extension values are valid
  • Warns on duplicate priorities for the same class

Debug Rules

# Debug rule evaluation for a specific object (shows all rules and why they matched/skipped)
bin/console asset-pilot:debug-rule --object-id=42

# Debug a specific asset against all rules
bin/console asset-pilot:debug-rule --object-id=42 --asset-id=100

# Filter to a single rule
bin/console asset-pilot:debug-rule --object-id=42 --rule=product_images

# Filter to a specific field
bin/console asset-pilot:debug-rule --object-id=42 --field=productImages

Output shows a table per rule with: rule name, result (MATCHED/SKIPPED), rejection reason (disabled, class_mismatch, field_mismatch, condition_failed, filter_rejected), condition expression and result, resolved target path, and priority.

View Status

# Show configured rules and statistics
bin/console asset-pilot:status

# JSON output for scripting
bin/console asset-pilot:status --format=json

Audit Log

# Recent operations
bin/console asset-pilot:audit --limit=50

# Filter by class and status
bin/console asset-pilot:audit --class=Product --status=completed --since="1 week ago"

# Filter by rule
bin/console asset-pilot:audit --rule=product_images

# Clean up old entries (respects retention_days config)
bin/console asset-pilot:audit --cleanup

Clean Up Unused Assets

# Preview unused assets
bin/console asset-pilot:cleanup-unused --dry-run

# Delete unused images older than 90 days
bin/console asset-pilot:cleanup-unused --type=image --before="-90 days" --action=delete

# Move unused assets to archive folder
bin/console asset-pilot:cleanup-unused --action=move --move-to="/Archive/Unused"

# Filter by size and extension
bin/console asset-pilot:cleanup-unused --min-size=1048576 --extension=jpg,png --dry-run

Scheduled Jobs (Cron)

All commands can be automated via cron. Example crontab entries:

# --- Nightly ---
# Organize all Product assets (async, processed by messenger workers)
0 2 * * * cd /var/www/html && bin/console asset-pilot:organize --class=Product --async --batch-size=100 >> /var/log/asset-pilot.log 2>&1

# --- Weekly ---
# Generate unused asset report (dry-run, no changes)
0 3 * * 0 cd /var/www/html && bin/console asset-pilot:cleanup-unused --dry-run --type=image 2>&1 | mail -s "Unused Assets Report" admin@example.com

# Archive unused images older than 90 days
0 4 * * 0 cd /var/www/html && bin/console asset-pilot:cleanup-unused --type=image --before="-90 days" --action=move --move-to="/Archive/Unused" >> /var/log/asset-pilot.log 2>&1

# --- Monthly ---
# Clean up old audit log entries
0 5 1 * * cd /var/www/html && bin/console asset-pilot:audit --cleanup >> /var/log/asset-pilot.log 2>&1

# --- CI/CD ---
# Validate config after deployments
# bin/console asset-pilot:validate-config
# bin/console asset-pilot:debug-rule --object-id=42

For Kubernetes/Docker environments, use CronJob resources or container-level cron scheduling.


Permissions

Asset Pilot registers three permissions in Pimcore's native permission system during installation. Assign them to user roles via Settings > Users / Roles > Permissions.

Permission Key Description
View asset_pilot_view View dashboard, rules, audit log, unused assets, search assets. All read-only endpoints.
Operate asset_pilot_operate Organize assets, lock/unlock, bulk tag, bulk set properties, delete/move unused assets.
Admin asset_pilot_admin Revert completed operations from the audit log.

All API endpoints enforce permissions via #[IsGranted(AssetPilotPermission::*)] attributes. The Studio UI hides action buttons when the user lacks the required permission.

The GET /permissions endpoint returns the current user's permission set:

{
    "view": true,
    "operate": true,
    "admin": false
}

REST API

All endpoints are prefixed with /pimcore-studio/api/asset-pilot. Requires Pimcore Studio authentication. Permissions are enforced on every endpoint (see Permissions).

Dashboard

Method Endpoint Permission Description
GET /dashboard View Dashboard statistics
GET /dashboard/class-stats View Per-class breakdown
GET /permissions Current user's permission set

Operations

Method Endpoint Permission Description
POST /organize Operate Organize a single object
POST /organize/preview View Dry-run preview
POST /organize/explain View Detailed rule evaluation per asset
POST /organize/bulk Operate Bulk organize by class or IDs
POST /operations/bulk-preview View Paginated bulk preview
GET /operations/status View Operation statistics

Organize request body

{
    "objectId": 42,
    "dryRun": false,
    "async": true
}

Bulk organize request body

{
    "className": "Product",
    "async": true,
    "batchSize": 50
}

Or with explicit IDs:

{
    "objectIds": [1, 2, 3, 4, 5],
    "async": true,
    "batchSize": 50
}

Explain response

{
    "objectId": 42,
    "operations": [{
        "assetId": 456,
        "sourcePath": "/uploads/photo.jpg",
        "targetPath": "/Products/ART-123/Images/photo.jpg",
        "ruleName": "product_images",
        "status": "completed"
    }],
    "evaluations": [{
        "assetId": 456,
        "assetPath": "/uploads/photo.jpg",
        "fieldName": "images",
        "locale": null,
        "ruleName": "product_images",
        "matched": true,
        "rejectionReason": null,
        "conditionExpression": "object.getItemNumber() != null",
        "conditionResult": true,
        "conditionError": null,
        "filterDetails": null,
        "resolvedPath": "/Products/ART-123/Images",
        "priority": 100,
        "enabled": true
    }, {
        "assetId": 456,
        "assetPath": "/uploads/photo.jpg",
        "fieldName": "images",
        "locale": null,
        "ruleName": "product_documents",
        "matched": false,
        "rejectionReason": "filter_rejected",
        "conditionExpression": null,
        "conditionResult": true,
        "conditionError": null,
        "filterDetails": "type mismatch: image not in [document]",
        "resolvedPath": null,
        "priority": 70,
        "enabled": true
    }]
}

Rules

Method Endpoint Permission Description
GET /rules View List all configured rules
GET /rules/{name} View Rule details with statistics
GET /rules/{name}/preview?objectId=42 View Preview rule against an object

Asset Management

Method Endpoint Permission Description
GET /assets/search View Search assets (params: q, type, folder, objectId, page, limit, sort, order)
GET /assets/by-object/{objectId} View Assets linked to a DataObject via dependencies (params: page, limit, type)
GET /assets/tags View List all available Pimcore tags
GET /assets/{id}/tags View Get tags for a specific asset
POST /assets/{id}/lock Operate Lock asset from organization
DELETE /assets/{id}/lock Operate Unlock asset
POST /assets/bulk-tag Operate Bulk assign tags to assets
POST /assets/bulk-property Operate Bulk set custom properties

Search parameters

Parameter Type Description
q string Search by filename or path (LIKE match)
type string Filter by asset type: image, document, video, audio, text, archive
folder string Filter by folder path (e.g., /Products/)
objectId int Filter to assets referenced by a specific DataObject (via dependencies table)
page int Page number (default: 1)
limit int Items per page (default: 50, max: 200)
sort string Sort field: id, filename, type, file_size, modified_at
order string Sort order: asc or desc

Bulk tag request body

{
    "assetIds": [1, 2, 3],
    "tagIds": [10, 20],
    "replace": false
}

Set replace: true to remove all existing tags before assigning new ones.

Bulk property request body

{
    "assetIds": [1, 2, 3],
    "name": "department",
    "type": "text",
    "data": "Marketing"
}

Supported types: text, bool, select.

Unused Assets

Method Endpoint Permission Description
GET /unused-assets View List unused assets with confidence scoring
GET /unused-assets/stats View Unused asset statistics by type
POST /unused-assets/bulk-delete Operate Delete unused assets
POST /unused-assets/bulk-move Operate Move unused assets to folder

Unused assets parameters

Parameter Type Description
type string Filter by asset type
extension string Comma-separated extensions (e.g., pdf,png,jpg)
before string Modified before date (ISO format)
after string Modified after date (ISO format)
folder string Filter by folder path
minSize int Minimum file size in bytes
maxSize int Maximum file size in bytes
confidence string Filter by confidence: definitely_unused, probably_unused, recently_uploaded, historically_used, protected
page int Page number (default: 1)
limit int Items per page (default: 50, max: 200)
sort string Sort field
order string Sort order: asc or desc

Confidence levels

Level Criteria Recommended Action
definitely_unused No references, last modified >90 days ago Safe to delete
probably_unused No references, last modified 30-90 days ago Review before deleting
recently_uploaded No references, last modified <30 days ago Wait — may be in use soon
historically_used No references, but has audit history of past moves Investigate before deleting
protected Has asset_pilot_locked property Excluded from cleanup

Audit Log

Method Endpoint Permission Description
GET /audit View Paginated audit entries (params: page, limit, class, status, ruleName, sort, order)
GET /audit/stats View Audit statistics
GET /audit/export View Export as CSV (params: class, status, ruleName)
GET /audit/by-rule/{ruleName}/assets View Distinct assets moved by a rule (params: page, limit, since, class)
POST /audit/{id}/revert Admin Revert a completed operation

Assets by rule parameters

Parameter Type Description
since string Only include moves after this date (ISO format, e.g., 2026-02-01)
class string Filter by object class name
page int Page number (default: 1)
limit int Items per page (default: 50)

Studio UI

Asset Pilot integrates into Pimcore Studio as a Module Federation remote. The UI provides six tabs:

Tab Description
Dashboard Statistics overview (organized, pending, failed, skipped counts), class breakdown table, recent operations list
Rules View all configured rules with priority, strategy, target path. Detail modal with configuration and statistics. Preview modal to test a rule against a specific object ID.
Operations Single object organize (with dry-run, async, and explain modes). Bulk organize by class with paginated preview and "Organize All" button. System status with refresh.
Audit Log Full operation history with sorting. Filter by class, status, and rule name. CSV export. Revert individual operations.
Unused Assets Confidence-scored unused asset list with color-coded badges. Filter by type, extensions, date range, folder, size, and confidence level. Bulk delete or move selected assets. Filter presets.
Asset Management Search assets by filename/path, filter by type, folder, or Object ID. Lock/unlock assets. Bulk assign tags. Bulk set custom properties. Sortable columns with pagination.

Confidence Badges

Unused assets display color-coded confidence badges:

Badge Color Meaning
Definitely Unused Green Safe to clean up (>90 days, no references)
Probably Unused Yellow Review recommended (30-90 days)
Recently Uploaded Red Wait before action (<30 days)
Historically Used Orange Was previously organized — investigate
Protected Gray Locked asset, excluded from cleanup

Localization

The Studio UI ships with English and German translations. All UI strings use the asset-pilot.* i18n namespace.


Idempotency & Loop Prevention

Asset Pilot uses a multi-layer approach to prevent duplicate processing:

Layer Mechanism TTL Purpose
Loop Guard Redis cache (cache.app) 60s Prevents re-entry when asset moves trigger DataObject saves
Recently Moved Redis cache 300s (5 min) Prevents async ping-pong for shared assets between objects
Dispatch Dedup Redis cache 10s Prevents burst duplicate dispatches from rapid saves
Stale Job Detection dispatchedAt timestamp Skips processing if object was modified after message dispatch
Already-at-Target Path comparison Skips move when source path equals target path
DeduplicateStamp Symfony Lock (Redis) 30s/60s Transport-level deduplication prevents the same message from being processed twice

The DeduplicateStamp TTLs:

  • 30 seconds for single-object organize messages (asset_pilot_organize_{objectId})
  • 60 seconds for bulk organize messages (asset_pilot_bulk_{batchHash})

Extending

Asset Pilot is built around interfaces. Swap out any component by implementing the interface and registering it as a service.

Custom Filter

Restrict which assets a rule applies to. All registered filters run inside CompositeFilter using AND logic — the first rejection short-circuits evaluation.

namespace App\AssetPilot\Filter;

use Oronts\AssetPilotBundle\Filter\AssetFilterInterface;
use Oronts\AssetPilotBundle\Model\Rule;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class PublishedOnlyFilter implements AssetFilterInterface
{
    public function accept(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        if ($object instanceof \Pimcore\Model\DataObject\Concrete) {
            return $object->isPublished();
        }
        return true;
    }
}
services:
    App\AssetPilot\Filter\PublishedOnlyFilter:
        tags:
            - { name: 'oronts_asset_pilot.filter' }

Custom Strategy

Control when assets should be moved. Implement ConflictStrategyInterface and register with an alias.

namespace App\AssetPilot\Strategy;

use Oronts\AssetPilotBundle\Enum\MoveStrategy;
use Oronts\AssetPilotBundle\Model\Rule;
use Oronts\AssetPilotBundle\Strategy\ConflictStrategyInterface;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class BusinessHoursStrategy implements ConflictStrategyInterface
{
    public function resolve(Asset $asset, AbstractObject $object, Rule $rule): bool
    {
        $hour = (int) date('H');
        return $hour >= 9 && $hour < 17;
    }

    public function supports(MoveStrategy $strategy): bool
    {
        return $strategy === MoveStrategy::Callback;
    }
}
services:
    App\AssetPilot\Strategy\BusinessHoursStrategy:
        tags:
            - { name: 'oronts_asset_pilot.strategy', alias: 'business_hours' }

Reference it in a rule config:

oronts_asset_pilot:
    rules:
        controlled_move:
            class: Product
            target_path: '/Products/{{ object.getItemNumber() }}/Images'
            strategy: callback
            callback: App\AssetPilot\Strategy\BusinessHoursStrategy

Custom Path Resolver

Replace the Twig-based path resolution entirely. Implement PathResolverInterface and override the service alias.

namespace App\AssetPilot\PathResolver;

use Oronts\AssetPilotBundle\Model\Rule;
use Oronts\AssetPilotBundle\PathResolver\PathResolverInterface;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class DatabasePathResolver implements PathResolverInterface
{
    public function __construct(private readonly \Doctrine\DBAL\Connection $db) {}

    public function resolve(
        AbstractObject $object,
        Asset $asset,
        Rule $rule,
        ?string $locale = null,
    ): string {
        $path = $this->db->fetchOne(
            'SELECT target_path FROM asset_path_mappings WHERE class_name = ? AND rule_name = ?',
            [$object->getClassName(), $rule->name]
        );
        return $path ?: '/Fallback/' . $object->getKey();
    }
}
services:
    Oronts\AssetPilotBundle\PathResolver\PathResolverInterface:
        alias: App\AssetPilot\PathResolver\DatabasePathResolver

Custom Condition Evaluator

Replace the ExpressionLanguage evaluator with your own logic. Implement ConditionEvaluatorInterface and override the service alias.

namespace App\AssetPilot\Condition;

use Oronts\AssetPilotBundle\Condition\ConditionEvaluatorInterface;
use Oronts\AssetPilotBundle\Model\Rule;
use Pimcore\Model\Asset;
use Pimcore\Model\DataObject\AbstractObject;

class WorkflowConditionEvaluator implements ConditionEvaluatorInterface
{
    public function evaluate(AbstractObject $object, Asset $asset, Rule $rule): bool
    {
        if ($rule->condition === null) {
            return true;
        }

        // Only proceed if the object's workflow state matches the condition
        return $object->getProperty('workflow_state') === $rule->condition;
    }
}
services:
    Oronts\AssetPilotBundle\Condition\ConditionEvaluatorInterface:
        alias: App\AssetPilot\Condition\WorkflowConditionEvaluator

Custom Naming Strategy

Control how filenames are generated and collisions are resolved. Implement NamingStrategyInterface and override the service alias.

namespace App\AssetPilot\Naming;

use Oronts\AssetPilotBundle\Naming\NamingStrategyInterface;
use Pimcore\Model\Asset;

class HashNamingStrategy implements NamingStrategyInterface
{
    public function generateName(Asset $asset, string $targetPath): string
    {
        $ext = pathinfo($asset->getFilename(), PATHINFO_EXTENSION);
        $hash = substr(md5($asset->getFilename() . time()), 0, 8);
        return $hash . '.' . $ext;
    }
}
services:
    Oronts\AssetPilotBundle\Naming\NamingStrategyInterface:
        alias: App\AssetPilot\Naming\HashNamingStrategy

Events

Subscribe to asset move events for custom logic:

namespace App\EventListener;

use Oronts\AssetPilotBundle\Event\AssetMoveEvent;
use Oronts\AssetPilotBundle\Event\AssetPilotEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: AssetPilotEvents::PRE_MOVE)]
class AssetMoveListener
{
    public function __invoke(AssetMoveEvent $event): void
    {
        // Cancel moves to restricted paths
        if (str_starts_with($event->targetPath, '/Protected/')) {
            $event->cancel();
            return;
        }

        // Access event data: $event->asset, $event->object, $event->rule, $event->triggerType
    }
}

Available events:

Event Constant Description
oronts_asset_pilot.pre_move AssetPilotEvents::PRE_MOVE Before move, cancellable
oronts_asset_pilot.post_move AssetPilotEvents::POST_MOVE After successful move
oronts_asset_pilot.move_failed AssetPilotEvents::MOVE_FAILED After failed move
oronts_asset_pilot.bulk_started AssetPilotEvents::BULK_STARTED Bulk operation started
oronts_asset_pilot.bulk_completed AssetPilotEvents::BULK_COMPLETED Bulk operation finished

Database Schema

The installer creates a single table:

CREATE TABLE asset_pilot_audit_log (
    id          INT AUTO_INCREMENT PRIMARY KEY,
    asset_id    INT          NOT NULL,
    asset_path_from VARCHAR(500) NOT NULL,
    asset_path_to   VARCHAR(500) NOT NULL,
    object_id   INT          NOT NULL,
    object_class VARCHAR(255) NOT NULL,
    rule_name   VARCHAR(255) NOT NULL,
    trigger_type VARCHAR(50) NOT NULL,
    status      VARCHAR(50)  NOT NULL,
    error_message TEXT        NULL,
    duration_ms INT          NULL,
    user_id     INT          NULL,
    created_at  DATETIME     NOT NULL,

    INDEX idx_audit_asset_id   (asset_id),
    INDEX idx_audit_object_id  (object_id),
    INDEX idx_audit_rule_name  (rule_name),
    INDEX idx_audit_status     (status),
    INDEX idx_audit_created_at (created_at)
);

Testing

The bundle ships with 133 PHPUnit tests covering all core components.

cd bundles/asset-pilot-bundle
composer install
vendor/bin/phpunit
tests/
├── Unit/
│   ├── Condition/      ExpressionConditionEvaluator (15 tests)
│   ├── Engine/         RuleEngine (15 tests)
│   ├── Enum/           MoveStrategy, OperationStatus, TriggerType (5 tests)
│   ├── Event/          AssetMoveEvent, AssetPilotEvents (5 tests)
│   ├── EventListener/  DataObjectSaveListener (10 tests)
│   ├── Filter/         Type, Size, Extension, Composite filters (21 tests)
│   ├── Message/        OrganizeAssetsMessage, BulkOrganizeMessage (5 tests)
│   ├── MessageHandler/ BulkOrganizeHandler (4 tests)
│   ├── Model/          Rule, RuleMatch, MoveOperation, OperationResult, AssetFieldInfo (19 tests)
│   ├── Service/        LoopGuard (11 tests)
│   └── Strategy/       Always, FirstAssignment, Callback, Resolver (23 tests)
└── bootstrap.php

Requirements

Dependency Version
PHP >= 8.4
Pimcore ^12.0
Symfony Expression Language ^7.0
Symfony Messenger ^7.0
Symfony Lock ^7.0

License

This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0), the same license used by Pimcore itself.

You are free to use, modify, and distribute this bundle in both private and commercial projects. If you modify the source code and distribute it or run it as a service, you must make your modifications available under the same license.


Consulting & Custom Development

Oronts

Oronts provides custom development and integration services:

  • Pimcore bundle development and customization
  • PIM/DAM implementation and architecture
  • Asset workflow automation
  • E-commerce platform implementation

Contact: office@oronts.com | oronts.com


Author: Oronts - AI-powered automation, e-commerce platforms, cloud infrastructure.

Contributors: Refaat Al Ktifan (Refaat@alktifan.com)