# Contributions: embedding into standard flows

A plugin does not create a separate application inside XLibrary. It contributes
to an existing flow, receives a standard payload, and returns a standard result.
Import, Export, `+ Add game`, UI, filters, and backup therefore behave the same
for users whether the implementation is built in or provided by a plugin.

## Game sources and `+ Add game`

```json
{
  "permissions": ["game-sources.providers"],
  "contributions": {
    "gameSources": [
      {
        "id": "catalog",
        "title": "My Catalog",
        "description": "Searches My Catalog.",
        "hosts": ["catalog.example"],
        "operations": ["search", "resolve", "refresh"]
      }
    ]
  }
}
```

### `search`

`game-sources.search` receives a query, cursor, limit, and locale. Return at most
50 candidates and `nextCursor` when another page exists. Every candidate must
have an external id, an HTTPS canonical URL, and a title.

### `resolve`

`game-sources.resolve` receives an `externalId` or URL. Return one normalized
draft:

```ts
return {
  externalId: 'game-123',
  canonicalUrl: 'https://catalog.example/games/game-123',
  name: 'Example Game',
  description: 'Description',
  tags: ['rpg'],
  screenshotUrls: [],
  isPartial: false,
  missingFields: [],
  metadata: {catalogType: 'pc'},
};
```

### `refresh`

Refresh receives the current `id/name/version`, but should fetch and validate
the provider page again. Return a draft and a non-empty `changedFields` array;
the application applies only the allowed fields.

### Browser capture

Add `browser-capture` to the operations and `browser.page.capture` to the
permissions:

```json
{
  "hosts": ["catalog.example"],
  "operations": ["browser-capture"]
}
```

The plugin receives:

```ts
{
  url: 'https://catalog.example/games/game-123',
  title: 'Example Game',
  html: '<main>...</main>',
  text: 'Example Game ...',
  capturedAt: '2026-01-01T00:00:00.000Z'
}
```

This is a safe snapshot, not code execution in the browser page. The extension
provides it only after host approval. Treat HTML as untrusted text and never
execute it.

## Universal Import

```json
{
  "permissions": ["imports.sources"],
  "contributions": {
    "imports": [
      {
        "id": "my-format",
        "title": "My format",
        "description": "Imports My Library JSON.",
        "fileExtensions": [".json"],
        "supportsAutomaticDetection": true
      }
    ]
  }
}
```

`imports.parse` receives only metadata for the selected input (`id`, `fileName`,
and `size`). Content is read through the bounded chunk API:

```ts
const inputId = String(invocation.payload.input.id);
const chunk = await host.importInput.readChunk(inputId, 0, 512 * 1024);
const bytes = Uint8Array.from(atob(chunk.data), (char) => char.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
```

Do not expect a `filePath` or open files directly. Return `{games, warnings}`.
The standard import flow validates each game, creates the preview, and only then
applies the import.

## Universal Export

```json
{
  "permissions": ["exports.targets"],
  "contributions": {
    "exports": [
      {
        "id": "my-format",
        "title": "My format",
        "description": "Exports My Library JSON.",
        "fileExtension": ".json",
        "mimeType": "application/json"
      }
    ]
  }
}
```

The payload contains selected `games?` and format metadata. Encode UTF-8 data as
base64 and write it in chunks:

```ts
await host.exportOutput.write(toBase64(JSON.stringify({games})));
return {completed: true};
```

The plugin cannot write to an arbitrary path. XLibrary creates the staged
output, controls its size, and finalizes the file after `{completed: true}`.

## Filters

A facet can be `boolean`, `select`, `multi-select`, or `number-range`:

```json
{
  "permissions": ["filters.facets", "library.games.read"],
  "contributions": {
    "filters": [
      {
        "id": "has-achievement",
        "kind": "boolean",
        "title": "Has achievement"
      },
      {
        "id": "score",
        "kind": "number-range",
        "title": "Score",
        "minimum": 0,
        "maximum": 100
      }
    ]
  }
}
```

`filters.evaluate` receives selected facets and games. Return exactly the list
of known `game.id` values for every facet:

```ts
return {
  matches: {
    'has-achievement': games.filter((game) => hasAchievement(game)).map((game) => game.id),
    score: games.filter((game) => scoreInRange(game)).map((game) => game.id),
  },
};
```

The application intersects facet results. A plugin does not modify the library
and must not return unknown game ids.

