File size: 34,253 Bytes
6a49f21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
# Backend Plugin Development Guide

Practical guide for creating **backend plugins** in the PDF-TEI Editor.

**Note**: This guide covers **backend plugins** (Python code running on the server). For **frontend plugins** (JavaScript code running in the browser), see [plugin-development.md](./plugin-development.md). For detailed backend plugin architecture, see [../development/plugin-system-backend.md](../development/plugin-system-backend.md).

**Key Differences**:

- **Backend plugins**: Python modules in `fastapi_app/plugins/` that provide server-side functionality and API endpoints
- **Frontend plugins**: JavaScript classes in `app/src/plugins/` that extend the UI and handle client-side logic

## Architecture

Backend plugins are Python modules discovered at runtime from:

- `fastapi_app/plugins/<plugin_id>/`
- Paths in `FASTAPI_PLUGIN_PATHS` environment variable (colon-separated)

Each plugin:

- Inherits from `fastapi_app.lib.plugin_base.Plugin`
- Defines metadata (id, name, description, category, version, required_roles)
- Implements endpoints as async methods
- Has access to `PluginContext` for app state and user info

## Creating a Plugin

### Directory Structure

**Use underscores in directory names** (not hyphens) to avoid Python import issues:

```text
fastapi_app/plugins/my_plugin/
├── __init__.py
├── plugin.py          # Main plugin class
├── routes.py          # Optional custom routes
├── extensions/        # Optional: frontend extensions to be registered
│   ├── my-script.js
├── static/            # Optional: static assets served at /api/plugins/{plugin_id}/static/
│   ├── view.html      # HTML templates (read by routes.py, also served statically)
│   ├── view.js        # JavaScript for view.html
│   ├── styles.css
│   └── template.xslt
└── tests/             # Plugin tests
    ├── test_plugin.py # Python unit tests
    └── script.test.js # JavaScript unit tests (if applicable)
```

