Compare commits
18 Commits
ec2bc0620a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b617234b40 | |||
| 1c918ca55c | |||
| 7322bb16da | |||
| 78449a702b | |||
| 4bcfbf6d0e | |||
| cceaf809d9 | |||
| 5bfe5dd249 | |||
| 65435b526c | |||
| 4a7fe7aeb4 | |||
| ad0a20613e | |||
| 6e5627f83b | |||
| 83c04e659b | |||
| 0310a30f22 | |||
| f520b8e5ac | |||
| 1f8a6d2d07 | |||
| e5eeeeb546 | |||
| 6716e507c4 | |||
| f704e2e392 |
42
.github/workflows/build-test.yml
vendored
Normal file
42
.github/workflows/build-test.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Build Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve Server Install Action
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/action-server-install
|
||||||
|
ref: main
|
||||||
|
path: action-server-install
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install Server
|
||||||
|
uses: ./action-server-install
|
||||||
|
with:
|
||||||
|
install-php: 'false'
|
||||||
|
install-node: 'true'
|
||||||
|
php-version: '8.5'
|
||||||
|
node-version: '24'
|
||||||
|
server-path: './server'
|
||||||
|
|
||||||
|
- name: Checkout Pull Request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: server/modules/mail_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
42
.github/workflows/js-unit-tests.yml
vendored
Normal file
42
.github/workflows/js-unit-tests.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: JS Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve Server Install Action
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/action-server-install
|
||||||
|
ref: main
|
||||||
|
path: action-server-install
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install Server
|
||||||
|
uses: ./action-server-install
|
||||||
|
with:
|
||||||
|
install-php: 'false'
|
||||||
|
install-node: 'true'
|
||||||
|
php-version: '8.5'
|
||||||
|
node-version: '24'
|
||||||
|
server-path: './server'
|
||||||
|
|
||||||
|
- name: Checkout Pull Request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: server/modules/mail_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm run test:unit
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
42
.github/workflows/php-unit-tests.yml
vendored
Normal file
42
.github/workflows/php-unit-tests.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: PHP Unit Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Retrieve Server Install Action
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: Nodarx/action-server-install
|
||||||
|
ref: main
|
||||||
|
path: action-server-install
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install Server
|
||||||
|
uses: ./action-server-install
|
||||||
|
with:
|
||||||
|
install-php: 'true'
|
||||||
|
install-node: 'false'
|
||||||
|
php-version: '8.5'
|
||||||
|
node-version: '24'
|
||||||
|
server-path: './server'
|
||||||
|
|
||||||
|
- name: Checkout Pull Request
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
with:
|
||||||
|
repository: ${{ github.repository }}
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
path: server/modules/mail_manager
|
||||||
|
github-server-url: https://git.ktrix.dev
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: composer install --prefer-dist --no-progress
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: composer test:unit
|
||||||
|
working-directory: server/modules/mail_manager
|
||||||
35
.github/workflows/renovate.yml
vendored
Normal file
35
.github/workflows/renovate.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Renovate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6.0.2
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6.2.0
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Set up PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.5'
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Install Renovate
|
||||||
|
run: npm install -g renovate
|
||||||
|
|
||||||
|
- name: Run Renovate
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
RENOVATE_PLATFORM: gitea
|
||||||
|
RENOVATE_ENDPOINT: https://git.ktrix.dev/api/v1
|
||||||
|
run: renovate ${{ gitea.repository }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ node_modules/
|
|||||||
/lib/vendor/
|
/lib/vendor/
|
||||||
coverage/
|
coverage/
|
||||||
phpunit.xml.cache
|
phpunit.xml.cache
|
||||||
|
.phpunit.cache
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
.php-cs-fixer.cache
|
.php-cs-fixer.cache
|
||||||
.phpstan.cache
|
.phpstan.cache
|
||||||
|
|||||||
@@ -18,9 +18,25 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2 <=8.5"
|
"php": ">=8.2 <=8.5"
|
||||||
},
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"KTXM\\MailManager\\": "lib/"
|
"KTXM\\MailManager\\": "lib/"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"KTXT\\MailManager\\Tests\\": "tests/php/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"post-install-cmd": [
|
||||||
|
],
|
||||||
|
"post-update-cmd": [
|
||||||
|
],
|
||||||
|
"test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox",
|
||||||
|
"test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1805
composer.lock
generated
Normal file
1805
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,428 +0,0 @@
|
|||||||
# Mail Manager - Interface Relationships
|
|
||||||
|
|
||||||
This document visualizes all the interfaces in the mail_manager module and their relationships.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The mail manager uses a hierarchical structure where interfaces are organized by their domain responsibilities:
|
|
||||||
- **Common Types**: Base types and selectors
|
|
||||||
- **Providers**: Mail service providers (Gmail, IMAP, etc.)
|
|
||||||
- **Services**: Individual mail accounts/services
|
|
||||||
- **Collections**: Mailboxes and folders
|
|
||||||
- **Messages**: Email messages and their parts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Complete Interface Diagram
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
classDiagram
|
|
||||||
%% Common/Base Types
|
|
||||||
class SourceSelector {
|
|
||||||
+string provider
|
|
||||||
+string service
|
|
||||||
+string collection
|
|
||||||
+string message
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiRequest~T~ {
|
|
||||||
+T data
|
|
||||||
}
|
|
||||||
|
|
||||||
class ApiResponse~T~ {
|
|
||||||
+T data
|
|
||||||
+Error error
|
|
||||||
}
|
|
||||||
|
|
||||||
class ListRange {
|
|
||||||
+number offset
|
|
||||||
+number limit
|
|
||||||
}
|
|
||||||
|
|
||||||
%% Provider Interfaces
|
|
||||||
class ProviderInterface {
|
|
||||||
+string @type
|
|
||||||
+string identifier
|
|
||||||
+string label
|
|
||||||
+ProviderCapabilitiesInterface capabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderCapabilitiesInterface {
|
|
||||||
+boolean ServiceList
|
|
||||||
+boolean ServiceFetch
|
|
||||||
+boolean ServiceExtant
|
|
||||||
+boolean ServiceCreate
|
|
||||||
+boolean ServiceModify
|
|
||||||
+boolean ServiceDestroy
|
|
||||||
+boolean ServiceDiscover
|
|
||||||
+boolean ServiceTest
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderListRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderListResponse {
|
|
||||||
+ProviderInterface[identifier] providers
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderFetchRequest {
|
|
||||||
+string identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderFetchResponse {
|
|
||||||
<<extends ProviderInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderExtantRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProviderExtantResponse {
|
|
||||||
+boolean[identifier] exists
|
|
||||||
}
|
|
||||||
|
|
||||||
%% Service Interfaces
|
|
||||||
class ServiceInterface {
|
|
||||||
+string @type
|
|
||||||
+string identifier
|
|
||||||
+string provider
|
|
||||||
+string label
|
|
||||||
+ServiceCapabilitiesInterface capabilities
|
|
||||||
+object configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceCapabilitiesInterface {
|
|
||||||
+boolean CollectionList
|
|
||||||
+boolean CollectionFetch
|
|
||||||
+boolean CollectionExtant
|
|
||||||
+boolean CollectionCreate
|
|
||||||
+boolean CollectionModify
|
|
||||||
+boolean CollectionDestroy
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceListRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
+ListRange range
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceListResponse {
|
|
||||||
+ServiceInterface[identifier] services
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceFetchRequest {
|
|
||||||
+string provider
|
|
||||||
+string identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceFetchResponse {
|
|
||||||
<<extends ServiceInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceExtantRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceExtantResponse {
|
|
||||||
+boolean[identifier] exists
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceCreateRequest {
|
|
||||||
+string provider
|
|
||||||
+string label
|
|
||||||
+object configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceCreateResponse {
|
|
||||||
<<extends ServiceInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceModifyRequest {
|
|
||||||
+string provider
|
|
||||||
+string identifier
|
|
||||||
+string label
|
|
||||||
+object configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceModifyResponse {
|
|
||||||
<<extends ServiceInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceDestroyRequest {
|
|
||||||
+string provider
|
|
||||||
+string identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
class ServiceDestroyResponse {
|
|
||||||
+boolean success
|
|
||||||
}
|
|
||||||
|
|
||||||
%% Collection Interfaces
|
|
||||||
class CollectionInterface {
|
|
||||||
+string @type
|
|
||||||
+string identifier
|
|
||||||
+string service
|
|
||||||
+string provider
|
|
||||||
+string label
|
|
||||||
+CollectionCapabilitiesInterface capabilities
|
|
||||||
+string[] flags
|
|
||||||
+number messageCount
|
|
||||||
}
|
|
||||||
|
|
||||||
class CollectionCapabilitiesInterface {
|
|
||||||
+boolean MessageList
|
|
||||||
+boolean MessageFetch
|
|
||||||
+boolean MessageExtant
|
|
||||||
+boolean MessageCreate
|
|
||||||
+boolean MessageModify
|
|
||||||
+boolean MessageDestroy
|
|
||||||
}
|
|
||||||
|
|
||||||
class CollectionListRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
+ListRange range
|
|
||||||
}
|
|
||||||
|
|
||||||
class CollectionListResponse {
|
|
||||||
+CollectionInterface[identifier] collections
|
|
||||||
}
|
|
||||||
|
|
||||||
class CollectionFetchRequest {
|
|
||||||
+string provider
|
|
||||||
+string service
|
|
||||||
+string identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
class CollectionFetchResponse {
|
|
||||||
<<extends CollectionInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
%% Message Interfaces
|
|
||||||
class MessageInterface {
|
|
||||||
+string @type
|
|
||||||
+string identifier
|
|
||||||
+string collection
|
|
||||||
+string service
|
|
||||||
+string provider
|
|
||||||
+string[] flags
|
|
||||||
+Date receivedDate
|
|
||||||
+Date internalDate
|
|
||||||
+MessageHeadersInterface headers
|
|
||||||
+MessagePartInterface[] parts
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageHeadersInterface {
|
|
||||||
+string from
|
|
||||||
+string[] to
|
|
||||||
+string[] cc
|
|
||||||
+string[] bcc
|
|
||||||
+string subject
|
|
||||||
+string messageId
|
|
||||||
+string[] references
|
|
||||||
+string inReplyTo
|
|
||||||
+Date date
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessagePartInterface {
|
|
||||||
+string partId
|
|
||||||
+string mimeType
|
|
||||||
+string filename
|
|
||||||
+number size
|
|
||||||
+MessagePartInterface[] subParts
|
|
||||||
+object headers
|
|
||||||
+string body
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageListRequest {
|
|
||||||
+SourceSelector sources
|
|
||||||
+ListRange range
|
|
||||||
+string[] flags
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageListResponse {
|
|
||||||
+MessageInterface[identifier] messages
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageFetchRequest {
|
|
||||||
+string provider
|
|
||||||
+string service
|
|
||||||
+string collection
|
|
||||||
+string identifier
|
|
||||||
}
|
|
||||||
|
|
||||||
class MessageFetchResponse {
|
|
||||||
<<extends MessageInterface>>
|
|
||||||
}
|
|
||||||
|
|
||||||
%% Relationships
|
|
||||||
ProviderInterface --> ProviderCapabilitiesInterface
|
|
||||||
ProviderFetchResponse --|> ProviderInterface
|
|
||||||
ProviderListResponse --> ProviderInterface
|
|
||||||
|
|
||||||
ServiceInterface --> ServiceCapabilitiesInterface
|
|
||||||
ServiceFetchResponse --|> ServiceInterface
|
|
||||||
ServiceCreateResponse --|> ServiceInterface
|
|
||||||
ServiceModifyResponse --|> ServiceInterface
|
|
||||||
ServiceListResponse --> ServiceInterface
|
|
||||||
|
|
||||||
CollectionInterface --> CollectionCapabilitiesInterface
|
|
||||||
CollectionFetchResponse --|> CollectionInterface
|
|
||||||
CollectionListResponse --> CollectionInterface
|
|
||||||
|
|
||||||
MessageInterface --> MessageHeadersInterface
|
|
||||||
MessageInterface --> MessagePartInterface
|
|
||||||
MessagePartInterface --> MessagePartInterface : subParts
|
|
||||||
MessageFetchResponse --|> MessageInterface
|
|
||||||
MessageListResponse --> MessageInterface
|
|
||||||
|
|
||||||
%% Selector Usage
|
|
||||||
ProviderListRequest --> SourceSelector
|
|
||||||
ProviderExtantRequest --> SourceSelector
|
|
||||||
ServiceListRequest --> SourceSelector
|
|
||||||
ServiceExtantRequest --> SourceSelector
|
|
||||||
CollectionListRequest --> SourceSelector
|
|
||||||
MessageListRequest --> SourceSelector
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hierarchical Structure
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A[SourceSelector] --> B[Provider Level]
|
|
||||||
B --> C[Service Level]
|
|
||||||
C --> D[Collection Level]
|
|
||||||
D --> E[Message Level]
|
|
||||||
|
|
||||||
B --> B1[ProviderInterface]
|
|
||||||
B --> B2[ProviderCapabilities]
|
|
||||||
|
|
||||||
C --> C1[ServiceInterface]
|
|
||||||
C --> C2[ServiceCapabilities]
|
|
||||||
|
|
||||||
D --> D1[CollectionInterface]
|
|
||||||
D --> D2[CollectionCapabilities]
|
|
||||||
|
|
||||||
E --> E1[MessageInterface]
|
|
||||||
E --> E2[MessageHeaders]
|
|
||||||
E --> E3[MessagePart]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Request/Response Pattern
|
|
||||||
|
|
||||||
All operations follow a consistent request/response pattern:
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
sequenceDiagram
|
|
||||||
participant Client
|
|
||||||
participant API
|
|
||||||
participant Provider
|
|
||||||
|
|
||||||
Client->>API: {Operation}Request
|
|
||||||
API->>Provider: Process Request
|
|
||||||
Provider->>API: Data
|
|
||||||
API->>Client: {Operation}Response
|
|
||||||
```
|
|
||||||
|
|
||||||
### Operations by Level:
|
|
||||||
|
|
||||||
**Provider Level:**
|
|
||||||
- List, Fetch, Extant
|
|
||||||
|
|
||||||
**Service Level:**
|
|
||||||
- List, Fetch, Extant, Create, Modify, Destroy
|
|
||||||
|
|
||||||
**Collection Level:**
|
|
||||||
- List, Fetch, Extant, Create, Modify, Destroy
|
|
||||||
|
|
||||||
**Message Level:**
|
|
||||||
- List, Fetch, Extant, Create, Modify, Destroy
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Capability Inheritance
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph LR
|
|
||||||
A[ProviderCapabilities] -->|enables| B[ServiceCapabilities]
|
|
||||||
B -->|enables| C[CollectionCapabilities]
|
|
||||||
C -->|enables| D[Message Operations]
|
|
||||||
```
|
|
||||||
|
|
||||||
Capabilities cascade down the hierarchy - if a provider doesn't support `ServiceList`, then no services can be listed for that provider.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
### 1. **Extends Pattern**
|
|
||||||
Response interfaces extend their base interface:
|
|
||||||
- `ProviderFetchResponse extends ProviderInterface`
|
|
||||||
- `ServiceFetchResponse extends ServiceInterface`
|
|
||||||
|
|
||||||
### 2. **Dictionary Pattern**
|
|
||||||
List responses use identifier as key:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
[identifier: string]: Interface
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **SourceSelector Pattern**
|
|
||||||
Resources are selected hierarchically:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
provider: "gmail",
|
|
||||||
service: "user@example.com",
|
|
||||||
collection: "INBOX",
|
|
||||||
message: "msg123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. **Recursive Structure**
|
|
||||||
MessagePart can contain subParts:
|
|
||||||
```typescript
|
|
||||||
MessagePartInterface {
|
|
||||||
subParts?: MessagePartInterface[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Examples
|
|
||||||
|
|
||||||
### Selecting a specific message:
|
|
||||||
```typescript
|
|
||||||
const selector: SourceSelector = {
|
|
||||||
provider: "gmail",
|
|
||||||
service: "user@example.com",
|
|
||||||
collection: "INBOX",
|
|
||||||
message: "12345"
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Listing all services for a provider:
|
|
||||||
```typescript
|
|
||||||
const request: ServiceListRequest = {
|
|
||||||
sources: {
|
|
||||||
provider: "gmail"
|
|
||||||
},
|
|
||||||
range: {
|
|
||||||
offset: 0,
|
|
||||||
limit: 50
|
|
||||||
}
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Interface Files
|
|
||||||
|
|
||||||
- `common.ts` - Base types and selectors
|
|
||||||
- `provider.ts` - Provider-level interfaces
|
|
||||||
- `service.ts` - Service-level interfaces
|
|
||||||
- `collection.ts` - Collection-level interfaces
|
|
||||||
- `message.ts` - Message-level interfaces
|
|
||||||
@@ -11,11 +11,17 @@ namespace KTXM\MailManager\Controllers;
|
|||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use KTXC\Http\Response\JsonResponse;
|
use KTXC\Http\Response\JsonResponse;
|
||||||
|
use KTXC\Http\Response\Response;
|
||||||
|
use KTXC\Http\Response\StreamedNdJsonResponse;
|
||||||
use KTXC\SessionIdentity;
|
use KTXC\SessionIdentity;
|
||||||
use KTXC\SessionTenant;
|
use KTXC\SessionTenant;
|
||||||
use KTXF\Controller\ControllerAbstract;
|
use KTXF\Controller\ControllerAbstract;
|
||||||
use KTXF\Mail\Entity\Message;
|
use KTXF\Json\JsonSerializable;
|
||||||
use KTXF\Mail\Queue\SendOptions;
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||||
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Selector\SourceSelector;
|
use KTXF\Resource\Selector\SourceSelector;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
use KTXM\MailManager\Manager;
|
use KTXM\MailManager\Manager;
|
||||||
@@ -30,23 +36,25 @@ use Throwable;
|
|||||||
*/
|
*/
|
||||||
class DefaultController extends ControllerAbstract {
|
class DefaultController extends ControllerAbstract {
|
||||||
|
|
||||||
// Error message constants
|
|
||||||
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
|
private const ERR_MISSING_PROVIDER = 'Missing parameter: provider';
|
||||||
private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier';
|
private const ERR_MISSING_IDENTIFIER = 'Missing parameter: identifier';
|
||||||
private const ERR_MISSING_SERVICE = 'Missing parameter: service';
|
private const ERR_MISSING_SERVICE = 'Missing parameter: service';
|
||||||
private const ERR_MISSING_COLLECTION = 'Missing parameter: collection';
|
private const ERR_MISSING_COLLECTION = 'Missing parameter: collection';
|
||||||
private const ERR_MISSING_DATA = 'Missing parameter: data';
|
private const ERR_MISSING_DATA = 'Missing parameter: data';
|
||||||
private const ERR_MISSING_SOURCES = 'Missing parameter: sources';
|
private const ERR_MISSING_SOURCES = 'Missing parameter: sources';
|
||||||
|
private const ERR_MISSING_TARGET = 'Missing parameter: target';
|
||||||
private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers';
|
private const ERR_MISSING_IDENTIFIERS = 'Missing parameter: identifiers';
|
||||||
private const ERR_MISSING_MESSAGE = 'Missing parameter: message';
|
private const ERR_INVALID_OPERATION = 'Invalid operation: ';
|
||||||
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string';
|
private const ERR_INVALID_PROVIDER = 'Invalid parameter: provider must be a string';
|
||||||
private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string';
|
private const ERR_INVALID_SERVICE = 'Invalid parameter: service must be a string';
|
||||||
private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string';
|
private const ERR_INVALID_IDENTIFIER = 'Invalid parameter: identifier must be a string';
|
||||||
private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer';
|
private const ERR_INVALID_COLLECTION = 'Invalid parameter: collection must be a string or integer';
|
||||||
private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array';
|
private const ERR_INVALID_SOURCES = 'Invalid parameter: sources must be an array';
|
||||||
|
private const ERR_INVALID_TARGET = 'Invalid parameter: target must be an array';
|
||||||
private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array';
|
private const ERR_INVALID_IDENTIFIERS = 'Invalid parameter: identifiers must be an array';
|
||||||
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
private const ERR_INVALID_DATA = 'Invalid parameter: data must be an array';
|
||||||
private const ERR_INVALID_MESSAGE = 'Invalid parameter: message must be an array';
|
|
||||||
|
private const STREAM_FLUSH_INTERVAL = 1;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly SessionTenant $tenantIdentity,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
@@ -59,15 +67,14 @@ class DefaultController extends ControllerAbstract {
|
|||||||
* Main API endpoint for mail operations
|
* Main API endpoint for mail operations
|
||||||
*
|
*
|
||||||
* Single operation:
|
* Single operation:
|
||||||
* { "version": 1, "transaction": "tx-1", "operation": "message.send", "data": {...} }
|
* {
|
||||||
|
* "version": 1,
|
||||||
|
* "transaction": "tx-1",
|
||||||
|
* "operation": "entity.create",
|
||||||
|
* "data": {...}
|
||||||
|
* }
|
||||||
*
|
*
|
||||||
* Batch operations:
|
* @return Response
|
||||||
* { "version": 1, "transaction": "tx-1", "operations": [
|
|
||||||
* {"id": "op1", "operation": "message.send", "data": {...}},
|
|
||||||
* {"id": "op2", "operation": "message.destroy", "data": {"collection": "#op1.draftId"}}
|
|
||||||
* ]}
|
|
||||||
*
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
#[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])]
|
#[AuthenticatedRoute('/v1', name: 'mail.manager.v1', methods: ['POST'])]
|
||||||
public function index(
|
public function index(
|
||||||
@@ -75,18 +82,22 @@ class DefaultController extends ControllerAbstract {
|
|||||||
string $transaction,
|
string $transaction,
|
||||||
string|null $operation = null,
|
string|null $operation = null,
|
||||||
array|null $data = null,
|
array|null $data = null,
|
||||||
array|null $operations = null,
|
|
||||||
string|null $user = null
|
string|null $user = null
|
||||||
): JsonResponse {
|
): Response {
|
||||||
|
|
||||||
// authorize request
|
// authorize request
|
||||||
$tenantId = $this->tenantIdentity->identifier();
|
$tenantId = $this->tenantIdentity->identifier();
|
||||||
$userId = $this->userIdentity->identifier();
|
$userId = $this->userIdentity->identifier();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Single operation mode
|
|
||||||
if ($operation !== null) {
|
if ($operation !== null) {
|
||||||
$result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], []);
|
$result = $this->processOperation($tenantId, $userId, $operation, $data ?? [], $version, $transaction);
|
||||||
|
|
||||||
|
if ($result instanceof Response) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'transaction' => $transaction,
|
'transaction' => $transaction,
|
||||||
@@ -96,21 +107,10 @@ class DefaultController extends ControllerAbstract {
|
|||||||
], JsonResponse::HTTP_OK);
|
], JsonResponse::HTTP_OK);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch operations mode
|
throw new InvalidArgumentException('Operation must be provided');
|
||||||
if ($operations !== null && is_array($operations)) {
|
|
||||||
$results = $this->processBatch($tenantId, $userId, $operations);
|
|
||||||
return new JsonResponse([
|
|
||||||
'version' => $version,
|
|
||||||
'transaction' => $transaction,
|
|
||||||
'status' => 'success',
|
|
||||||
'operations' => $results
|
|
||||||
], JsonResponse::HTTP_OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new InvalidArgumentException('Either operation or operations must be provided');
|
|
||||||
|
|
||||||
} catch (Throwable $t) {
|
} catch (Throwable $t) {
|
||||||
$this->logger->error('Error processing mail manager request', ['exception' => $t]);
|
$this->logger->error('Error processing request', ['exception' => $t]);
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'transaction' => $transaction,
|
'transaction' => $transaction,
|
||||||
@@ -124,105 +124,10 @@ class DefaultController extends ControllerAbstract {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Process batch operations with result references
|
|
||||||
*/
|
|
||||||
private function processBatch(string $tenantId, string $userId, array $operations): array {
|
|
||||||
$results = [];
|
|
||||||
$resultMap = []; // Store results by operation ID for references
|
|
||||||
|
|
||||||
foreach ($operations as $index => $op) {
|
|
||||||
$opId = $op['id'] ?? "op{$index}";
|
|
||||||
$operation = $op['operation'] ?? null;
|
|
||||||
$data = $op['data'] ?? [];
|
|
||||||
|
|
||||||
if ($operation === null) {
|
|
||||||
$results[] = [
|
|
||||||
'id' => $opId,
|
|
||||||
'status' => 'error',
|
|
||||||
'data' => ['message' => 'Missing operation name']
|
|
||||||
];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Resolve result references in data (e.g., "#op1.id")
|
|
||||||
$data = $this->resolveReferences($data, $resultMap);
|
|
||||||
|
|
||||||
$result = $this->processOperation($tenantId, $userId, $operation, $data, $resultMap);
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'id' => $opId,
|
|
||||||
'operation' => $operation,
|
|
||||||
'status' => 'success',
|
|
||||||
'data' => $result
|
|
||||||
];
|
|
||||||
|
|
||||||
// Store result for future references
|
|
||||||
$resultMap[$opId] = $result;
|
|
||||||
|
|
||||||
} catch (Throwable $t) {
|
|
||||||
$this->logger->warning('Batch operation failed', [
|
|
||||||
'operation' => $operation,
|
|
||||||
'opId' => $opId,
|
|
||||||
'error' => $t->getMessage()
|
|
||||||
]);
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'id' => $opId,
|
|
||||||
'operation' => $operation,
|
|
||||||
'status' => 'error',
|
|
||||||
'data' => [
|
|
||||||
'code' => $t->getCode(),
|
|
||||||
'message' => $t->getMessage()
|
|
||||||
]
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve result references in operation data
|
|
||||||
*
|
|
||||||
* Transforms "#op1.id" into the actual value from previous operation results
|
|
||||||
*/
|
|
||||||
private function resolveReferences(mixed $data, array $resultMap): mixed {
|
|
||||||
if (is_string($data) && str_starts_with($data, '#')) {
|
|
||||||
// Parse reference like "#op1.id" or "#op1.collection.id"
|
|
||||||
$parts = explode('.', substr($data, 1));
|
|
||||||
$opId = array_shift($parts);
|
|
||||||
|
|
||||||
if (!isset($resultMap[$opId])) {
|
|
||||||
throw new InvalidArgumentException("Reference to undefined operation: #{$opId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = $resultMap[$opId];
|
|
||||||
foreach ($parts as $key) {
|
|
||||||
if (is_array($value) && isset($value[$key])) {
|
|
||||||
$value = $value[$key];
|
|
||||||
} elseif (is_object($value) && isset($value->$key)) {
|
|
||||||
$value = $value->$key;
|
|
||||||
} else {
|
|
||||||
throw new InvalidArgumentException("Invalid reference path: {$data}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($data)) {
|
|
||||||
return array_map(fn($item) => $this->resolveReferences($item, $resultMap), $data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process a single operation
|
* Process a single operation
|
||||||
*/
|
*/
|
||||||
private function processOperation(string $tenantId, string $userId, string $operation, array $data, array $resultMap): mixed {
|
private function processOperation(string $tenantId, string $userId, string $operation, array $data, int $version = 1, string $transaction = ''): mixed {
|
||||||
return match ($operation) {
|
return match ($operation) {
|
||||||
// Provider operations
|
// Provider operations
|
||||||
'provider.list' => $this->providerList($tenantId, $userId, $data),
|
'provider.list' => $this->providerList($tenantId, $userId, $data),
|
||||||
@@ -236,7 +141,7 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'service.create' => $this->serviceCreate($tenantId, $userId, $data),
|
'service.create' => $this->serviceCreate($tenantId, $userId, $data),
|
||||||
'service.update' => $this->serviceUpdate($tenantId, $userId, $data),
|
'service.update' => $this->serviceUpdate($tenantId, $userId, $data),
|
||||||
'service.delete' => $this->serviceDelete($tenantId, $userId, $data),
|
'service.delete' => $this->serviceDelete($tenantId, $userId, $data),
|
||||||
'service.discover' => $this->serviceDiscover($tenantId, $userId, $data),
|
'service.discover' => $this->serviceDiscover($tenantId, $userId, $data, $version, $transaction),
|
||||||
'service.test' => $this->serviceTest($tenantId, $userId, $data),
|
'service.test' => $this->serviceTest($tenantId, $userId, $data),
|
||||||
|
|
||||||
// Collection operations
|
// Collection operations
|
||||||
@@ -256,12 +161,13 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.create' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.update' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.delete' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
|
'entity.stream' => $this->entityStream($tenantId, $userId, $data, $version, $transaction),
|
||||||
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
|
'entity.delta' => $this->entityDelta($tenantId, $userId, $data),
|
||||||
'entity.move' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.move' => $this->entityMove($tenantId, $userId, $data),
|
||||||
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
'entity.copy' => throw new InvalidArgumentException('Operation not implemented: ' . $operation),
|
||||||
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
|
'entity.transmit' => $this->entityTransmit($tenantId, $userId, $data),
|
||||||
|
|
||||||
default => throw new InvalidArgumentException('Unknown operation: ' . $operation)
|
default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +185,18 @@ class DefaultController extends ControllerAbstract {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['identifier'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->providerFetch($tenantId, $userId, $data['identifier']);
|
||||||
|
}
|
||||||
|
|
||||||
private function providerExtant(string $tenantId, string $userId, array $data): mixed {
|
private function providerExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
if (!isset($data['sources'])) {
|
if (!isset($data['sources'])) {
|
||||||
@@ -294,18 +212,6 @@ class DefaultController extends ControllerAbstract {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function providerFetch(string $tenantId, string $userId, array $data): mixed {
|
|
||||||
|
|
||||||
if (!isset($data['identifier'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_IDENTIFIER);
|
|
||||||
}
|
|
||||||
if (!is_string($data['identifier'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_IDENTIFIER);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->mailManager->providerFetch($tenantId, $userId, $data['identifier']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Service Operations =====================
|
// ==================== Service Operations =====================
|
||||||
|
|
||||||
private function serviceList(string $tenantId, string $userId, array $data): mixed {
|
private function serviceList(string $tenantId, string $userId, array $data): mixed {
|
||||||
@@ -320,20 +226,6 @@ class DefaultController extends ControllerAbstract {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serviceExtant(string $tenantId, string $userId, array $data): mixed {
|
|
||||||
|
|
||||||
if (!isset($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
|
||||||
}
|
|
||||||
if (!is_array($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
|
||||||
}
|
|
||||||
$sources = new SourceSelector();
|
|
||||||
$sources->jsonDeserialize($data['sources']);
|
|
||||||
|
|
||||||
return $this->mailManager->serviceExtant($tenantId, $userId, $sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serviceFetch(string $tenantId, string $userId, array $data): mixed {
|
private function serviceFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
if (!isset($data['provider'])) {
|
if (!isset($data['provider'])) {
|
||||||
@@ -352,41 +244,18 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
|
return $this->mailManager->serviceFetch($tenantId, $userId, $data['provider'], $data['identifier']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serviceDiscover(string $tenantId, string $userId, array $data): mixed {
|
private function serviceExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
|
if (!isset($data['sources'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
}
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
$provider = $data['provider'] ?? null;
|
return $this->mailManager->serviceExtant($tenantId, $userId, $sources);
|
||||||
$identity = $data['identity'];
|
|
||||||
$location = $data['location'] ?? null;
|
|
||||||
$secret = $data['secret'] ?? null;
|
|
||||||
|
|
||||||
return $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serviceTest(string $tenantId, string $userId, array $data): mixed {
|
|
||||||
|
|
||||||
if (!isset($data['provider'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
|
||||||
}
|
|
||||||
if (!is_string($data['provider'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) {
|
|
||||||
throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->mailManager->serviceTest(
|
|
||||||
$tenantId,
|
|
||||||
$userId,
|
|
||||||
$data['provider'],
|
|
||||||
$data['identifier'] ?? null,
|
|
||||||
$data['location'] ?? null,
|
|
||||||
$data['identity'] ?? null,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
|
private function serviceCreate(string $tenantId, string $userId, array $data): mixed {
|
||||||
@@ -462,6 +331,73 @@ class DefaultController extends ControllerAbstract {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function serviceTest(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
|
||||||
|
if (!isset($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
|
}
|
||||||
|
if (!is_string($data['provider'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_PROVIDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($data['identifier']) && !isset($data['location']) && !isset($data['identity'])) {
|
||||||
|
throw new InvalidArgumentException('Either a service identifier or location and identity must be provided for service test');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->serviceTest(
|
||||||
|
$tenantId,
|
||||||
|
$userId,
|
||||||
|
$data['provider'],
|
||||||
|
$data['identifier'] ?? null,
|
||||||
|
$data['location'] ?? null,
|
||||||
|
$data['identity'] ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serviceDiscover(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
|
||||||
|
|
||||||
|
if (!isset($data['identity']) || empty($data['identity']) || !is_string($data['identity'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
$provider = $data['provider'] ?? null;
|
||||||
|
$identity = $data['identity'];
|
||||||
|
$location = $data['location'] ?? null;
|
||||||
|
$secret = $data['secret'] ?? null;
|
||||||
|
|
||||||
|
$discoverGenerator = $this->mailManager->serviceDiscover($tenantId, $userId, $provider, $identity, $location, $secret);
|
||||||
|
$logger = $this->logger;
|
||||||
|
|
||||||
|
$response = (function () use ($discoverGenerator, $version, $transaction, $logger): \Generator {
|
||||||
|
yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
try {
|
||||||
|
foreach ($discoverGenerator as $providerId => $serviceLocation) {
|
||||||
|
if (!$serviceLocation instanceof ResourceServiceLocationInterface) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield [
|
||||||
|
'type' => 'data',
|
||||||
|
'data' => [
|
||||||
|
'provider' => $providerId,
|
||||||
|
'location' => $serviceLocation->jsonSerialize()
|
||||||
|
]
|
||||||
|
];
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $t) {
|
||||||
|
$logger->error('Error streaming service discovery', ['exception' => $t]);
|
||||||
|
yield ['type' => 'error', 'message' => $t->getMessage()];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new StreamedNdJsonResponse($response, 1, 200, ['Content-Type' => 'application/json']);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Collection Operations ====================
|
// ==================== Collection Operations ====================
|
||||||
|
|
||||||
private function collectionList(string $tenantId, string $userId, array $data): mixed {
|
private function collectionList(string $tenantId, string $userId, array $data): mixed {
|
||||||
@@ -640,34 +576,6 @@ class DefaultController extends ControllerAbstract {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function entityDelta(string $tenantId, string $userId, array $data): mixed {
|
|
||||||
if (!isset($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
|
||||||
}
|
|
||||||
if (!is_array($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sources = new SourceSelector();
|
|
||||||
$sources->jsonDeserialize($data['sources']);
|
|
||||||
|
|
||||||
return $this->mailManager->entityDelta($tenantId, $userId, $sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function entityExtant(string $tenantId, string $userId, array $data): mixed {
|
|
||||||
if (!isset($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
|
||||||
}
|
|
||||||
if (!is_array($data['sources'])) {
|
|
||||||
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
|
||||||
}
|
|
||||||
|
|
||||||
$sources = new SourceSelector();
|
|
||||||
$sources->jsonDeserialize($data['sources']);
|
|
||||||
|
|
||||||
return $this->mailManager->entityExtant($tenantId, $userId, $sources);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function entityFetch(string $tenantId, string $userId, array $data): mixed {
|
private function entityFetch(string $tenantId, string $userId, array $data): mixed {
|
||||||
if (!isset($data['provider'])) {
|
if (!isset($data['provider'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
@@ -704,6 +612,63 @@ class DefaultController extends ControllerAbstract {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function entityExtant(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->entityExtant($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityDelta(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
return $this->mailManager->entityDelta($tenantId, $userId, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entityMove(string $tenantId, string $userId, array $data): mixed {
|
||||||
|
if (!isset($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_TARGET);
|
||||||
|
}
|
||||||
|
if (!is_string($data['target'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_TARGET);
|
||||||
|
}
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = ResourceIdentifier::fromString($data['target']);
|
||||||
|
if (!$target instanceof CollectionIdentifier) {
|
||||||
|
throw new InvalidArgumentException('Invalid parameter: target must be provider:service:collection');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = ResourceIdentifiers::fromArray($data['sources']);
|
||||||
|
foreach ($sources as $source) {
|
||||||
|
if (!$source instanceof EntityIdentifier) {
|
||||||
|
throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection:entity identifiers');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->mailManager->entityMove($tenantId, $userId, $target, $sources);
|
||||||
|
}
|
||||||
|
|
||||||
private function entityTransmit(string $tenantId, string $userId, array $data): mixed {
|
private function entityTransmit(string $tenantId, string $userId, array $data): mixed {
|
||||||
if (!isset($data['provider'])) {
|
if (!isset($data['provider'])) {
|
||||||
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
throw new InvalidArgumentException(self::ERR_MISSING_PROVIDER);
|
||||||
@@ -729,4 +694,49 @@ class DefaultController extends ControllerAbstract {
|
|||||||
return ['jobId' => $jobId];
|
return ['jobId' => $jobId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function entityStream(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
|
||||||
|
if (!isset($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_MISSING_SOURCES);
|
||||||
|
}
|
||||||
|
if (!is_array($data['sources'])) {
|
||||||
|
throw new InvalidArgumentException(self::ERR_INVALID_SOURCES);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sources = new SourceSelector();
|
||||||
|
$sources->jsonDeserialize($data['sources']);
|
||||||
|
|
||||||
|
$filter = $data['filter'] ?? null;
|
||||||
|
$sort = $data['sort'] ?? null;
|
||||||
|
$range = $data['range'] ?? null;
|
||||||
|
|
||||||
|
$entityGenerator = $this->mailManager->entityStream($tenantId, $userId, $sources, $filter, $sort, $range);
|
||||||
|
$logger = $this->logger;
|
||||||
|
|
||||||
|
$responseGenerator = (function () use ($entityGenerator, $version, $transaction, $logger): \Generator {
|
||||||
|
yield ['type' => 'control', 'status' => 'start', 'version' => $version, 'transaction' => $transaction];
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
try {
|
||||||
|
foreach ($entityGenerator as $entity) {
|
||||||
|
if (!$entity instanceof JsonSerializable) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield [
|
||||||
|
'type' => 'data',
|
||||||
|
'data' => $entity->jsonSerialize()
|
||||||
|
];
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $t) {
|
||||||
|
$logger->error('Error streaming entities', ['exception' => $t]);
|
||||||
|
yield ['type' => 'error', 'message' => $t->getMessage()];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield ['type' => 'control', 'status' => 'end', 'total' => $total];
|
||||||
|
})();
|
||||||
|
|
||||||
|
return new StreamedNdJsonResponse($responseGenerator, 1, 200, ['Content-Type' => 'application/json']);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
338
lib/Manager.php
338
lib/Manager.php
@@ -21,7 +21,12 @@ use KTXF\Mail\Queue\SendOptions;
|
|||||||
use KTXF\Mail\Service\IServiceSend;
|
use KTXF\Mail\Service\IServiceSend;
|
||||||
use KTXF\Mail\Service\ServiceBaseInterface;
|
use KTXF\Mail\Service\ServiceBaseInterface;
|
||||||
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
use KTXF\Mail\Service\ServiceCollectionMutableInterface;
|
||||||
|
use KTXF\Mail\Service\ServiceEntityMutableInterface;
|
||||||
use KTXF\Resource\Filter\IFilter;
|
use KTXF\Resource\Filter\IFilter;
|
||||||
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\EntityIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||||
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||||
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
use KTXF\Resource\Provider\ResourceServiceIdentityInterface;
|
||||||
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
use KTXF\Resource\Provider\ResourceServiceLocationInterface;
|
||||||
use KTXF\Resource\Range\IRange;
|
use KTXF\Resource\Range\IRange;
|
||||||
@@ -38,8 +43,7 @@ use Psr\Log\LoggerInterface;
|
|||||||
/**
|
/**
|
||||||
* Mail Manager
|
* Mail Manager
|
||||||
*
|
*
|
||||||
* Provides unified mail sending across multiple providers with context-aware
|
* Provides unified mail sending across multiple providers
|
||||||
* service discovery and queued delivery support.
|
|
||||||
*/
|
*/
|
||||||
class Manager {
|
class Manager {
|
||||||
|
|
||||||
@@ -63,6 +67,25 @@ class Manager {
|
|||||||
return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter);
|
return $this->providerManager->providers(ProviderBaseInterface::TYPE_MAIL, $filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve specific provider for specific user
|
||||||
|
*
|
||||||
|
* @param string $tenantId tenant identifier
|
||||||
|
* @param string $userId user identifier
|
||||||
|
* @param string $provider provider identifier
|
||||||
|
*
|
||||||
|
* @return ProviderBaseInterface
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface {
|
||||||
|
// retrieve provider
|
||||||
|
$providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true]));
|
||||||
|
if (!isset($providers[$provider])) {
|
||||||
|
throw new InvalidArgumentException("Provider '$provider' not found");
|
||||||
|
}
|
||||||
|
return $providers[$provider];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm which providers are available
|
* Confirm which providers are available
|
||||||
*
|
*
|
||||||
@@ -83,25 +106,6 @@ class Manager {
|
|||||||
return $responseData;
|
return $responseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve specific provider for specific user
|
|
||||||
*
|
|
||||||
* @param string $tenantId tenant identifier
|
|
||||||
* @param string $userId user identifier
|
|
||||||
* @param string $provider provider identifier
|
|
||||||
*
|
|
||||||
* @return ProviderBaseInterface
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
public function providerFetch(string $tenantId, string $userId, string $provider): ProviderBaseInterface {
|
|
||||||
// retrieve provider
|
|
||||||
$providers = $this->providerList($tenantId, $userId, new SourceSelector([$provider => true]));
|
|
||||||
if (!isset($providers[$provider])) {
|
|
||||||
throw new InvalidArgumentException("Provider '$provider' not found");
|
|
||||||
}
|
|
||||||
return $providers[$provider];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve available services for specific user
|
* Retrieve available services for specific user
|
||||||
*
|
*
|
||||||
@@ -124,6 +128,27 @@ class Manager {
|
|||||||
return $responseData;
|
return $responseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve service for specific user
|
||||||
|
*
|
||||||
|
* @param string $tenantId tenant identifier
|
||||||
|
* @param string $userId user identifier
|
||||||
|
* @param string $providerId provider identifier
|
||||||
|
* @param string|int $serviceId service identifier
|
||||||
|
*
|
||||||
|
* @return ServiceBaseInterface
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface {
|
||||||
|
// retrieve provider and service
|
||||||
|
$service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId);
|
||||||
|
if ($service === null) {
|
||||||
|
throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'");
|
||||||
|
}
|
||||||
|
// retrieve services
|
||||||
|
return $service;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Confirm which services are available
|
* Confirm which services are available
|
||||||
*
|
*
|
||||||
@@ -151,27 +176,6 @@ class Manager {
|
|||||||
return $responseData;
|
return $responseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Retrieve service for specific user
|
|
||||||
*
|
|
||||||
* @param string $tenantId tenant identifier
|
|
||||||
* @param string $userId user identifier
|
|
||||||
* @param string $providerId provider identifier
|
|
||||||
* @param string|int $serviceId service identifier
|
|
||||||
*
|
|
||||||
* @return ServiceBaseInterface
|
|
||||||
* @throws InvalidArgumentException
|
|
||||||
*/
|
|
||||||
public function serviceFetch(string $tenantId, string $userId, string $providerId, string|int $serviceId): ServiceBaseInterface {
|
|
||||||
// retrieve provider and service
|
|
||||||
$service = $this->providerFetch($tenantId, $userId, $providerId)->serviceFetch($tenantId, $userId, $serviceId);
|
|
||||||
if ($service === null) {
|
|
||||||
throw new InvalidArgumentException("Service '$serviceId' not found for provider '$providerId'");
|
|
||||||
}
|
|
||||||
// retrieve services
|
|
||||||
return $service;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a service that handles a specific email address
|
* Find a service that handles a specific email address
|
||||||
*
|
*
|
||||||
@@ -304,7 +308,7 @@ class Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discover mail service settings from identity
|
* Discover mail service settings from identity, yielding results as each provider completes
|
||||||
*
|
*
|
||||||
* @since 2025.05.01
|
* @since 2025.05.01
|
||||||
*
|
*
|
||||||
@@ -315,12 +319,7 @@ class Manager {
|
|||||||
* @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup)
|
* @param string|null $location Optional hostname to test directly (bypasses DNS SRV lookup)
|
||||||
* @param string|null $secret Optional password/token to validate discovered service
|
* @param string|null $secret Optional password/token to validate discovered service
|
||||||
*
|
*
|
||||||
* @return array<string,ResourceServiceLocationInterface> Array of discovered service locations keyed by provider ID
|
* @return \Generator Yields providerId => ResourceServiceLocationInterface pairs as each provider completes
|
||||||
* [
|
|
||||||
* 'jmap' => ResourceServiceLocationInterface,
|
|
||||||
* 'smtp' => ResourceServiceLocationInterface,
|
|
||||||
* // Only providers that successfully discovered (non-null)
|
|
||||||
* ]
|
|
||||||
*/
|
*/
|
||||||
public function serviceDiscover(
|
public function serviceDiscover(
|
||||||
string $tenantId,
|
string $tenantId,
|
||||||
@@ -329,32 +328,28 @@ class Manager {
|
|||||||
string $identity,
|
string $identity,
|
||||||
string|null $location = null,
|
string|null $location = null,
|
||||||
string|null $secret = null
|
string|null $secret = null
|
||||||
): array {
|
): \Generator {
|
||||||
$locations = [];
|
|
||||||
|
|
||||||
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null);
|
$providers = $this->providerList($tenantId, $userId, $providerId !== null ? new SourceSelector([$providerId => true]) : null);
|
||||||
|
|
||||||
foreach ($providers as $providerId => $provider) {
|
foreach ($providers as $currentProviderId => $provider) {
|
||||||
if (!($provider instanceof ProviderServiceDiscoverInterface)) {
|
if (!($provider instanceof ProviderServiceDiscoverInterface)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$location = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret);
|
$result = $provider->serviceDiscover($tenantId, $userId, $identity, $location, $secret);
|
||||||
|
|
||||||
if ($location !== null) {
|
if ($result !== null) {
|
||||||
$locations[$providerId] = $location;
|
yield $currentProviderId => $result;
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$this->logger->warning('Provider autodiscovery failed', [
|
$this->logger->warning('Provider autodiscovery failed', [
|
||||||
'provider' => $providerId,
|
'provider' => $currentProviderId,
|
||||||
'identity' => $identity,
|
'identity' => $identity,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $locations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -766,52 +761,101 @@ class Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get message delta/changes
|
* Stream entities
|
||||||
|
*
|
||||||
|
* @since 2026.02.01
|
||||||
|
*
|
||||||
|
* @param string $tenantId Tenant identifier
|
||||||
|
* @param string $userId User identifier
|
||||||
|
* @param SourceSelector $sources Message sources with collection identifiers
|
||||||
|
* @param array|null $filter Message filter
|
||||||
|
* @param array|null $sort Message sort
|
||||||
|
* @param array|null $range Message range/pagination
|
||||||
|
*
|
||||||
|
* @return \Generator<EntityBaseInterface> Yields each entity as it is retrieved
|
||||||
|
*/
|
||||||
|
public function entityStream(string $tenantId, string $userId, SourceSelector $sources, array|null $filter = null, array|null $sort = null, array|null $range = null): \Generator {
|
||||||
|
// retrieve providers
|
||||||
|
$providers = $this->providerList($tenantId, $userId, $sources);
|
||||||
|
// retrieve services for each provider
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$serviceSelector = $sources[$provider->identifier()];
|
||||||
|
$servicesSelected = $provider->serviceList($tenantId, $userId, $serviceSelector->identifiers());
|
||||||
|
/** @var ServiceBaseInterface $service */
|
||||||
|
foreach ($servicesSelected as $service) {
|
||||||
|
// retrieve collections for each service
|
||||||
|
$collectionSelector = $serviceSelector[$service->identifier()];
|
||||||
|
$collectionSelected = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
|
||||||
|
if ($collectionSelected === []) {
|
||||||
|
$collections = $service->collectionList('');
|
||||||
|
$collectionSelected = array_map(
|
||||||
|
fn($collection) => $collection->identifier(),
|
||||||
|
$collections
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($collectionSelected === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// construct filter for entities
|
||||||
|
$entityFilter = null;
|
||||||
|
if ($filter !== null && $filter !== []) {
|
||||||
|
$entityFilter = $service->entityListFilter();
|
||||||
|
foreach ($filter as $attribute => $value) {
|
||||||
|
$entityFilter->condition($attribute, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// construct sort for entities
|
||||||
|
$entitySort = null;
|
||||||
|
if ($sort !== null && $sort !== []) {
|
||||||
|
$entitySort = $service->entityListSort();
|
||||||
|
foreach ($sort as $attribute => $direction) {
|
||||||
|
$entitySort->condition($attribute, $direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// construct range for entities
|
||||||
|
$entityRange = null;
|
||||||
|
if ($range !== null && $range !== [] && isset($range['type'])) {
|
||||||
|
$entityRange = $service->entityListRange(RangeType::from($range['type']));
|
||||||
|
if ($entityRange->type() === RangeType::TALLY) {
|
||||||
|
/** @var IRangeTally $entityRange */
|
||||||
|
if (isset($range['anchor'])) {
|
||||||
|
$entityRange->setAnchor(RangeAnchorType::from($range['anchor']));
|
||||||
|
}
|
||||||
|
if (isset($range['position'])) {
|
||||||
|
$entityRange->setPosition($range['position']);
|
||||||
|
}
|
||||||
|
if (isset($range['tally'])) {
|
||||||
|
$entityRange->setTally($range['tally']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// yield entities for each collection individually
|
||||||
|
foreach ($collectionSelected as $collectionId) {
|
||||||
|
yield from $service->entityListStream($collectionId, $entityFilter, $entitySort, $entityRange, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch specific messages
|
||||||
*
|
*
|
||||||
* @since 2025.05.01
|
* @since 2025.05.01
|
||||||
*
|
*
|
||||||
* @param string $tenantId Tenant identifier
|
* @param string $tenantId Tenant identifier
|
||||||
* @param string|null $userId User identifier for context
|
* @param string|null $userId User identifier for context
|
||||||
* @param SourceSelector $sources Message sources with signatures
|
* @param string $providerId Provider identifier
|
||||||
|
* @param string|int $serviceId Service identifier
|
||||||
|
* @param string|int $collectionId Collection identifier
|
||||||
|
* @param array<string|int> $identifiers Message identifiers
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|int, array<string|int, array>>> Delta grouped by provider/service/collection
|
* @return array<string|int, IMessageBase> Messages indexed by ID
|
||||||
*/
|
*/
|
||||||
public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array {
|
public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array {
|
||||||
// confirm that sources are provided
|
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
|
||||||
if ($sources === null) {
|
|
||||||
$sources = new SourceSelector([]);
|
// retrieve collection
|
||||||
}
|
return $service->entityFetch($collectionId, ...$identifiers);
|
||||||
// retrieve providers
|
|
||||||
$providers = $this->providerList($tenantId, $userId, $sources);
|
|
||||||
$providersRequested = $sources->identifiers();
|
|
||||||
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
|
|
||||||
// initialize response with unavailable providers
|
|
||||||
$responseData = array_fill_keys($providersUnavailable, false);
|
|
||||||
// iterate through available providers
|
|
||||||
foreach ($providers as $provider) {
|
|
||||||
$serviceSelector = $sources[$provider->identifier()];
|
|
||||||
$servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : [];
|
|
||||||
/** @var ServiceBaseInterface[] $services */
|
|
||||||
$services = $provider->serviceList($tenantId, $userId, $servicesRequested);
|
|
||||||
$servicesUnavailable = array_diff($servicesRequested, array_keys($services));
|
|
||||||
if ($servicesUnavailable !== []) {
|
|
||||||
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
|
|
||||||
}
|
|
||||||
// iterate through available services
|
|
||||||
foreach ($services as $service) {
|
|
||||||
$collectionSelector = $serviceSelector[$service->identifier()];
|
|
||||||
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
|
|
||||||
if ($collectionsRequested === []) {
|
|
||||||
$responseData[$provider->identifier()][$service->identifier()] = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
foreach ($collectionsRequested as $collection) {
|
|
||||||
$entitySelector = $collectionSelector[$collection] ?? null;
|
|
||||||
$responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $responseData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -889,24 +933,102 @@ class Manager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch specific messages
|
* Get message delta/changes
|
||||||
*
|
*
|
||||||
* @since 2025.05.01
|
* @since 2025.05.01
|
||||||
*
|
*
|
||||||
* @param string $tenantId Tenant identifier
|
* @param string $tenantId Tenant identifier
|
||||||
* @param string|null $userId User identifier for context
|
* @param string|null $userId User identifier for context
|
||||||
* @param string $providerId Provider identifier
|
* @param SourceSelector $sources Message sources with signatures
|
||||||
* @param string|int $serviceId Service identifier
|
|
||||||
* @param string|int $collectionId Collection identifier
|
|
||||||
* @param array<string|int> $identifiers Message identifiers
|
|
||||||
*
|
*
|
||||||
* @return array<string|int, IMessageBase> Messages indexed by ID
|
* @return array<string, array<string|int, array<string|int, array>>> Delta grouped by provider/service/collection
|
||||||
*/
|
*/
|
||||||
public function entityFetch(string $tenantId, ?string $userId, string $providerId, string|int $serviceId, string|int $collectionId, array $identifiers): array {
|
public function entityDelta(string $tenantId, string $userId, SourceSelector $sources): array {
|
||||||
$service = $this->serviceFetch($tenantId, $userId, $providerId, $serviceId);
|
// confirm that sources are provided
|
||||||
|
if ($sources === null) {
|
||||||
|
$sources = new SourceSelector([]);
|
||||||
|
}
|
||||||
|
// retrieve providers
|
||||||
|
$providers = $this->providerList($tenantId, $userId, $sources);
|
||||||
|
$providersRequested = $sources->identifiers();
|
||||||
|
$providersUnavailable = array_diff($providersRequested, array_keys($providers));
|
||||||
|
// initialize response with unavailable providers
|
||||||
|
$responseData = array_fill_keys($providersUnavailable, false);
|
||||||
|
// iterate through available providers
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$serviceSelector = $sources[$provider->identifier()];
|
||||||
|
$servicesRequested = $serviceSelector instanceof ServiceSelector ? $serviceSelector->identifiers() : [];
|
||||||
|
/** @var ServiceBaseInterface[] $services */
|
||||||
|
$services = $provider->serviceList($tenantId, $userId, $servicesRequested);
|
||||||
|
$servicesUnavailable = array_diff($servicesRequested, array_keys($services));
|
||||||
|
if ($servicesUnavailable !== []) {
|
||||||
|
$responseData[$provider->identifier()] = array_fill_keys($servicesUnavailable, false);
|
||||||
|
}
|
||||||
|
// iterate through available services
|
||||||
|
foreach ($services as $service) {
|
||||||
|
$collectionSelector = $serviceSelector[$service->identifier()];
|
||||||
|
$collectionsRequested = $collectionSelector instanceof CollectionSelector ? $collectionSelector->identifiers() : [];
|
||||||
|
if ($collectionsRequested === []) {
|
||||||
|
$responseData[$provider->identifier()][$service->identifier()] = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($collectionsRequested as $collection) {
|
||||||
|
$entitySelector = $collectionSelector[$collection] ?? null;
|
||||||
|
$responseData[$provider->identifier()][$service->identifier()][$collection] = $service->entityDelta($collection, $entitySelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $responseData;
|
||||||
|
}
|
||||||
|
|
||||||
// retrieve collection
|
public function entityMove(string $tenantId, string $userId, CollectionIdentifier $target, ResourceIdentifiers $sources): array {
|
||||||
return $service->entityFetch($collectionId, ...$identifiers);
|
|
||||||
|
$targetService = $this->serviceFetch($tenantId, $userId, $target->provider(), $target->service());
|
||||||
|
|
||||||
|
// Check if service supports entity move
|
||||||
|
if ($targetService instanceof ServiceEntityMutableInterface === false) {
|
||||||
|
//return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationOutcome = [];
|
||||||
|
|
||||||
|
$destinationSources = $sources->byProvider($targetService->provider())->byService((string)$targetService->identifier());
|
||||||
|
if (!$destinationSources->isEmpty()) {
|
||||||
|
$entitiesToMove = [];
|
||||||
|
foreach ($destinationSources as $identifier) {
|
||||||
|
$entitiesToMove[$identifier->collection()][] = $identifier->entity();
|
||||||
|
}
|
||||||
|
$operationResult = $targetService->entityMove($target->collection(), $entitiesToMove);
|
||||||
|
|
||||||
|
foreach ($destinationSources as $identifier) {
|
||||||
|
$sourceIdentifier = (string)$identifier;
|
||||||
|
$entityIdentifier = $identifier->entity();
|
||||||
|
$result = $operationResult[$entityIdentifier] ?? null;
|
||||||
|
|
||||||
|
if ($result === true) {
|
||||||
|
$operationOutcome[$sourceIdentifier] = [
|
||||||
|
'success' => true,
|
||||||
|
'identifier' => (string)new EntityIdentifier(
|
||||||
|
$target->provider(),
|
||||||
|
$target->service(),
|
||||||
|
$target->collection(),
|
||||||
|
$entityIdentifier,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operationOutcome[$sourceIdentifier] = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => is_string($result) && $result !== '' ? $result : 'unknownError',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Handle moving entities across different services/providers by fetching each entity and re-creating it in the target collection,
|
||||||
|
// then deleting the original if the move is successful. This will require additional logic to handle potential failures and ensure data integrity.
|
||||||
|
|
||||||
|
return $operationOutcome;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
728
package-lock.json
generated
728
package-lock.json
generated
@@ -9,9 +9,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^2.3.1"
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
@@ -64,6 +70,16 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bcoe/v8-coverage": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||||
@@ -506,12 +522,46 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@polka/url": {
|
||||||
|
"version": "1.0.0-next.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
|
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@rolldown/pluginutils": {
|
||||||
|
"version": "1.0.0-rc.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||||
|
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.57.1",
|
||||||
@@ -863,6 +913,34 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -870,6 +948,203 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@rolldown/pluginutils": "1.0.0-rc.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
|
||||||
|
"vue": "^3.2.25"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/coverage-v8": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"ast-v8-to-istanbul": "^0.3.10",
|
||||||
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
|
"istanbul-lib-report": "^3.0.1",
|
||||||
|
"istanbul-reports": "^3.2.0",
|
||||||
|
"magicast": "^0.5.1",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"std-env": "^3.10.0",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vitest/browser": "4.0.18",
|
||||||
|
"vitest": "4.0.18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@vitest/browser": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"chai": "^6.2.1",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/ui": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-CGJ25bc8fRi8Lod/3GHSvXRKi7nBo3kxh0ApW4yCjmrWmRmlT53B5E08XRSZRliygG0aVNxLrBEqPYdz/KcCtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"flatted": "^3.3.3",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"sirv": "^3.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vitest": "4.0.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"tinyrainbow": "^3.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@volar/language-core": {
|
"node_modules/@volar/language-core": {
|
||||||
"version": "2.4.27",
|
"version": "2.4.27",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz",
|
||||||
@@ -927,7 +1202,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz",
|
||||||
"integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==",
|
"integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.0",
|
"@babel/parser": "^7.29.0",
|
||||||
"@vue/compiler-core": "3.5.28",
|
"@vue/compiler-core": "3.5.28",
|
||||||
@@ -945,7 +1219,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz",
|
||||||
"integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==",
|
"integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.28",
|
"@vue/compiler-dom": "3.5.28",
|
||||||
"@vue/shared": "3.5.28"
|
"@vue/shared": "3.5.28"
|
||||||
@@ -978,7 +1251,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz",
|
||||||
"integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==",
|
"integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.28"
|
"@vue/shared": "3.5.28"
|
||||||
}
|
}
|
||||||
@@ -988,7 +1260,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz",
|
||||||
"integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==",
|
"integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.28",
|
"@vue/reactivity": "3.5.28",
|
||||||
"@vue/shared": "3.5.28"
|
"@vue/shared": "3.5.28"
|
||||||
@@ -999,7 +1270,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz",
|
||||||
"integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==",
|
"integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.28",
|
"@vue/reactivity": "3.5.28",
|
||||||
"@vue/runtime-core": "3.5.28",
|
"@vue/runtime-core": "3.5.28",
|
||||||
@@ -1012,7 +1282,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz",
|
||||||
"integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==",
|
"integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.28",
|
"@vue/compiler-ssr": "3.5.28",
|
||||||
"@vue/shared": "3.5.28"
|
"@vue/shared": "3.5.28"
|
||||||
@@ -1053,12 +1322,55 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.31",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"js-tokens": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ast-v8-to-istanbul/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
@@ -1072,6 +1384,14 @@
|
|||||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1120,6 +1440,17 @@
|
|||||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1138,6 +1469,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fflate": {
|
||||||
|
"version": "0.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
|
||||||
|
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/flatted": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1153,16 +1498,116 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-coverage": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/js-tokens": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magicast": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.0",
|
||||||
|
"@babel/types": "^7.29.0",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mrmime": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/muggle-string": {
|
"node_modules/muggle-string": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||||
@@ -1188,6 +1633,17 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1195,6 +1651,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1309,6 +1772,42 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||||
|
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/sirv": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@polka/url": "^1.0.0-next.24",
|
||||||
|
"mrmime": "^2.0.0",
|
||||||
|
"totalist": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1318,6 +1817,53 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "3.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||||
|
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -1335,6 +1881,26 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/totalist": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@@ -1424,6 +1990,85 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||||
|
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.0.18",
|
||||||
|
"@vitest/mocker": "4.0.18",
|
||||||
|
"@vitest/pretty-format": "4.0.18",
|
||||||
|
"@vitest/runner": "4.0.18",
|
||||||
|
"@vitest/snapshot": "4.0.18",
|
||||||
|
"@vitest/spy": "4.0.18",
|
||||||
|
"@vitest/utils": "4.0.18",
|
||||||
|
"es-module-lexer": "^1.7.0",
|
||||||
|
"expect-type": "^1.2.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^3.10.0",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.0.3",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.0.18",
|
||||||
|
"@vitest/browser-preview": "4.0.18",
|
||||||
|
"@vitest/browser-webdriverio": "4.0.18",
|
||||||
|
"@vitest/ui": "4.0.18",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vscode-uri": {
|
"node_modules/vscode-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||||
@@ -1436,7 +2081,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
|
||||||
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
"integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.28",
|
"@vue/compiler-dom": "3.5.28",
|
||||||
"@vue/compiler-sfc": "3.5.28",
|
"@vue/compiler-sfc": "3.5.28",
|
||||||
@@ -1479,6 +2123,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-router": {
|
||||||
|
"version": "4.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/devtools-api": "^6.6.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/posva"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-tsc": {
|
"node_modules/vue-tsc": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.4.tgz",
|
||||||
@@ -1495,6 +2154,51 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vuetify": {
|
||||||
|
"version": "3.11.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.11.8.tgz",
|
||||||
|
"integrity": "sha512-4iKnntOnLFFklygZjzlVfcHrtLO8+iK4HOhiia6HP2U8v82x+ngaSCgm+epvPrGyCMfCpfuEttqD2qElrr1axw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/johnleider"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": ">=4.7",
|
||||||
|
"vite-plugin-vuetify": ">=2.1.0",
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"webpack-plugin-vuetify": ">=3.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite-plugin-vuetify": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"webpack-plugin-vuetify": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
package.json
15
package.json
@@ -10,12 +10,23 @@
|
|||||||
"build": "vite build --mode production --config vite.config.ts",
|
"build": "vite build --mode production --config vite.config.ts",
|
||||||
"dev": "vite build --mode development --config vite.config.ts",
|
"dev": "vite build --mode development --config vite.config.ts",
|
||||||
"watch": "vite build --mode development --watch --config vite.config.ts",
|
"watch": "vite build --mode development --watch --config vite.config.ts",
|
||||||
"typecheck": "vue-tsc --noEmit"
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||||
|
"test": "vitest run --config tests/js/vitest.config.ts",
|
||||||
|
"test:unit": "vitest run --config tests/js/vitest.config.ts",
|
||||||
|
"test:watch": "vitest watch --config tests/js/vitest.config.ts",
|
||||||
|
"test:coverage": "vitest run --coverage --config tests/js/vitest.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pinia": "^2.3.1"
|
"pinia": "^2.3.1",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1",
|
||||||
|
"vuetify": "^3.10.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^7.1.2",
|
"vite": "^7.1.2",
|
||||||
|
|||||||
@@ -198,23 +198,24 @@ async function handleDiscover() {
|
|||||||
discoveryStatus.value[identifier].status = 'discovering'
|
discoveryStatus.value[identifier].status = 'discovering'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const services = await servicesStore.discover(
|
let discoveredService: any = undefined
|
||||||
|
await servicesStore.discover(
|
||||||
discoverAddress.value,
|
discoverAddress.value,
|
||||||
discoverSecret.value || undefined,
|
discoverSecret.value || undefined,
|
||||||
discoverHostname.value || undefined,
|
discoverHostname.value || undefined,
|
||||||
identifier
|
identifier,
|
||||||
|
(service) => { discoveredService = service }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Success - check if we got results for this provider
|
// Success - check if we got results for this provider
|
||||||
const service = services.find(s => s.provider === identifier)
|
if (discoveredService && discoveredService.location) {
|
||||||
if (service && service.location) {
|
|
||||||
discoveryStatus.value[identifier] = {
|
discoveryStatus.value[identifier] = {
|
||||||
provider: identifier,
|
provider: identifier,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
location: service.location,
|
location: discoveredService.location,
|
||||||
metadata: extractLocationMetadata(service.location)
|
metadata: extractLocationMetadata(discoveredService.location)
|
||||||
}
|
}
|
||||||
discoveredServices.value.push(service)
|
discoveredServices.value.push(discoveredService)
|
||||||
} else {
|
} else {
|
||||||
// No configuration found for this provider
|
// No configuration found for this provider
|
||||||
discoveryStatus.value[identifier].status = 'failed'
|
discoveryStatus.value[identifier].status = 'failed'
|
||||||
|
|||||||
@@ -5,10 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import type { Ref } from 'vue';
|
||||||
import { useEntitiesStore } from '../stores/entitiesStore';
|
import { useEntitiesStore } from '../stores/entitiesStore';
|
||||||
import { useCollectionsStore } from '../stores/collectionsStore';
|
import { useCollectionsStore } from '../stores/collectionsStore';
|
||||||
|
|
||||||
interface SyncSource {
|
export interface SyncSource {
|
||||||
provider: string;
|
provider: string;
|
||||||
service: string | number;
|
service: string | number;
|
||||||
collections: (string | number)[];
|
collections: (string | number)[];
|
||||||
@@ -23,7 +24,21 @@ interface SyncOptions {
|
|||||||
fetchDetails?: boolean;
|
fetchDetails?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMailSync(options: SyncOptions = {}) {
|
export interface MailSyncController {
|
||||||
|
isRunning: Ref<boolean>;
|
||||||
|
lastSync: Ref<Date | null>;
|
||||||
|
error: Ref<string | null>;
|
||||||
|
sources: Ref<SyncSource[]>;
|
||||||
|
addSource: (source: SyncSource) => void;
|
||||||
|
removeSource: (source: SyncSource) => void;
|
||||||
|
clearSources: () => void;
|
||||||
|
sync: () => Promise<void>;
|
||||||
|
start: () => void;
|
||||||
|
stop: () => void;
|
||||||
|
restart: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMailSync(options: SyncOptions = {}): MailSyncController {
|
||||||
const {
|
const {
|
||||||
interval = 30000,
|
interval = 30000,
|
||||||
autoStart = true,
|
autoStart = true,
|
||||||
|
|||||||
38
src/main.ts
38
src/main.ts
@@ -1,21 +1,13 @@
|
|||||||
import type { App as Vue } from 'vue'
|
|
||||||
import routes from '@/routes'
|
|
||||||
import integrations from '@/integrations'
|
|
||||||
import { useCollectionsStore } from '@/stores/collectionsStore'
|
|
||||||
import { useEntitiesStore } from '@/stores/entitiesStore'
|
|
||||||
import { useProvidersStore } from '@/stores/providersStore'
|
|
||||||
import { useServicesStore } from '@/stores/servicesStore'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mail Manager Module Boot Script
|
* Mail Manager Module Boot
|
||||||
*
|
|
||||||
* This script is executed when the mail_manager module is loaded.
|
|
||||||
* It initializes the stores which manage mail providers, services, collections, and messages.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
console.log('[MailManager] Booting Mail Manager module...')
|
import routes from '@/routes'
|
||||||
|
import integrations from '@/integrations'
|
||||||
|
|
||||||
console.log('[MailManager] Mail Manager module booted successfully')
|
console.log('[Mail Manager] Booting module...')
|
||||||
|
|
||||||
|
console.log('[Mail Manager] Module booted successfully...')
|
||||||
|
|
||||||
// CSS will be injected by build process
|
// CSS will be injected by build process
|
||||||
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
||||||
@@ -23,12 +15,14 @@ export const css = ['__CSS_FILENAME_PLACEHOLDER__']
|
|||||||
// Export routes and integrations for module system
|
// Export routes and integrations for module system
|
||||||
export { routes, integrations }
|
export { routes, integrations }
|
||||||
|
|
||||||
// Export stores for external use if needed
|
// Export services, stores and models for external use
|
||||||
export { useCollectionsStore, useEntitiesStore, useProvidersStore, useServicesStore }
|
export * from '@/services'
|
||||||
|
export * from '@/stores'
|
||||||
|
export * from '@/models'
|
||||||
|
|
||||||
// Default export for Vue plugin installation
|
// Export composables for external use
|
||||||
export default {
|
export { useMailSync } from '@/composables/useMailSync'
|
||||||
install(app: Vue) {
|
|
||||||
// Module initialization if needed
|
// Export components for external use
|
||||||
}
|
export { default as AddAccountDialog } from '@/components/AddAccountDialog.vue'
|
||||||
}
|
export { default as EditAccountDialog } from '@/components/EditAccountDialog.vue'
|
||||||
|
|||||||
@@ -17,16 +17,7 @@ export class CollectionObject implements CollectionInterface {
|
|||||||
signature: null,
|
signature: null,
|
||||||
created: null,
|
created: null,
|
||||||
modified: null,
|
modified: null,
|
||||||
properties: {
|
properties: new CollectionPropertiesObject(),
|
||||||
'@type': 'mail.collection',
|
|
||||||
version: 1,
|
|
||||||
total: 0,
|
|
||||||
unread: 0,
|
|
||||||
label: '',
|
|
||||||
role: null,
|
|
||||||
rank: 0,
|
|
||||||
subscribed: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +25,6 @@ export class CollectionObject implements CollectionInterface {
|
|||||||
this._data = data;
|
this._data = data;
|
||||||
if (data.properties) {
|
if (data.properties) {
|
||||||
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
|
this._data.properties = new CollectionPropertiesObject().fromJson(data.properties as CollectionPropertiesInterface);
|
||||||
} else {
|
|
||||||
this._data.properties = new CollectionPropertiesObject();
|
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -114,12 +103,12 @@ export class CollectionPropertiesObject implements CollectionPropertiesInterface
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._data = {
|
this._data = {
|
||||||
'@type': 'mail.collection',
|
'@type': 'mail:collection',
|
||||||
version: 1,
|
version: 1,
|
||||||
total: 0,
|
total: 0,
|
||||||
unread: 0,
|
unread: 0,
|
||||||
label: '',
|
|
||||||
role: null,
|
role: null,
|
||||||
|
label: '',
|
||||||
rank: 0,
|
rank: 0,
|
||||||
subscribed: true,
|
subscribed: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/**
|
|
||||||
* Central export point for all Mail Manager models
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { CollectionObject } from './collection';
|
|
||||||
export { EntityObject } from './entity';
|
|
||||||
export { ProviderObject } from './provider';
|
export { ProviderObject } from './provider';
|
||||||
export { ServiceObject } from './service';
|
export { ServiceObject } from './service';
|
||||||
|
export {
|
||||||
// Identity models
|
CollectionObject,
|
||||||
|
CollectionPropertiesObject
|
||||||
|
} from './collection';
|
||||||
|
export { EntityObject } from './entity';
|
||||||
|
export {
|
||||||
|
MessageObject,
|
||||||
|
MessagePartObject
|
||||||
|
} from './message';
|
||||||
export {
|
export {
|
||||||
Identity,
|
Identity,
|
||||||
IdentityNone,
|
IdentityNone,
|
||||||
@@ -16,8 +17,6 @@ export {
|
|||||||
IdentityOAuth,
|
IdentityOAuth,
|
||||||
IdentityCertificate
|
IdentityCertificate
|
||||||
} from './identity';
|
} from './identity';
|
||||||
|
|
||||||
// Location models
|
|
||||||
export {
|
export {
|
||||||
Location,
|
Location,
|
||||||
LocationUri,
|
LocationUri,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export class ProviderObject implements ProviderInterface {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this._data = {
|
this._data = {
|
||||||
'@type': 'mail.provider',
|
'@type': 'mail:provider',
|
||||||
identifier: '',
|
identifier: '',
|
||||||
label: '',
|
label: '',
|
||||||
capabilities: {},
|
capabilities: {},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Entity management service
|
* Entity management service
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { transceivePost } from './transceive';
|
import { transceivePost, transceiveStream } from './transceive';
|
||||||
import type {
|
import type {
|
||||||
EntityListRequest,
|
EntityListRequest,
|
||||||
EntityListResponse,
|
EntityListResponse,
|
||||||
@@ -18,9 +18,13 @@ import type {
|
|||||||
EntityDeleteResponse,
|
EntityDeleteResponse,
|
||||||
EntityDeltaRequest,
|
EntityDeltaRequest,
|
||||||
EntityDeltaResponse,
|
EntityDeltaResponse,
|
||||||
|
EntityMoveRequest,
|
||||||
|
EntityMoveResponse,
|
||||||
EntityTransmitRequest,
|
EntityTransmitRequest,
|
||||||
EntityTransmitResponse,
|
EntityTransmitResponse,
|
||||||
EntityInterface,
|
EntityInterface,
|
||||||
|
EntityStreamRequest,
|
||||||
|
EntityStreamResponse,
|
||||||
} from '../types/entity';
|
} from '../types/entity';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
import { EntityObject } from '../models';
|
import { EntityObject } from '../models';
|
||||||
@@ -147,6 +151,17 @@ export const entityService = {
|
|||||||
return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('entity.delta', request);
|
return await transceivePost<EntityDeltaRequest, EntityDeltaResponse>('entity.delta', request);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move entities to a target collection
|
||||||
|
*
|
||||||
|
* @param request - move request parameters
|
||||||
|
*
|
||||||
|
* @returns Promise with move results keyed by source entity identifier
|
||||||
|
*/
|
||||||
|
async move(request: EntityMoveRequest): Promise<EntityMoveResponse> {
|
||||||
|
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an entity
|
* Send an entity
|
||||||
*
|
*
|
||||||
@@ -157,6 +172,30 @@ export const entityService = {
|
|||||||
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
|
async transmit(request: EntityTransmitRequest): Promise<EntityTransmitResponse> {
|
||||||
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
|
return await transceivePost<EntityTransmitRequest, EntityTransmitResponse>('entity.transmit', request);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream entities as NDJSON, invoking onEntity for each entity as it arrives.
|
||||||
|
*
|
||||||
|
* The server emits one entity per line so the caller receives entities
|
||||||
|
* progressively rather than waiting for the full collection to load.
|
||||||
|
*
|
||||||
|
* @param request - stream request parameters (same shape as list)
|
||||||
|
* @param onEntity - called synchronously for each entity as it is received
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to { total } when the stream completes
|
||||||
|
*/
|
||||||
|
async stream(
|
||||||
|
request: EntityStreamRequest,
|
||||||
|
onEntity: (entity: EntityObject) => void
|
||||||
|
): Promise<{ total: number }> {
|
||||||
|
return await transceiveStream<EntityStreamRequest, EntityStreamResponse>(
|
||||||
|
'entity.stream',
|
||||||
|
request,
|
||||||
|
(entity) => {
|
||||||
|
onEntity(createEntityObject(entity));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default entityService;
|
export default entityService;
|
||||||
|
|||||||
@@ -5,24 +5,24 @@
|
|||||||
import type {
|
import type {
|
||||||
ServiceListRequest,
|
ServiceListRequest,
|
||||||
ServiceListResponse,
|
ServiceListResponse,
|
||||||
ServiceExtantRequest,
|
|
||||||
ServiceExtantResponse,
|
|
||||||
ServiceFetchRequest,
|
ServiceFetchRequest,
|
||||||
ServiceFetchResponse,
|
ServiceFetchResponse,
|
||||||
ServiceDiscoverRequest,
|
ServiceExtantRequest,
|
||||||
ServiceDiscoverResponse,
|
ServiceExtantResponse,
|
||||||
ServiceTestRequest,
|
|
||||||
ServiceTestResponse,
|
|
||||||
ServiceInterface,
|
|
||||||
ServiceCreateResponse,
|
ServiceCreateResponse,
|
||||||
ServiceCreateRequest,
|
ServiceCreateRequest,
|
||||||
ServiceUpdateResponse,
|
ServiceUpdateResponse,
|
||||||
ServiceUpdateRequest,
|
ServiceUpdateRequest,
|
||||||
ServiceDeleteResponse,
|
ServiceDeleteResponse,
|
||||||
ServiceDeleteRequest,
|
ServiceDeleteRequest,
|
||||||
|
ServiceDiscoverRequest,
|
||||||
|
ServiceTestRequest,
|
||||||
|
ServiceTestResponse,
|
||||||
|
ServiceInterface,
|
||||||
|
ServiceDiscoverResponse,
|
||||||
} from '../types/service';
|
} from '../types/service';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
import { transceivePost } from './transceive';
|
import { transceivePost, transceiveStream } from './transceive';
|
||||||
import { ServiceObject } from '../models/service';
|
import { ServiceObject } from '../models/service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,31 +87,32 @@ export const serviceService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve discoverable services for a given source selector, sorted by provider
|
* Discover services, streaming results as each provider responds
|
||||||
*
|
*
|
||||||
* @param request - discover request parameters
|
* @param request - discover request parameters
|
||||||
|
* @param onService - called for each discovered service as it arrives
|
||||||
*
|
*
|
||||||
* @returns Promise with array of discovered services sorted by provider
|
* @returns Promise resolving to { total } when the stream completes
|
||||||
*/
|
*/
|
||||||
async discover(request: ServiceDiscoverRequest): Promise<ServiceObject[]> {
|
async discover(
|
||||||
const response = await transceivePost<ServiceDiscoverRequest, ServiceDiscoverResponse>('service.discover', request);
|
request: ServiceDiscoverRequest,
|
||||||
|
onService: (service: ServiceObject) => void
|
||||||
// Convert discovery results to ServiceObjects
|
): Promise<{ total: number }> {
|
||||||
const services: ServiceObject[] = [];
|
return await transceiveStream<ServiceDiscoverRequest, ServiceDiscoverResponse>(
|
||||||
Object.entries(response).forEach(([providerId, location]) => {
|
'service.discover',
|
||||||
const serviceData: ServiceInterface = {
|
request,
|
||||||
'@type': 'mail:service',
|
(service) => {
|
||||||
provider: providerId,
|
const serviceData: ServiceInterface = {
|
||||||
identifier: null,
|
'@type': 'mail:service',
|
||||||
label: null,
|
provider: service.provider,
|
||||||
enabled: false,
|
identifier: null,
|
||||||
location: location,
|
label: null,
|
||||||
};
|
enabled: false,
|
||||||
services.push(createServiceObject(serviceData));
|
location: service.location,
|
||||||
});
|
};
|
||||||
|
onService(createServiceObject(serviceData));
|
||||||
// Sort by provider
|
}
|
||||||
return services.sort((a, b) => a.provider.localeCompare(b.provider));
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* Provides a centralized way to make API calls with envelope wrapping/unwrapping
|
* Provides a centralized way to make API calls with envelope wrapping/unwrapping
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createFetchWrapper } from '@KTXC/utils/helpers/fetch-wrapper-core';
|
import { createFetchWrapper } from '@KTXC';
|
||||||
import type { ApiRequest, ApiResponse } from '../types/common';
|
import type { ApiRequest, ApiResponse, ApiStreamResponse } from '../types/common';
|
||||||
|
|
||||||
const fetchWrapper = createFetchWrapper();
|
const fetchWrapper = createFetchWrapper();
|
||||||
const API_URL = '/m/mail_manager/v1';
|
const API_URL = '/m/mail_manager/v1';
|
||||||
@@ -48,3 +48,96 @@ export async function transceivePost<TRequest, TResponse>(
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream an NDJSON API response, unwrapping data frames for the caller.
|
||||||
|
*
|
||||||
|
* The server emits one JSON object per line with a transport-level `type`
|
||||||
|
* discriminant. This helper consumes control and error frames, forwards only
|
||||||
|
* unwrapped `data` payloads to the caller, and returns the final stream total.
|
||||||
|
*
|
||||||
|
* @param operation - Operation name, e.g. 'entity.stream'
|
||||||
|
* @param data - Operation-specific request data
|
||||||
|
* @param onData - Synchronous callback invoked for every unwrapped data payload.
|
||||||
|
* May throw to abort the stream.
|
||||||
|
* @param user - Optional user identifier override
|
||||||
|
* @returns Promise resolving to the final stream total from the control/end frame
|
||||||
|
*/
|
||||||
|
export async function transceiveStream<TRequest, TData>(
|
||||||
|
operation: string,
|
||||||
|
data: TRequest,
|
||||||
|
onData: (data: TData) => void,
|
||||||
|
user?: string
|
||||||
|
): Promise<{ total: number }> {
|
||||||
|
const request: ApiRequest<TRequest> = {
|
||||||
|
version: API_VERSION,
|
||||||
|
transaction: generateTransactionId(),
|
||||||
|
operation,
|
||||||
|
data,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
await fetchWrapper.post(API_URL, request, {
|
||||||
|
//headers: { 'Accept': 'application/x-ndjson' },
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
onStream: async (response: Response) => {
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error(`[${operation}] Response body is not readable`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop()!; // retain any incomplete trailing chunk
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const message = JSON.parse(line) as ApiStreamResponse<TData>;
|
||||||
|
|
||||||
|
if (message.type === 'control') {
|
||||||
|
if (message.status === 'end') {
|
||||||
|
total = message.total;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'error') {
|
||||||
|
throw new Error(`[${operation}] ${message.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush any remaining bytes still in the buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const message = JSON.parse(buffer) as ApiStreamResponse<TData>;
|
||||||
|
|
||||||
|
if (message.type === 'control') {
|
||||||
|
if (message.status === 'end') {
|
||||||
|
total = message.total;
|
||||||
|
}
|
||||||
|
} else if (message.type === 'error') {
|
||||||
|
throw new Error(`[${operation}] ${message.message}`);
|
||||||
|
} else {
|
||||||
|
onData(message.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { total };
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ import { CollectionObject, CollectionPropertiesObject } from '../models/collecti
|
|||||||
import type { SourceSelector, ListFilter, ListSort } from '../types'
|
import type { SourceSelector, ListFilter, ListSort } from '../types'
|
||||||
|
|
||||||
export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
||||||
|
const ROOT_IDENTIFIER = '__root__'
|
||||||
|
const SERVICE_INDEX_IDENTIFIER = '__service__'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const _collections = ref<Record<string, CollectionObject>>({})
|
const _collections = ref<Record<string, CollectionObject>>({})
|
||||||
|
const _collectionsByServiceIndex = ref<Record<string, string[]>>({})
|
||||||
|
const _collectionsByParentIndex = ref<Record<string, string[]>>({})
|
||||||
const transceiving = ref(false)
|
const transceiving = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,12 +39,19 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
const collectionsByService = computed(() => {
|
const collectionsByService = computed(() => {
|
||||||
const groups: Record<string, CollectionObject[]> = {}
|
const groups: Record<string, CollectionObject[]> = {}
|
||||||
|
|
||||||
Object.values(_collections.value).forEach((collection) => {
|
Object.keys(_collectionsByServiceIndex.value).forEach(serviceIndexKey => {
|
||||||
const serviceKey = `${collection.provider}:${collection.service}`
|
const collectionKeys = _collectionsByServiceIndex.value[serviceIndexKey] ?? []
|
||||||
if (!groups[serviceKey]) {
|
const collectionsForKey = collectionKeys
|
||||||
groups[serviceKey] = []
|
.map(collectionKey => _collections.value[collectionKey])
|
||||||
|
.filter((collection): collection is CollectionObject => collection !== undefined)
|
||||||
|
|
||||||
|
if (collectionsForKey.length === 0) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
groups[serviceKey].push(collection)
|
|
||||||
|
const firstCollection = collectionsForKey[0]
|
||||||
|
const serviceKey = `${firstCollection.provider}:${firstCollection.service}`
|
||||||
|
groups[serviceKey] = collectionsForKey
|
||||||
})
|
})
|
||||||
|
|
||||||
return groups
|
return groups
|
||||||
@@ -75,10 +87,9 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
* @returns Array of collection objects
|
* @returns Array of collection objects
|
||||||
*/
|
*/
|
||||||
function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] {
|
function collectionsForService(provider: string, service: string | number, retrieve: boolean = false): CollectionObject[] {
|
||||||
const serviceKeyPrefix = `${provider}:${service}:`
|
const serviceCollections = collectionObjectsForKeys(
|
||||||
const serviceCollections = Object.entries(_collections.value)
|
_collectionsByServiceIndex.value[identifierKey(provider, service, SERVICE_INDEX_IDENTIFIER)] ?? [],
|
||||||
.filter(([key]) => key.startsWith(serviceKeyPrefix))
|
)
|
||||||
.map(([_, collection]) => collection)
|
|
||||||
|
|
||||||
if (retrieve === true && serviceCollections.length === 0) {
|
if (retrieve === true && serviceCollections.length === 0) {
|
||||||
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`)
|
console.debug(`[Mail Manager][Store] - Force fetching collections for service "${provider}:${service}"`)
|
||||||
@@ -93,19 +104,26 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
return serviceCollections
|
return serviceCollections
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectionsInCollection(provider: string, service: string | number, collectionId: string | number, retrieve: boolean = false): CollectionObject[] {
|
/**
|
||||||
const collectionKeyPrefix = `${provider}:${service}:${collectionId}:`
|
* Get direct child collections for a parent collection, or root collections when parent is null.
|
||||||
const nestedCollections = Object.entries(_collections.value)
|
*
|
||||||
.filter(([key]) => key.startsWith(collectionKeyPrefix))
|
* @param provider - provider identifier
|
||||||
.map(([_, collection]) => collection)
|
* @param service - service identifier
|
||||||
|
* @param collectionId - parent collection identifier, or null for root-level collections
|
||||||
|
* @param retrieve - Retrieve behavior: true = fetch service collections if missing, false = cache only
|
||||||
|
*
|
||||||
|
* @returns Array of direct child collection objects
|
||||||
|
*/
|
||||||
|
function collectionsInCollection(provider: string, service: string | number, collectionId: string | number | null, retrieve: boolean = false): CollectionObject[] {
|
||||||
|
const nestedCollections = collectionObjectsForKeys(
|
||||||
|
_collectionsByParentIndex.value[identifierKey(provider, service, collectionId)] ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
if (retrieve === true && nestedCollections.length === 0) {
|
if (retrieve === true && nestedCollections.length === 0) {
|
||||||
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`)
|
console.debug(`[Mail Manager][Store] - Force fetching collections in collection "${provider}:${service}:${collectionId}"`)
|
||||||
const sources: SourceSelector = {
|
const sources: SourceSelector = {
|
||||||
[provider]: {
|
[provider]: {
|
||||||
[String(service)]: {
|
[String(service)]: true
|
||||||
[String(collectionId)]: true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
list(sources)
|
list(sources)
|
||||||
@@ -114,11 +132,66 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
return nestedCollections
|
return nestedCollections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasChildrenInCollection(provider: string, service: string | number, collectionId: string | number | null): boolean {
|
||||||
|
return (_collectionsByParentIndex.value[identifierKey(provider, service, collectionId)]?.length ?? 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create unique key for a collection
|
* Create unique key for a collection
|
||||||
*/
|
*/
|
||||||
function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string {
|
function identifierKey(provider: string, service: string | number | null, identifier: string | number | null): string {
|
||||||
return `${provider}:${service ?? ''}:${identifier ?? ''}`
|
return `${provider}:${String(service ?? ROOT_IDENTIFIER)}:${String(identifier ?? ROOT_IDENTIFIER)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectionObjectsForKeys(collectionKeys: string[]): CollectionObject[] {
|
||||||
|
return collectionKeys
|
||||||
|
.map(collectionKey => _collections.value[collectionKey])
|
||||||
|
.filter((collection): collection is CollectionObject => collection !== undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
|
||||||
|
const existing = index[indexKey] ?? []
|
||||||
|
|
||||||
|
if (existing.includes(collectionKey)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index[indexKey] = [...existing, collectionKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeIndexEntry(index: Record<string, string[]>, indexKey: string, collectionKey: string) {
|
||||||
|
const existing = index[indexKey]
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = existing.filter(existingKey => existingKey !== collectionKey)
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
delete index[indexKey]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index[indexKey] = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexCollection(collection: CollectionObject) {
|
||||||
|
const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier)
|
||||||
|
const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER)
|
||||||
|
const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection)
|
||||||
|
|
||||||
|
addIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey)
|
||||||
|
addIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deindexCollection(collection: CollectionObject) {
|
||||||
|
const collectionKey = identifierKey(collection.provider, collection.service, collection.identifier)
|
||||||
|
const serviceIndexKey = identifierKey(collection.provider, collection.service, SERVICE_INDEX_IDENTIFIER)
|
||||||
|
const parentIndexKey = identifierKey(collection.provider, collection.service, collection.collection)
|
||||||
|
|
||||||
|
removeIndexEntry(_collectionsByServiceIndex.value, serviceIndexKey, collectionKey)
|
||||||
|
removeIndexEntry(_collectionsByParentIndex.value, parentIndexKey, collectionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -143,6 +216,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => {
|
Object.entries(providerServices).forEach(([_serviceId, serviceCollections]) => {
|
||||||
Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => {
|
Object.entries(serviceCollections).forEach(([_collectionId, collectionObj]) => {
|
||||||
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
|
const key = identifierKey(collectionObj.provider, collectionObj.service, collectionObj.identifier)
|
||||||
|
const previousCollection = _collections.value[key]
|
||||||
|
|
||||||
|
if (previousCollection) {
|
||||||
|
deindexCollection(previousCollection)
|
||||||
|
}
|
||||||
|
|
||||||
collections[key] = collectionObj
|
collections[key] = collectionObj
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -150,6 +229,9 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
|
|
||||||
// Merge retrieved collections into state
|
// Merge retrieved collections into state
|
||||||
_collections.value = { ..._collections.value, ...collections }
|
_collections.value = { ..._collections.value, ...collections }
|
||||||
|
Object.values(collections).forEach(collectionObj => {
|
||||||
|
indexCollection(collectionObj)
|
||||||
|
})
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections')
|
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(collections).length, 'collections')
|
||||||
return collections
|
return collections
|
||||||
@@ -177,7 +259,14 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
|
|
||||||
// Merge fetched collection into state
|
// Merge fetched collection into state
|
||||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||||
|
const previousCollection = _collections.value[key]
|
||||||
|
|
||||||
|
if (previousCollection) {
|
||||||
|
deindexCollection(previousCollection)
|
||||||
|
}
|
||||||
|
|
||||||
_collections.value[key] = response
|
_collections.value[key] = response
|
||||||
|
indexCollection(response)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully fetched collection:', key)
|
console.debug('[Mail Manager][Store] - Successfully fetched collection:', key)
|
||||||
return response
|
return response
|
||||||
@@ -234,6 +323,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
// Merge created collection into state
|
// Merge created collection into state
|
||||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||||
_collections.value[key] = response
|
_collections.value[key] = response
|
||||||
|
indexCollection(response)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully created collection:', key)
|
console.debug('[Mail Manager][Store] - Successfully created collection:', key)
|
||||||
return response
|
return response
|
||||||
@@ -267,7 +357,14 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
|
|
||||||
// Merge updated collection into state
|
// Merge updated collection into state
|
||||||
const key = identifierKey(response.provider, response.service, response.identifier)
|
const key = identifierKey(response.provider, response.service, response.identifier)
|
||||||
|
const previousCollection = _collections.value[key]
|
||||||
|
|
||||||
|
if (previousCollection) {
|
||||||
|
deindexCollection(previousCollection)
|
||||||
|
}
|
||||||
|
|
||||||
_collections.value[key] = response
|
_collections.value[key] = response
|
||||||
|
indexCollection(response)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully updated collection:', key)
|
console.debug('[Mail Manager][Store] - Successfully updated collection:', key)
|
||||||
return response
|
return response
|
||||||
@@ -295,6 +392,12 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
|
|
||||||
// Remove deleted collection from state
|
// Remove deleted collection from state
|
||||||
const key = identifierKey(provider, service, identifier)
|
const key = identifierKey(provider, service, identifier)
|
||||||
|
const previousCollection = _collections.value[key]
|
||||||
|
|
||||||
|
if (previousCollection) {
|
||||||
|
deindexCollection(previousCollection)
|
||||||
|
}
|
||||||
|
|
||||||
delete _collections.value[key]
|
delete _collections.value[key]
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully deleted collection:', key)
|
console.debug('[Mail Manager][Store] - Successfully deleted collection:', key)
|
||||||
@@ -317,6 +420,7 @@ export const useCollectionsStore = defineStore('mailCollectionsStore', () => {
|
|||||||
collectionsByService,
|
collectionsByService,
|
||||||
collectionsForService,
|
collectionsForService,
|
||||||
collectionsInCollection,
|
collectionsInCollection,
|
||||||
|
hasChildrenInCollection,
|
||||||
// Actions
|
// Actions
|
||||||
collection,
|
collection,
|
||||||
list,
|
list,
|
||||||
|
|||||||
@@ -6,8 +6,20 @@ import { ref, computed, readonly } from 'vue'
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { entityService } from '../services'
|
import { entityService } from '../services'
|
||||||
import { EntityObject } from '../models'
|
import { EntityObject } from '../models'
|
||||||
import type { EntityTransmitRequest, EntityTransmitResponse } from '../types/entity'
|
import type {
|
||||||
import type { SourceSelector, ListFilter, ListSort, ListRange } from '../types/common'
|
EntityMoveResponse,
|
||||||
|
EntityStreamRequest,
|
||||||
|
EntityTransmitRequest,
|
||||||
|
EntityTransmitResponse,
|
||||||
|
} from '../types/entity'
|
||||||
|
import type {
|
||||||
|
CollectionIdentifier,
|
||||||
|
EntityIdentifier,
|
||||||
|
ListFilter,
|
||||||
|
ListRange,
|
||||||
|
ListSort,
|
||||||
|
SourceSelector,
|
||||||
|
} from '../types/common'
|
||||||
|
|
||||||
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
||||||
// State
|
// State
|
||||||
@@ -88,6 +100,24 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
return `${provider}:${service}:${collection}:${identifier}`
|
return `${provider}:${service}:${collection}:${identifier}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a full entity identifier into its components.
|
||||||
|
*/
|
||||||
|
function parseEntityIdentifier(identifier: EntityIdentifier): {
|
||||||
|
provider: string
|
||||||
|
service: string
|
||||||
|
collection: string
|
||||||
|
identifier: string
|
||||||
|
} {
|
||||||
|
const [provider, service, collection, entity] = identifier.split(':', 4)
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
service,
|
||||||
|
collection,
|
||||||
|
identifier: entity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,26 +133,16 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
|
async function list(sources?: SourceSelector, filter?: ListFilter, sort?: ListSort, range?: ListRange): Promise<Record<string, EntityObject>> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
try {
|
try {
|
||||||
const response = await entityService.list({ sources, filter, sort, range })
|
const added: Record<string, EntityObject> = {}
|
||||||
|
|
||||||
// Flatten nested structure: provider:service:collection:entity -> "provider:service:collection:entity": object
|
await entityService.stream({ sources, filter, sort, range }, (entity: EntityObject) => {
|
||||||
const entities: Record<string, EntityObject> = {}
|
const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier)
|
||||||
Object.entries(response).forEach(([providerId, providerServices]) => {
|
_entities.value[key] = entity
|
||||||
Object.entries(providerServices).forEach(([serviceId, serviceCollections]) => {
|
added[key] = entity
|
||||||
Object.entries(serviceCollections).forEach(([collectionId, collectionEntities]) => {
|
|
||||||
Object.entries(collectionEntities).forEach(([entityId, entityData]) => {
|
|
||||||
const key = identifierKey(providerId, serviceId, collectionId, entityId)
|
|
||||||
entities[key] = entityData
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Merge retrieved entities into state
|
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(added).length, 'entities')
|
||||||
_entities.value = { ..._entities.value, ...entities }
|
return added
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully retrieved', Object.keys(entities).length, 'entities')
|
|
||||||
return entities
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Mail Manager][Store] - Failed to retrieve entities:', error)
|
console.error('[Mail Manager][Store] - Failed to retrieve entities:', error)
|
||||||
throw error
|
throw error
|
||||||
@@ -325,6 +345,56 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move entities to another collection.
|
||||||
|
*
|
||||||
|
* Updates local store keys for successfully moved entities when they are
|
||||||
|
* already present in cache.
|
||||||
|
*
|
||||||
|
* @param target - target collection identifier
|
||||||
|
* @param sources - source entity identifiers
|
||||||
|
*
|
||||||
|
* @returns Promise with move results keyed by source identifier
|
||||||
|
*/
|
||||||
|
async function move(target: CollectionIdentifier, sources: EntityIdentifier[]): Promise<EntityMoveResponse> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const response = await entityService.move({ target, sources })
|
||||||
|
|
||||||
|
Object.entries(response).forEach(([sourceIdentifier, result]) => {
|
||||||
|
if (!result.success) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedEntity = _entities.value[sourceIdentifier]
|
||||||
|
if (!cachedEntity) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = parseEntityIdentifier(result.identifier)
|
||||||
|
const movedEntity = cachedEntity.clone().fromJson({
|
||||||
|
...cachedEntity.toJson(),
|
||||||
|
provider: destination.provider,
|
||||||
|
service: destination.service,
|
||||||
|
collection: destination.collection,
|
||||||
|
identifier: destination.identifier,
|
||||||
|
})
|
||||||
|
|
||||||
|
delete _entities.value[sourceIdentifier]
|
||||||
|
|
||||||
|
_entities.value[result.identifier] = movedEntity
|
||||||
|
})
|
||||||
|
|
||||||
|
console.debug('[Mail Manager][Store] - Successfully moved', Object.keys(response).length, 'entities')
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager][Store] - Failed to move entities:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send/transmit an entity
|
* Send/transmit an entity
|
||||||
*
|
*
|
||||||
@@ -346,6 +416,42 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream entities progressively, merging each entity into the store as it arrives.
|
||||||
|
*
|
||||||
|
* Unlike list(), which waits for the full response before updating the store,
|
||||||
|
* stream() updates reactive state entity-by-entity so UI renders incrementally.
|
||||||
|
*
|
||||||
|
* @param sources - optional source selector
|
||||||
|
* @param filter - optional list filter
|
||||||
|
* @param sort - optional list sort
|
||||||
|
* @param range - optional list range
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to { total } when the stream completes
|
||||||
|
*/
|
||||||
|
async function stream(
|
||||||
|
sources?: SourceSelector,
|
||||||
|
filter?: ListFilter,
|
||||||
|
sort?: ListSort,
|
||||||
|
range?: ListRange
|
||||||
|
): Promise<{ total: number }> {
|
||||||
|
transceiving.value = true
|
||||||
|
try {
|
||||||
|
const request: EntityStreamRequest = { sources, filter, sort, range }
|
||||||
|
const result = await entityService.stream(request, (entity: EntityObject) => {
|
||||||
|
const key = identifierKey(entity.provider, entity.service, entity.collection, entity.identifier)
|
||||||
|
_entities.value[key] = entity
|
||||||
|
})
|
||||||
|
console.debug('[Mail Manager][Store] - Successfully streamed', result.total, 'entities')
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Mail Manager][Store] - Failed to stream entities:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
transceiving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return public API
|
// Return public API
|
||||||
return {
|
return {
|
||||||
// State (readonly)
|
// State (readonly)
|
||||||
@@ -364,6 +470,8 @@ export const useEntitiesStore = defineStore('mailEntitiesStore', () => {
|
|||||||
update,
|
update,
|
||||||
delete: remove,
|
delete: remove,
|
||||||
delta,
|
delta,
|
||||||
|
move,
|
||||||
transmit,
|
transmit,
|
||||||
|
stream,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
4
src/stores/index.ts
Normal file
4
src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { useCollectionsStore } from './collectionsStore';
|
||||||
|
export { useEntitiesStore } from './entitiesStore';
|
||||||
|
export { useProvidersStore } from './providersStore';
|
||||||
|
export { useServicesStore } from './servicesStore';
|
||||||
@@ -268,22 +268,29 @@ export const useServicesStore = defineStore('mailServicesStore', () => {
|
|||||||
* @param secret - optional secret for discovery
|
* @param secret - optional secret for discovery
|
||||||
* @param location - optional location for discovery
|
* @param location - optional location for discovery
|
||||||
* @param provider - optional provider identifier for discovery
|
* @param provider - optional provider identifier for discovery
|
||||||
|
* @param onService - called for each discovered service as it arrives
|
||||||
*
|
*
|
||||||
* @returns Promise with list of discovered service objects
|
* @returns Promise resolving to { total } when the stream completes
|
||||||
*/
|
*/
|
||||||
async function discover(
|
async function discover(
|
||||||
identity: string,
|
identity: string,
|
||||||
secret: string | undefined,
|
secret: string | undefined,
|
||||||
location: string | undefined,
|
location: string | undefined,
|
||||||
provider: string | undefined,
|
provider: string | undefined,
|
||||||
): Promise<ServiceObject[]> {
|
onService?: (service: ServiceObject) => void,
|
||||||
|
): Promise<{ total: number }> {
|
||||||
transceiving.value = true
|
transceiving.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const services = await serviceService.discover({identity, secret, location, provider})
|
const result = await serviceService.discover(
|
||||||
|
{ identity, secret, location, provider },
|
||||||
|
(service: ServiceObject) => {
|
||||||
|
onService?.(service)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
console.debug('[Mail Manager][Store] - Successfully discovered', services.length, 'services')
|
console.debug('[Mail Manager][Store] - Successfully discovered', result.total, 'services')
|
||||||
return services
|
return result
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[Mail Manager][Store] - Failed to discover service:', error)
|
console.error('[Mail Manager][Store] - Failed to discover service:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -43,6 +43,47 @@ export interface ApiErrorResponse {
|
|||||||
*/
|
*/
|
||||||
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
export type ApiResponse<T = any> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream control start line
|
||||||
|
*/
|
||||||
|
export interface ApiStreamStartResponse {
|
||||||
|
type: 'control';
|
||||||
|
status: 'start';
|
||||||
|
version: number;
|
||||||
|
transaction: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream control end line
|
||||||
|
*/
|
||||||
|
export interface ApiStreamEndResponse {
|
||||||
|
type: 'control';
|
||||||
|
status: 'end';
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream error line
|
||||||
|
*/
|
||||||
|
export interface ApiStreamErrorResponse {
|
||||||
|
type: 'error';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiStreamDataResponse<T = any> {
|
||||||
|
type: 'data';
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared stream control lines
|
||||||
|
*/
|
||||||
|
export type ApiStreamResponse<T = any> =
|
||||||
|
| ApiStreamStartResponse
|
||||||
|
| ApiStreamEndResponse
|
||||||
|
| ApiStreamErrorResponse
|
||||||
|
| ApiStreamDataResponse<T>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selector for targeting specific providers, services, collections, or entities in list or extant operations.
|
* Selector for targeting specific providers, services, collections, or entities in list or extant operations.
|
||||||
*
|
*
|
||||||
@@ -72,6 +113,10 @@ export type CollectionSelector = {
|
|||||||
|
|
||||||
export type EntitySelector = (string | number)[];
|
export type EntitySelector = (string | number)[];
|
||||||
|
|
||||||
|
export type ProviderIdentifier = `${string}`;
|
||||||
|
export type ServiceIdentifier = `${string}:${string}`;
|
||||||
|
export type CollectionIdentifier = `${string}:${string}:${string}`;
|
||||||
|
export type EntityIdentifier = `${string}:${string}:${string}:${string}`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter comparison for list operations
|
* Filter comparison for list operations
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Entity type definitions
|
* Entity type definitions
|
||||||
*/
|
*/
|
||||||
import type { SourceSelector, ListFilter, ListSort, ListRange } from './common';
|
import type {
|
||||||
|
CollectionIdentifier,
|
||||||
|
EntityIdentifier,
|
||||||
|
SourceSelector,
|
||||||
|
ListFilter,
|
||||||
|
ListRange,
|
||||||
|
ListSort,
|
||||||
|
} from './common';
|
||||||
import type { MessageInterface } from './message';
|
import type { MessageInterface } from './message';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,6 +135,28 @@ export interface EntityDeltaResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity move
|
||||||
|
*/
|
||||||
|
export interface EntityMoveRequest {
|
||||||
|
target: CollectionIdentifier;
|
||||||
|
sources: EntityIdentifier[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityMoveResultSuccess {
|
||||||
|
success: boolean;
|
||||||
|
identifier: EntityIdentifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityMoveResultFailure {
|
||||||
|
success: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityMoveResponse {
|
||||||
|
[sourceIdentifier: EntityIdentifier]: EntityMoveResultSuccess | EntityMoveResultFailure;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity transmit
|
* Entity transmit
|
||||||
*/
|
*/
|
||||||
@@ -158,3 +187,15 @@ export interface EntityTransmitResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
status: 'queued' | 'sent';
|
status: 'queued' | 'sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity stream
|
||||||
|
*/
|
||||||
|
export interface EntityStreamRequest {
|
||||||
|
sources?: SourceSelector;
|
||||||
|
filter?: ListFilter;
|
||||||
|
sort?: ListSort;
|
||||||
|
range?: ListRange;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityStreamResponse extends EntityInterface<MessageInterface> {}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
/**
|
|
||||||
* Central export point for all Mail Manager types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type * from './collection';
|
export type * from './collection';
|
||||||
export type * from './common';
|
export type * from './common';
|
||||||
export type * from './entity';
|
export type * from './entity';
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export interface ProviderCapabilitiesInterface {
|
|||||||
ServiceFetch?: boolean;
|
ServiceFetch?: boolean;
|
||||||
ServiceExtant?: boolean;
|
ServiceExtant?: boolean;
|
||||||
ServiceCreate?: boolean;
|
ServiceCreate?: boolean;
|
||||||
ServiceModify?: boolean;
|
ServiceUpdate?: boolean;
|
||||||
ServiceDestroy?: boolean;
|
ServiceDelete?: boolean;
|
||||||
ServiceDiscover?: boolean;
|
ServiceDiscover?: boolean;
|
||||||
ServiceTest?: boolean;
|
ServiceTest?: boolean;
|
||||||
[key: string]: boolean | object | string[] | undefined;
|
[key: string]: boolean | object | string[] | undefined;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Service type definitions
|
* Service type definitions
|
||||||
*/
|
*/
|
||||||
import type { SourceSelector, ListFilterComparisonOperator } from './common';
|
import type {
|
||||||
|
ListFilterComparisonOperator,
|
||||||
|
SourceSelector,
|
||||||
|
} from './common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service capabilities
|
* Service capabilities
|
||||||
@@ -16,6 +19,7 @@ export interface ServiceCapabilitiesInterface {
|
|||||||
CollectionCreate?: boolean;
|
CollectionCreate?: boolean;
|
||||||
CollectionUpdate?: boolean;
|
CollectionUpdate?: boolean;
|
||||||
CollectionDelete?: boolean;
|
CollectionDelete?: boolean;
|
||||||
|
CollectionMove?: boolean;
|
||||||
// Message capabilities
|
// Message capabilities
|
||||||
EntityList?: boolean;
|
EntityList?: boolean;
|
||||||
EntityListFilter?: ServiceListFilterEntity;
|
EntityListFilter?: ServiceListFilterEntity;
|
||||||
@@ -129,7 +133,8 @@ export interface ServiceDiscoverRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceDiscoverResponse {
|
export interface ServiceDiscoverResponse {
|
||||||
[provider: string]: ServiceLocation; // Uses existing ServiceLocation discriminated union
|
provider: string;
|
||||||
|
location: ServiceLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
30
tests/js/unit/base.test.ts
Normal file
30
tests/js/unit/base.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
|
||||||
|
describe('Basic Tests', () => {
|
||||||
|
it('should perform basic assertion', () => {
|
||||||
|
expect(true).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test array operations', () => {
|
||||||
|
const array = ['foo', 'bar', 'baz']
|
||||||
|
|
||||||
|
expect(array).toHaveLength(3)
|
||||||
|
expect(array).toContain('bar')
|
||||||
|
expect(array[0]).toBe('foo')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test string operations', () => {
|
||||||
|
const string = 'Hello, World!'
|
||||||
|
|
||||||
|
expect(string).toContain('World')
|
||||||
|
expect(string.length).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should test object operations', () => {
|
||||||
|
const obj = { foo: 'bar', count: 42 }
|
||||||
|
|
||||||
|
expect(obj).toHaveProperty('foo')
|
||||||
|
expect(obj.foo).toBe('bar')
|
||||||
|
expect(obj.count).toBeGreaterThan(40)
|
||||||
|
})
|
||||||
|
})
|
||||||
33
tests/js/vitest.config.ts
Normal file
33
tests/js/vitest.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vuetify from 'vite-plugin-vuetify'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), vuetify()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@KTXC': path.resolve(__dirname, '../../../core/src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('../../', import.meta.url)),
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text', 'json', 'html'],
|
||||||
|
exclude: [
|
||||||
|
'node_modules/',
|
||||||
|
'tests/',
|
||||||
|
'**/*.d.ts',
|
||||||
|
'**/*.config.*',
|
||||||
|
'**/dist/**',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
7
tests/php/bootstrap.php
Normal file
7
tests/php/bootstrap.php
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require dirname(__DIR__, 2).'/lib/vendor/autoload.php';
|
||||||
|
|
||||||
|
if (isset($_SERVER['APP_DEBUG']) && $_SERVER['APP_DEBUG']) {
|
||||||
|
umask(0000);
|
||||||
|
}
|
||||||
39
tests/php/phpunit.unit.xml
Normal file
39
tests/php/phpunit.unit.xml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<!-- https://phpunit.readthedocs.io/en/latest/configuration.html -->
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="../../vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
colors="true"
|
||||||
|
failOnDeprecation="true"
|
||||||
|
failOnNotice="true"
|
||||||
|
failOnWarning="true"
|
||||||
|
bootstrap="bootstrap.php"
|
||||||
|
cacheDirectory="../../.phpunit.cache"
|
||||||
|
>
|
||||||
|
<php>
|
||||||
|
<ini name="display_errors" value="1" />
|
||||||
|
<ini name="error_reporting" value="-1" />
|
||||||
|
<server name="APP_ENV" value="test" force="true" />
|
||||||
|
<server name="SHELL_VERBOSITY" value="-1" />
|
||||||
|
</php>
|
||||||
|
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit Tests">
|
||||||
|
<directory>unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source ignoreSuppressionOfDeprecations="true"
|
||||||
|
ignoreIndirectDeprecations="true"
|
||||||
|
restrictNotices="true"
|
||||||
|
restrictWarnings="true"
|
||||||
|
>
|
||||||
|
<include>
|
||||||
|
<directory>../../core/lib</directory>
|
||||||
|
<directory>../../shared/lib</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
|
||||||
|
<extensions>
|
||||||
|
</extensions>
|
||||||
|
</phpunit>
|
||||||
29
tests/php/unit/BaseTest.php
Normal file
29
tests/php/unit/BaseTest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace KTXT\MailManager\Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BaseTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testBasicAssertion(): void
|
||||||
|
{
|
||||||
|
$this->assertTrue(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArrayOperations(): void
|
||||||
|
{
|
||||||
|
$array = ['foo' => 'bar'];
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('foo', $array);
|
||||||
|
$this->assertEquals('bar', $array['foo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStringOperations(): void
|
||||||
|
{
|
||||||
|
$string = 'Hello, World!';
|
||||||
|
|
||||||
|
$this->assertStringContainsString('World', $string);
|
||||||
|
$this->assertEquals(13, strlen($string));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,13 +41,16 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [
|
external: [
|
||||||
'pinia',
|
|
||||||
'vue',
|
'vue',
|
||||||
'vue-router',
|
'vue-router',
|
||||||
// Externalize shared utilities from core to avoid duplication
|
'pinia',
|
||||||
/^@KTXC\/utils\//,
|
'@KTXC',
|
||||||
],
|
],
|
||||||
output: {
|
output: {
|
||||||
|
paths: (id) => {
|
||||||
|
if (id === '@KTXC') return '/js/ktxc.mjs'
|
||||||
|
return id
|
||||||
|
},
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
if (assetInfo.name?.endsWith('.css')) {
|
if (assetInfo.name?.endsWith('.css')) {
|
||||||
return 'mail_manager-[hash].css'
|
return 'mail_manager-[hash].css'
|
||||||
|
|||||||
Reference in New Issue
Block a user