## Data-only UI

```json
{
  "permissions": ["ui.game-details.sidebar"],
  "contributions": {
    "ui": [{
      "id": "tools",
      "slot": "ui.game-details.sidebar",
      "title": "Catalog tools"
    }]
  }
}
```

`ui.render` returns a `PluginUiPanel` with these node types:

- `notice` — text state with `default/info/success/warning/danger` tone;
- `stat` — label/value/description;
- `key-value` — a bounded list of pairs;
- `action` — action id, label, optional disabled/description.

`ui.action` receives a contribution id, action id, and context. It may return a
new panel. It cannot return a component, HTML, iframe URL, or callback.

## Custom game fields

```json
{
  "permissions": ["game.custom-fields"],
  "contributions": {
    "gameFields": [
      {"id": "score", "kind": "number", "label": "Score", "minimum": 0, "maximum": 100},
      {"id": "note", "kind": "text", "label": "Note", "maximumLength": 280},
      {"id": "favorite", "kind": "boolean", "label": "Favorite"},
      {"id": "genre", "kind": "select", "label": "Genre", "options": [{"label": "RPG", "value": "rpg"}]}
    ]
  }
}
```

Fields belong to the plugin namespace and are included in host-owned backups.
Values returned from tracking/actions must match the declared field type.

## Tracking providers

```json
{
  "permissions": [
    "tracking.providers",
    "sessions.events.read",
    "library.games.read",
    "game.custom-fields"
  ],
  "contributions": {
    "tracking": [{
      "id": "play-session",
      "title": "Play session tracker",
      "description": "Handles session start and end.",
      "eventTypes": ["started", "ended"]
    }]
  }
}
```

`tracking.session-event` receives a normalized session, game, and optional
duration. Return only `{fieldValues?}`. A tracking handler failure faults the
plugin, so handlers should be idempotent and tolerate duplicate events.

## Game actions

An action may return a patch only with `library.games.write`; field values also
require `game.custom-fields`:

```ts
return {
  message: 'Marked as played',
  gamePatch: {addTags: ['played'], removeTags: ['backlog']},
  fieldValues: {score: 100},
};
```

When `dryRun: true`, do not write storage or treat the operation as completed.
Return the same patch so the standard UI can show the proposed changes.

## Jobs

```json
{
  "permissions": ["jobs.run"],
  "contributions": {
    "jobs": [{
      "id": "sync",
      "title": "Synchronize",
      "triggers": ["startup", "session-ended"],
      "minimumIntervalSeconds": 300
    }]
  }
}
```

Jobs are rate-limited and recorded in run history. Do not create an unbounded
custom timer; use `jobs.run` and handle repeated execution safely.

## Settings, storage, and migrations

Settings are typed values from the manifest and the user's current state. When
increasing `dataVersion`, implement `settings.migrate`:

```ts
if (invocation.method === 'settings.migrate') {
  return {
    values: {
      ...invocation.payload.values,
      endpoint: invocation.payload.values.endpoint || 'https://example.com',
    },
  };
}
```

Storage migration works the same way but allows an arbitrary JSON tree. A
migration should be repeatable, preserve compatible unknown values, and avoid
network side effects.

## Backup

```json
{
  "permissions": ["backup.contribute"],
  "contributions": {
    "backup": {"dataVersion": 1, "title": "My plugin data"}
  }
}
```

`backup.capture` returns `{dataVersion, data}`. Data must be bounded JSON and
must not contain tokens, cookies, passkeys, magnets, local paths, logs,
temporary files, or rebuildable cache. `backup.restore` must support `dryRun`;
the application rolls back host-owned mutations when restoration fails.

## Network and auth

Network hosts are declared separately:

```json
{
  "permissions": ["network.http"],
  "contributions": {"network": {"allowedHosts": ["catalog.example"]}}
}
```

An auth contribution describes only a safe HTTPS login flow and allowlist:

```json
{
  "permissions": ["plugin.auth"],
  "contributions": {
    "auth": [{
      "id": "catalog-account",
      "title": "Catalog account",
      "description": "Connect a catalog account.",
      "loginUrl": "https://catalog.example/login",
      "completionUrl": "https://catalog.example/oauth/callback",
      "allowedHosts": ["catalog.example"]
    }]
  }
}
```

The plugin never receives an Electron session, cookie store, or raw token
storage. It calls the broker and receives only the permitted request result.