**Static assets** (`static/`): Files in the `static/` subdirectory are automatically served at `/api/plugins/{plugin_id}/static/`. Use this for HTML templates, JS, CSS, XSLT, and other assets. See [Static File Serving](../development/plugin-system-backend.md#static-file-serving).

**Keep JavaScript out of HTML templates**: Never put `<script>` blocks with significant logic inline in HTML template files. Instead, place JavaScript in a separate `.js` file in the `static/` directory — it will be served automatically at `/api/plugins/{plugin_id}/static/view.js` — and reference it from the template:

```html
<script type="module" src="/api/plugins/my-plugin/static/view.js"></script>
```

This keeps templates lean, enables IDE type-checking and autocompletion in the JS file, and avoids JSDoc type resolution limitations in inline scripts.

**Test Discovery**: The smart test runner automatically discovers tests in plugin `tests/` directories. Use `@testCovers` annotations to link tests to plugin files for dependency-based test execution.

**Example Test Annotation**:

```python
"""
Unit tests for My Plugin.

@testCovers fastapi_app/plugins/my_plugin/plugin.py
"""
```

### Minimal Plugin

```python
# plugin.py
from fastapi_app.lib.plugins.plugin_base import Plugin
from typing import Any

class MyPlugin(Plugin):
    @property
    def metadata(self) -> dict[str, Any]:
        return {
            "id": "my-plugin",
            "name": "My Plugin",
            "description": "What it does",
            "category": "analyzer",  # Used for UI grouping
            "version": "1.0.0",
            "required_roles": ["user"]  # or ["*"] for all
        }

    def get_endpoints(self) -> dict[str, callable]:
        return {
            "execute": self.execute,
            "info": self.get_info
        }

    async def execute(self, context, params: dict) -> dict:
        """Main execution endpoint"""
        # Access user: context.user
        # Access app: context.app
        return {"result": "data"}

    async def get_info(self, context, params: dict) -> dict:
        """Info endpoint"""
        return {"plugin": self.metadata["id"]}
```

### Plugin Registration

In `__init__.py`:

```python
from .plugin import MyPlugin

plugin = MyPlugin()
```

## Service Registry

Plugins can register and consume services by capability name without hard dependencies. See [Service Registry](../development/service-registry.md) for details.

## Plugin Dependencies

Plugins can declare dependencies on other plugins. The plugin system loads dependencies first and provides runtime access to them.

### Declaring Dependencies

Add a `dependencies` field to your plugin metadata:

```python
@property
def metadata(self) -> dict[str, Any]:
    return {
        "id": "my-plugin",
        "name": "My Plugin",
        "description": "Plugin that uses another plugin",
        "category": "analyzer",
        "version": "1.0.0",
        "required_roles": ["user"],
        "dependencies": ["base-analyzer", "data-exporter"],
    }
```

### Accessing Dependencies

Use `context.get_dependency()` in your endpoint methods:

```python
async def execute(self, context, params: dict) -> dict:
    # Get dependency plugin instance
    analyzer = context.get_dependency("base-analyzer")
    if analyzer:
        # Call dependency endpoint
        result = await analyzer.get_endpoints()["analyze"](context, params)
        return {"analysis": result}
    return {"error": "Dependency not available"}
```

### Behavior

- Plugins are loaded in dependency order (dependencies first)
- Missing dependencies prevent plugin registration
- Circular dependencies are detected and reported
- Undeclared dependency access logs a warning and returns None

## Conditional Availability

Plugins can define runtime availability conditions using the `is_available()` class method. This allows plugins to be conditionally loaded based on:

- Environment variables (e.g., application mode)
- External dependencies
- Configuration settings
- System capabilities

```python
import os
from fastapi_app.lib.plugins.plugin_base import Plugin

class MyPlugin(Plugin):
    # ... metadata and endpoints ...

    @classmethod
    def is_available(cls) -> bool:
        """Only available in development and testing modes."""
        app_mode = os.environ.get("FASTAPI_APPLICATION_MODE", "development")
        return app_mode in ("development", "testing")
```

**When to Use:**

- Development/testing-only plugins (like sample_analyzer)
- Plugins requiring optional external services
- Feature-flagged functionality
- Environment-specific tools

**Behavior:**

- Unavailable plugins are skipped during discovery (not registered)
- Default implementation returns `True` (always available)
- Checked once at startup during plugin discovery

**Example - Mock Extractor Pattern:**

Similar to [mock_extractor.py](../../fastapi_app/extractors/mock_extractor.py):

```python
@classmethod
def is_available(cls) -> bool:
    """Available only in testing mode."""
    app_mode = os.environ.get("FASTAPI_APPLICATION_MODE", "development")
    return app_mode == "testing"
```

## Plugin Configuration with Environment Variables

Plugins often need configuration that can be set via environment variables or config keys.

### Initialization Pattern

**Initialize configuration values at plugin registration time** in `__init__.py`. This ensures config keys are created from environment variables when the plugin is loaded:

```python
# __init__.py
from fastapi_app.lib.plugins.plugin_tools import get_plugin_config

# Initialize config values from environment variables
get_plugin_config("plugin.my-plugin.enabled", "MY_PLUGIN_ENABLED", default=False, value_type="boolean")
get_plugin_config("plugin.my-plugin.api-key", "MY_PLUGIN_API_KEY", default=None)
get_plugin_config("plugin.my-plugin.timeout", "MY_PLUGIN_TIMEOUT", default=30, value_type="number")

from .plugin import MyPlugin

plugin = MyPlugin()
```

**Access configuration in plugin methods** using `get_config()`:

```python
# plugin.py
from fastapi_app.lib.utils.config_utils import get_config

class MyPlugin(Plugin):
    async def execute(self, context, params: dict) -> dict:
        config = get_config()
        api_key = config.get("plugin.my-plugin.api-key")
        timeout = config.get("plugin.my-plugin.timeout", default=30)

        # Use config values...
```

**Access configuration in custom routes** using `get_config()`:

```python
# routes.py
from fastapi_app.lib.utils.config_utils import get_config

@router.get("/action")
async def custom_action():
    config = get_config()
    api_key = config.get("plugin.my-plugin.api-key")

    # Use config values...
```

**Priority**: Config file (`data/db/config.json`) > Environment variable > Default value

**Key points**:

- Initialize config in `__init__.py` using `get_plugin_config()` (creates keys from env vars)
- Access config everywhere else using `get_config()` (retrieves existing keys)
- Config values are automatically created from environment variables on first initialization
- Routes and plugin methods use the same `get_config()` pattern

**Example - Plugin availability based on config**:

```python
@classmethod
def is_available(cls) -> bool:
    """Only available if enabled in config."""
    from fastapi_app.lib.plugins.plugin_tools import get_plugin_config

    enabled = get_plugin_config(
        "plugin.my-plugin.enabled",
        "MY_PLUGIN_ENABLED",
        default=False,
        value_type="boolean"
    )

    if not enabled:
        return False

    # Check if required configuration is present
    api_key = get_plugin_config(
        "plugin.my-plugin.api-key",
        "MY_PLUGIN_API_KEY",
        default=None
    )

    if not api_key:
        return False

    return True
```

**Reference Implementation**: See [local_sync plugin](../../fastapi_app/plugins/local_sync) for complete example.

## Role-Based Access

- `required_roles: ["admin"]` - Only admin users
- `required_roles: ["user"]` - Any authenticated user
- `required_roles: ["*"]` - Everyone (including anonymous)
- `required_roles: []` - Everyone (including anonymous)

Wildcard `*` in user roles grants access to all plugins.

## Frontend Integration

Plugins appear in toolbar dropdown, organized by category. Frontend calls:

```javascript
// List plugins (role-filtered)
const plugins = await api.getBackendPlugins();

// Execute plugin
const result = await api.executeBackendPlugin(
  'my-plugin',
  'execute',
  { param: 'value' }
);
```

### Multi-Endpoint Menu Support

Plugins can define multiple menu entries, each calling a different endpoint with different parameters from the application state:

```python
@property
def metadata(self) -> dict[str, Any]:
    return {
        "id": "my-analyzer",
        "name": "Document Analyzer",
        "description": "Analyzes documents",
        "version": "1.0.0",
        "category": "analyzer",
        "required_roles": ["user"],
        "endpoints": [
            {
                "name": "analyze",
                "label": "Analyze Current XML",
                "description": "Analyze currently open XML document",
                "state_params": ["xml", "variant"]
            },
            {
                "name": "analyze_all",
                "label": "Analyze All Documents",
                "description": "Run analysis on all documents",
                "state_params": []
            },
            {
                "name": "info",
                "label": "Plugin Info",
                "description": "Get plugin information",
                "state_params": []
            }
        ]
    }
```

**Endpoint Definition Fields:**

- `name` (required): Endpoint method name (must match key in `get_endpoints()`)
- `label` (required): Display label for menu item
- `description` (optional): Tooltip text
- `state_params` (required): List of state fields to pass as parameters (see [app/src/state.js](../../app/src/state.js))

**Available State Parameters:**

- `pdf` - PDF document ID
- `xml` - XML document ID
- `diff` - Diff XML document ID
- `xpath` - Current XPath selection
- `variant` - Variant filter
- `collection` - Current collection ID
- Other fields from `ApplicationState` typedef

**Backward Compatibility:**

- If `endpoints` not defined: Single menu item calls `execute` endpoint
- If `endpoints` is empty array: Plugin appears in list but adds no menu items

**Example with State Parameters:**

```python
async def analyze(self, context, params: dict) -> dict:
    """Analyze XML document from state parameters"""
    xml_id = params.get("xml")  # Passed from frontend state
    variant = params.get("variant")  # Passed from frontend state

    if xml_id:
        # Load and analyze the XML file
        from fastapi_app.lib.core.dependencies import get_db, get_file_storage
        from fastapi_app.lib.repository.file_repository import FileRepository

        db = get_db()
        file_repo = FileRepository(db)
        file_storage = get_file_storage()

        file_metadata = file_repo.get_file_by_id_or_stable_id(xml_id)
        if file_metadata and file_metadata.file_type == "tei":
            content_bytes = file_storage.read_file(file_metadata.id, "tei")
            text = content_bytes.decode("utf-8")
            # Perform analysis on text
            return {"analysis": "result"}

    return {"error": "No XML document open"}
```

## Backend Plugin Output Pattern

**IMPORTANT: When creating backend plugins that generate HTML or CSV output, ALWAYS use custom routes instead of returning content directly from plugin endpoints except when generating simple messages such a report on a completed task.**

### Pattern for HTML/CSV Output

1. **Plugin endpoint returns URLs** (not HTML/CSV content):

   ```python
   async def analyze(self, context, params: dict) -> dict:
       """Return URLs pointing to custom routes."""
       pdf_id = params.get("pdf")
       variant = params.get("variant")

       view_url = f"/api/plugins/my-plugin/view?pdf={pdf_id}&variant={variant}"
       export_url = f"/api/plugins/my-plugin/export?pdf={pdf_id}&variant={variant}"

       return {
           "outputUrl": view_url,    # For HTML view
           "exportUrl": export_url,  # For CSV export
           "pdf": pdf_id,
           "variant": variant
       }
   ```

2. **Custom routes generate content** (in `routes.py`):

   ```python
   @router.get("/view", response_class=HTMLResponse)
   async def view_history(
       pdf: str = Query(...),
       variant: str = Query("all"),
       session_id: str | None = Query(None),
       x_session_id: str | None = Header(None, alias="X-Session-ID"),
       session_manager=Depends(get_session_manager),
       auth_manager=Depends(get_auth_manager),
   ):
       """Generate HTML page with results."""
       # Authenticate user
       # Process data
       # Generate HTML using generate_datatable_page() or custom template
       return HTMLResponse(content=html)

   @router.get("/export")
   async def export_csv(
       pdf: str = Query(...),
       variant: str = Query("all")
   ):
       """Generate CSV export."""
       # Process data
       # Generate CSV
       return StreamingResponse(
           iter([csv_content]),
           media_type="text/csv",
           headers={"Content-Disposition": f"attachment; filename=export.csv"}
       )
   ```

### Reference Examples

- `fastapi_app/plugins/edit_history/` - Collection-based edit history with DataTables
- `fastapi_app/plugins/annotation_history/` - Document-based annotation history with nested tables



## API Endpoints

- `GET /api/v1/plugins` - List plugins (filtered by user roles)
- `POST /api/v1/plugins/{plugin_id}/execute` - Execute endpoint

Request body:

```json
{
  "endpoint": "execute",
  "params": {"key": "value"}
}
```

## Custom Routes (Optional)

Add `routes.py` for custom FastAPI routes. Plugin routes use the unversioned `/api/plugins` prefix:

```python
from fastapi import APIRouter

# Router prefix uses unversioned /api/plugins
router = APIRouter(prefix="/api/plugins/my-plugin", tags=["my-plugin"])

@router.get("/custom")
async def custom_route():
    return {"custom": "data"}
```

This creates the endpoint at `/api/plugins/my-plugin/custom`.

**Automatic Route Discovery:**

Routes are automatically discovered and registered by the `PluginManager` at application startup. The discovery process:

1. Searches for `routes.py` in each plugin directory
2. Loads the module using `importlib`
3. Looks for a `router` object in the module
4. Registers the router with the FastAPI app via `app.include_router()`

**No manual registration is required** - simply create a `routes.py` file with a `router` export and it will be automatically discovered. The router is registered at the application level (not under `api_v1`), so routes are unversioned.

**Export in `__init__.py` (recommended):**

For consistency with other plugins, also export the router in `__init__.py`:

```python
# __init__.py
from .plugin import MyPlugin
from .routes import router

__all__ = ["MyPlugin", "router"]
```

**Path Requirements:**

- Router prefix should be `/api/plugins/{plugin-id}` (unversioned)
- The router is registered directly on the app (not under the versioned api_v1 router)
- Plugin routes are unversioned and independent of the main API versioning
- Plugin routes are excluded from the generated API client (`api-client-v1.js`)

**Frontend Access:**

Plugin routes should be called using the `callPluginApi` method from `BackendPluginsPlugin`, not the main `callApi` function:

```javascript
// In a plugin or component that has access to BackendPluginsPlugin
const backendPluginsPlugin = /* get BackendPluginsPlugin instance */;

// GET request with query params
const response = await backendPluginsPlugin.callPluginApi(
  '/api/plugins/my-plugin/custom',
  'GET',
  { param1: 'value1', param2: 'value2' }
);

// POST request with JSON body
const response = await backendPluginsPlugin.callPluginApi(
  '/api/plugins/my-plugin/action',
  'POST',
  { data: 'value' }
);

// Handle different response types
const jsonData = await response.json();  // For JSON responses
const blob = await response.blob();      // For file downloads
const text = await response.text();      // For text responses
```

The `callPluginApi` method:

- Automatically adds authentication headers (`X-Session-ID`)
- Handles query parameters for GET requests
- Handles JSON body for POST/PUT/etc requests
- Returns the raw `Response` object for flexible response handling
- Throws errors for non-OK responses

## Key Files

- [fastapi_app/lib/plugins/plugin_base.py](../../fastapi_app/lib/plugins/plugin_base.py) - Base classes
- [fastapi_app/lib/plugins/plugin_registry.py](../../fastapi_app/lib/plugins/plugin_registry.py) - Discovery
- [fastapi_app/lib/plugins/plugin_manager.py](../../fastapi_app/lib/plugins/plugin_manager.py) - Lifecycle
- [fastapi_app/routes/plugins.py](../../fastapi_app/routes/plugins.py) - API routes
- [app/src/plugins/backend-plugins.js](../../app/src/plugins/backend-plugins.js) - Frontend integration

## Plugin Response Formats

Backend plugins can return results in three formats depending on the complexity and interaction requirements:

### 1. Inline HTML (for simple content)

Use the `html` field for short, simple text results that fit comfortably in the dialog:

```python
async def execute(self, context, params: dict) -> dict:
    """Return simple HTML content."""
    return {
        "html": "<p>Analysis complete. Found 42 matches.</p>"
    }
```

**When to use:**

- Short text results (a few paragraphs)
- Simple lists or small tables
- Quick status messages or summaries
- Content that doesn't need extensive formatting

### 2. Standalone Pages (for complex content)

Use the `outputUrl` field for complex, tabular data, or content requiring JavaScript libraries:

```python
async def execute(self, context, params: dict) -> dict:
    """Return URL to standalone page."""
    collection_id = params.get("collection")
    variant = params.get("variant")

    # Build URL to custom route that returns complete HTML page
    view_url = f"/api/plugins/my-plugin/view?collection={collection_id}&variant={variant}"
    export_url = f"/api/plugins/my-plugin/export?collection={collection_id}&variant={variant}"

    return {
        "outputUrl": view_url,      # Displayed in iframe
        "exportUrl": export_url,    # Optional: enable export button
        "collection": collection_id # Optional: pass data to frontend
    }
```

**When to use:**

- Large tables with sorting/filtering (e.g., DataTables)
- Content requiring external JavaScript libraries
- Complex visualizations or charts
- Multi-section reports
- Any content needing custom CSS or extensive styling

**Benefits:**

- Proper script execution (iframe loads scripts naturally)
- Better performance (libraries load once)
- "Open in new window" button for full-screen viewing
- Cleaner separation (route generates HTML, plugin coordinates)

**Implementation pattern:**

1. **Create a custom route** in `routes.py` that generates the full HTML page:

```python
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from fastapi_app.lib.plugins.plugin_tools import generate_datatable_page, escape_html

router = APIRouter(prefix="/api/plugins/my-plugin", tags=["my-plugin"])

@router.get("/view", response_class=HTMLResponse)
async def view_results(
    collection: str = Query(...),
    variant: str | None = Query(None),
    session_id: str | None = Query(None),
    x_session_id: str | None = Header(None, alias="X-Session-ID"),
    session_manager=Depends(get_session_manager),
    auth_manager=Depends(get_auth_manager),
):
    """Generate standalone HTML page with results."""
    # Authenticate user (see User Authentication section)
    # ...

    # Prepare table data
    headers = ["Column 1", "Column 2", "Column 3"]
    rows = [
        [escape_html("Data 1"), escape_html("Data 2"), "Data 3"],
        # ... more rows
    ]

    # Generate HTML page with DataTables
    html = generate_datatable_page(
        title="My Plugin Results",
        headers=headers,
        rows=rows,
        table_id="resultsTable",
        page_length=25,
        default_sort_col=0,
        default_sort_dir="desc",
        enable_sandbox_client=True  # For inter-window communication
    )

    return HTMLResponse(content=html)
```

1. **Return the URL** from your plugin endpoint:

```python
async def execute(self, context, params: dict) -> dict:
    collection_id = params.get("collection")
    view_url = f"/api/plugins/my-plugin/view?collection={collection_id}"

    return {
        "outputUrl": view_url,
        "collection": collection_id
    }
```

**See also:** [edit_history plugin](../../fastapi_app/plugins/edit_history) for complete example.

### 3. Preview-then-Execute Pattern (for operations requiring confirmation)

Use the `outputUrl` and `executeUrl` fields together for operations that should show a preview before execution:

```python
async def execute(self, context, params: dict) -> dict:
    """Return URLs for preview and execute endpoints."""
    collection_id = params.get("collection")
    variant = params.get("variant", "all")

    # Build URLs for preview and execute
    variant_param = f"&variant={variant}" if variant != "all" else ""
    preview_url = f"/api/plugins/my-plugin/preview?collection={collection_id}{variant_param}"
    execute_url = f"/api/plugins/my-plugin/execute?collection={collection_id}{variant_param}"

    return {
        "outputUrl": preview_url,      # Shows preview in iframe
        "executeUrl": execute_url,      # Execute button calls this URL
        "collection": collection_id,
        "variant": variant
    }
```

**When to use:**

- Operations that modify data (syncing, batch updates, deletions)
- Operations where users need to review changes before applying
- Operations that can be expensive and should be confirmed

**User flow:**

1. Plugin returns `outputUrl` and `executeUrl`
2. Frontend displays preview in iframe (from `outputUrl`)
3. Execute button appears in dialog footer
4. User reviews changes and clicks Execute
5. Execute button loads `executeUrl` in the same iframe
6. Execute button is hidden after clicking

**Implementation pattern:**

Create two routes in `routes.py`:

```python
@router.get("/preview", response_class=HTMLResponse)
async def preview_operation(
    collection: str = Query(...),
    session_id: str | None = Query(None),
    x_session_id: str | None = Header(None, alias="X-Session-ID"),
    session_manager=Depends(get_session_manager),
    auth_manager=Depends(get_auth_manager),
):
    """Generate preview HTML (dry-run mode)."""
    # Authenticate user
    # Perform dry-run operation
    # Generate detailed HTML with preview notice
    # Return HTML with message: "Click Execute to apply these changes"
    return HTMLResponse(content=preview_html)

@router.get("/execute", response_class=HTMLResponse)
async def execute_operation(
    collection: str = Query(...),
    session_id: str | None = Query(None),
    x_session_id: str | None = Header(None, alias="X-Session-ID"),
    session_manager=Depends(get_session_manager),
    auth_manager=Depends(get_auth_manager),
):
    """Execute the operation and return summary HTML."""
    # Authenticate user
    # Perform actual operation
    # Generate summary HTML (statistics only)
    return HTMLResponse(content=summary_html)
```

**Preview HTML structure:**

- Include prominent notice: "Preview Mode - Click Execute to apply changes"
- Show detailed list of changes that will be made
- Use complete HTML document with styles
- Keep layout clean and readable

**Execute HTML structure:**

- Show success message
- Display summary statistics only (no details)
- Optionally show errors if any occurred
- Use complete HTML document with styles

**See also:** [local_sync plugin](../../fastapi_app/plugins/local_sync) for complete example.

### 4. Options-then-Execute Pattern (for user-configured operations)

Use `outputUrl` with `sandbox.navigateIframe()` when the user must configure options in a form before execution starts. Unlike Preview-then-Execute (which uses a fixed `executeUrl`), this pattern lets the iframe build the next URL dynamically based on form input.

**User flow:**

1. Plugin endpoint returns `outputUrl` pointing to an options form route
2. Frontend loads form in iframe
3. User configures options, clicks "Start"
4. Form JS calls `sandbox.navigateIframe('/api/plugins/.../execute?params...')`
5. Sandbox injects `session_id` and navigates iframe to execution route
6. Execution route runs and displays results

**Key requirements:**

- Options form must include `generate_sandbox_client_script()` so `sandbox.navigateIframe()` is available
- `session_id` is injected automatically — form JS only builds the path and query parameters
- "Cancel" button can call `sandbox.closeDialog()`
- Each page in the chain is a full page load; the sandbox client reconnects on each navigation

For detailed implementation pattern with code examples, see [Options-then-Execute Pattern](../development/plugin-system-backend.md#4-options-then-execute-pattern-for-user-configured-operations).

**See also:** [update_metadata plugin](../../fastapi_app/plugins/update_metadata) for complete example.

## Interactive HTML Content

Both response formats support interactive elements through the **Plugin Sandbox** interface.

### Plugin Sandbox API

When plugin content is displayed (either via `html` or `outputUrl`), a `window.pluginSandbox` object (or `window.sandbox` in standalone pages) exposes methods to interact with the application:

```javascript
// Available methods:
// - In inline HTML: window.pluginSandbox
// - In standalone pages (outputUrl): window.sandbox

// Update application state (any fields from ApplicationState) - async
await pluginSandbox.updateState({ xml: 'doc-id', variant: 'model-x' });

// Close the result dialog
pluginSandbox.closeDialog();

// Navigate the iframe to a new URL (session_id injected automatically)
sandbox.navigateIframe('/api/plugins/my-plugin/execute?param=value');

// Convenience: Open a document (updates xml state, clears diff, closes dialog) - async
await pluginSandbox.openDocument('stable-id');

// Convenience: Open diff view (updates xml and diff states, closes dialog) - async
await pluginSandbox.openDiff('stable-id-1', 'stable-id-2');
```

### Example: Clickable Links

**In inline HTML:**

```python
async def execute(self, context, params: dict) -> dict:
    """Generate interactive HTML with clickable links."""
    doc_id = "abc123"

    # Create clickable link that opens document
    html = f'''
    <p>View document:
      <a href="#"
         onclick="window.pluginSandbox.openDocument('{doc_id}'); return false;"
         style="color: #0066cc; text-decoration: underline;">
        {doc_id}
      </a>
    </p>
    '''

    return {"html": html}
```

**In standalone pages (outputUrl):**

When using `generate_datatable_page()` with `enable_sandbox_client=True`, use `sandbox` (not `pluginSandbox`):

```python
# In your custom route
from fastapi_app.lib.plugins.plugin_tools import escape_html

doc_link = f'''<a href="#"
   onclick="sandbox.openDocument('{entry["stable_id"]}'); return false;"
   style="color: #0066cc; text-decoration: underline;">
   {escape_html(entry["doc_label"])}
</a>'''

rows.append([
    escape_html(entry["date"]),
    doc_link,  # Clickable link
    escape_html(entry["description"])
])
```

### Available State Fields

The sandbox can update any field from `ApplicationState` (see [app/src/state.js](../../app/src/state.js)):

- `xml` - Open XML document ID
- `diff` - Diff XML document ID (triggers diff view when set with `xml`)
- `pdf` - Open PDF document ID
- `xpath` - Current XPath selection
- `variant` - Variant filter
- `collection` - Current collection ID
- Other fields as needed

### Implementation Examples

- **Inline HTML**: The [iaa_analyzer plugin](../../fastapi_app/plugins/iaa_analyzer/plugin.py) demonstrates inline HTML with clickable stable IDs and match counts that open documents or diff views
- **Standalone pages**: The [edit_history plugin](../../fastapi_app/plugins/edit_history) demonstrates the `outputUrl` pattern with a complete DataTables implementation

### Utility Functions

The `fastapi_app.lib.plugin_tools` module provides utilities for generating plugin content:

**`generate_datatable_page()`** - Generate complete HTML page with sortable DataTables table:

```python
from fastapi_app.lib.plugins.plugin_tools import generate_datatable_page, escape_html

html = generate_datatable_page(
    title="Results",                    # Page title
    headers=["Col1", "Col2"],          # Column headers
    rows=[                              # Table rows (can contain HTML)
        [escape_html("A"), "B"],
        [escape_html("C"), "D"]
    ],
    table_id="myTable",                # HTML table ID
    page_length=25,                    # Rows per page
    default_sort_col=0,                # Sort column index
    default_sort_dir="desc",           # "asc" or "desc"
    enable_sandbox_client=True,        # Include sandbox for links
    custom_css="",                     # Additional CSS
    custom_js=""                       # Additional JavaScript
)
```

**`escape_html()`** - Escape HTML to prevent XSS:

```python
from fastapi_app.lib.plugins.plugin_tools import escape_html

safe_text = escape_html(user_input)  # Escapes <, >, &, ", '
```

**`generate_sandbox_client_script()`** - Generate sandbox client for custom HTML pages (advanced use).

**`load_plugin_html()`** - Load an HTML template from `static/` and inject the sandbox client automatically:

```python
from fastapi_app.lib.plugins.plugin_tools import load_plugin_html

@router.get("/view", response_class=HTMLResponse)
async def view_page(...):
    html = load_plugin_html(__file__, "view.html")
    return HTMLResponse(content=html)
```

Reads `static/view.html` relative to the calling module's directory. Pass `inject_sandbox=False` to skip sandbox injection. The legacy `html/` directory is still supported as a fallback for backwards compatibility but is deprecated.

**`wrap_html_with_sandbox_client()`** - Lower-level helper that injects the sandbox client script into an arbitrary HTML string (advanced use).

**IMPORTANT**: Never inline long HTML strings in route files. Place HTML in external template files in `static/` and load them with `load_plugin_html()`.

## Frontend Extensions

Backend plugins can register JavaScript files that extend frontend functionality. These extensions integrate with the application's PluginManager lifecycle and have access to a controlled sandbox API.

See [Frontend Extensions](../development/frontend-extensions.md) for detailed documentation.

Quick reference:

- Register extensions via `FrontendExtensionRegistry.register_extension(path, plugin_id)`
- Extensions integrate with PluginManager lifecycle (`install`, `start`, `onStateUpdate`)
- Use sandbox for controlled API access (`ui`, `dialog`, `notify`, `invoke`, etc.)

**Example registration in plugin initialize():**

```python
from fastapi_app.lib.plugins.frontend_extension_registry import FrontendExtensionRegistry
from pathlib import Path

async def initialize(self, context: PluginContext) -> None:
    registry = FrontendExtensionRegistry.get_instance()
    extension_file = Path(__file__).parent / "extensions" / "my-extension.js"
    if extension_file.exists():
        registry.register_extension(extension_file, self.metadata["id"])
```

## Notes

- **Directory naming**: Use underscores (e.g., `my_plugin`) not hyphens (e.g., `my-plugin`) in directory names to avoid Python import issues
- Shadow DOM: Frontend uses `querySelector` to access Shoelace menu elements
- Plugin discovery happens at startup
- Plugins can be reloaded without restart in dev mode
- Use `PluginManager.get_instance()` to access plugin manager
- **Plugin Sandbox**: Always available as `window.pluginSandbox` when plugin HTML is displayed
- **HTML escaping**: Always escape user-provided content in HTML to prevent XSS attacks