diff --git a/FILESYSTEM.md b/FILESYSTEM.md index 8b5e009..458b3cf 100644 --- a/FILESYSTEM.md +++ b/FILESYSTEM.md @@ -54,13 +54,15 @@ uses its isolated profile only when an explicit browser binary is configured. ``` ├── auth/ # Authentication store ├── base/ # DBAPIClient, base utilities -├── client/ # Client model & API ├── config/ # Configuration adapters & schema ├── database/ # Supabase relation types plus stable database/runtime-contract adapters ├── extension/ # Extension lifecycle, Module Federation ├── info-base/ # Block, Relation, Storage, Resolvers ├── libs/ # Third-party integrations (AI) ├── obsrv/ # Observability (logging) +├── organization/ # Organization capability entry points +├── peer/ # Peer discovery, protocols, outbounds, and delegation +├── semantic-retrieval/ # Semantic retrieval capability entry point ├── sink/ # Output processing (graph layouts) ├── source/ # Source, CollectJob, CollectAt ├── utils/ # Vue prop helpers, utilities @@ -72,11 +74,11 @@ uses its isolated profile only when an explicit browser binary is configured. ``` ├── components/ # Vue components by domain -│ ├── client/ # Client management │ ├── common/ # Shared components │ ├── extension/ # Extension UI │ ├── info-base/ # Block, Relation, Graph │ ├── obsrv/ # Log viewer +│ ├── peer/ # Technical Peer management (product UI says “client”) │ └── source/ # Source management ├── composables/ # Vue composition functions ├── locales/ # i18n translations diff --git a/apps/client-web/AGENTS.md b/apps/client-web/AGENTS.md index 29e7358..340bf23 100644 --- a/apps/client-web/AGENTS.md +++ b/apps/client-web/AGENTS.md @@ -20,7 +20,7 @@ Web client for InKCre - GUI for system management and visualization. - info-base - Graph: block, relation, storage, resolver - sink - Output/visualization - extension - Plugin system -- client - Multi-client management +- peer - Technical Peer management; keep product-facing UI language as “client” - obsrv - Observability/logging ## Directory Structure @@ -44,7 +44,7 @@ src/ - The application has no Hono/Worker runtime and no `/api/config` endpoint. - Bootstrap config is validated and persisted in this origin's localStorage. - A fresh origin has no environment default. Static output and source maps contain no InKCre - environment origin or client identity. + environment origin or Peer identity. - The user-supplied JWT signing credential is masked, never logged, and excluded from portable config exports. - Start through the root `pnpm dev` command so SVC and Portless preserve worktree identity. diff --git a/apps/client-web/ARCHITECTURE.md b/apps/client-web/ARCHITECTURE.md index 207c8fb..9d821ab 100644 --- a/apps/client-web/ARCHITECTURE.md +++ b/apps/client-web/ARCHITECTURE.md @@ -1,318 +1,108 @@ # InKCre Client-Web Architecture -## Table of Contents - -1. System Overview -2. Technology Stack -3. High-Level Architecture -4. Architecture Patterns -5. Directory Structure -6. Core Systems -7. Data Flow - ---- - -## System Overview - -InKCre Client-Web is a web-based application for information collection, organization, and visualization. It provides a GUI for managing the InKCre system, featuring graph-based storage, extensible plugins, and real-time visualization. - -**Core Capabilities:** - -- Automated data collection with scheduled jobs -- Knowledge graph with blocks and relations -- Module Federation-**based** extensions -- Multi-client peer network -- Interactive graph layouts -- Pluggable content handling - -**Business Domains:** - -- **Source**: Data input pipeline -- **Info-Base**: Graph-based knowledge system -- **Extension**: Plugin architecture -- **Client**: Multi-client management -- **Obsrv**: Observability and logging - ---- - -## Technology Stack - -**Core Framework:** - -- Vue 3 (Composition API) -- TypeScript (strict typing) -- Vite (build tool) -- SCSS + UnoCSS (styling) - -**State & Routing:** - -- Pinia (global state) -- Vue Router (navigation) - -**Data Layer:** - -- Zod (validation and type inference) -- PostgREST client (database access) - -**Extension System:** - -- Module Federation (dynamic loading) - -**Visualization:** - -- Vue Flow (graph rendering) -- D3.js (layouts) - -**APIs:** - -- PostgREST and core-py (external services) -- JOSE (authentication) - ---- - -## High-Level Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ InKCre Client-Web │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Vue App │ │ -│ │ ┌────────────────────────────────────────────────────────┐ │ │ -│ │ │ Views Layer │ │ │ -│ │ │ (Settings, Sources, Extensions, Info-Base Graph) │ │ │ -│ │ └────────────────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────────────────┐ │ │ -│ │ │ Components Layer │ │ │ -│ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │ -│ │ │ │ Source │ │ Info-Base│ │Extension │ │ Client │ │ │ │ -│ │ │ │Components│ │Components│ │Components│ │ Comps │ │ │ │ -│ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │ │ -│ │ └────────────────────────────────────────────────────────┘ │ │ -│ │ ┌────────────────────────────────────────────────────────┐ │ │ -│ │ │ Business Logic Layer │ │ │ -│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ -│ │ │ │ BusinessClass Pattern │ │ │ │ -│ │ │ │ (Source, Client, Extension, Block, Relation) │ │ │ │ -│ │ │ │ - Zod Schema + TypeScript Class │ │ │ │ -│ │ │ │ - Static API clients (DBAPIClient, CoreAPIClient)│ │ │ │ -│ │ │ │ - Static registries for instances │ │ │ │ -│ │ │ └─────────────────────────────────────────────────┘ │ │ │ -│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ -│ │ │ │ Extension System (Module Federation) │ │ │ │ -│ │ │ │ - Dynamic remote loading │ │ │ │ -│ │ │ │ - Lifecycle management (DISCOVERED → ACTIVE) │ │ │ │ -│ │ │ │ - Registry pattern for Storages/Resolvers │ │ │ │ -│ │ │ └─────────────────────────────────────────────────┘ │ │ │ -│ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ -│ │ │ │ Info-Base Subsystems │ │ │ │ -│ │ │ │ - Storage: Content retrieval (http, local, etc) │ │ │ │ -│ │ │ │ - Resolver: Content rendering (text, image, etc)│ │ │ │ -│ │ │ │ - Graph: Force/Dagre/Circular layouts │ │ │ │ -│ │ │ └─────────────────────────────────────────────────┘ │ │ │ -│ │ └────────────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ API Layer (Dual) │ │ -│ │ ┌──────────────────────────┐ ┌────────────────────────┐ │ │ -│ │ │ DBAPIClient │ │ CoreAPIClient │ │ │ -│ │ │ (PostgREST via Supabase)│ │ (REST to core-py) │ │ │ -│ │ │ - Direct DB queries │ │ - Complex operations │ │ │ -│ │ │ - CRUD operations │ │ - Business logic │ │ │ -│ │ └──────────────────────────┘ └────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ State Management (Pinia) │ │ -│ │ - Auth Store (JWT tokens, user state) │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Config System (zod-config) │ │ -│ │ - Browser-local validated bootstrap config │ │ -│ │ - Explicit application-owned initialization │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ - ┌───────────────────────────────────────────────┐ - │ Backend Services │ - │ ┌──────────────────┐ ┌──────────────────┐ │ - │ │ PostgreSQL DB │ │ core-py API │ │ - │ │ (via PostgREST) │ │ (REST Server) │ │ - │ └──────────────────┘ └──────────────────┘ │ - └───────────────────────────────────────────────┘ +Client-Web is a static Vue application and an equal InKCre database Peer. “Client” remains the +product-facing name; code and technical documentation use **Peer** for the deployment identity used +by discovery and delegation. + +## Topology + +```text +Vue view/component + ├─ database fact/query ──> domain Active Record ──> DBAPIClient ──> PostgREST/PostgreSQL + └─ business capability ──> domain manager ──> PeerManager.delegate(exact capability) + │ + └─ live Peer advertisement ──> protocol outbound + │ + └─ provider inbound + └─ local domain execution ``` -### Data Flow Overview - -1. User interaction triggers Vue components -2. Components invoke BusinessClass methods -3. BusinessClass uses DBAPIClient or CoreAPIClient -4. API clients communicate with backend -5. Responses update reactive UI - ---- - -## Architecture Patterns - -### 1. BusinessClass Pattern - -Combines Zod schemas with TypeScript classes for type-safe entities. Provides runtime validation, centralized API access, and instance registries. +The database and capability paths are intentionally separate. Client-Web does not have a generic +Core API client, a generic capability invoke endpoint, or a delegation job table. -**Key Features:** +## Technical Domains -- Single source of truth for types and validation -- Static API clients per entity -- Consistent CRUD operations +- `info-base`: Block, Relation, Storage, Resolver, graph visualization, and content hydration. +- `source`: source facts and collection-job visibility. +- `extension`: local Module Federation lifecycle and exact remote extension management. +- `peer`: Peer Active Record, capability discovery, protocol outbounds, and one-shot delegation. +- `semantic-retrieval`: typed facade for `core.semantic_retrieval.v1`. +- `organization`: typed facade for `core.organization.rumination.v1`. +- `obsrv`: runtime logs and diagnostics. -### 2. Dual API Architecture +## Peer Delegation -Two separate API clients for different access patterns: - -**DBAPIClient (PostgREST):** - -- Direct database queries -- Fast CRUD operations -- Simple list and filter queries - -**CoreAPIClient (REST):** - -- Complex business logic -- Validation and orchestration -- Batch operations and workflows - -**Decision Criteria:** - -- Use DBAPIClient for simple, performance-critical operations -- Use CoreAPIClient for complex logic requiring backend processing - -### 3. Module Federation Extension System - -Dynamic plugin loading with lifecycle management. Extensions register custom storages and resolvers. - -**Lifecycle:** -DISCOVERED → LOADING → LOADED → INITIALIZING → READY → ACTIVATING → ACTIVE - -**Benefits:** - -- Runtime extensibility -- Shared dependencies -- Strong isolation - -### 4. Registry Pattern (Storage & Resolver) - -Decorator-based registration for pluggable content handling. Extensions can add new content types with custom retrieval and rendering logic. - -**Storage:** Handles content fetching (HTTP, local, external APIs) -**Resolver:** Handles content transformation and rendering - -### 5. Config System - -The static web application explicitly initializes the shared config store with its localStorage -adapter before mounting Vue. The browser origin owns the PostgREST URL, client ID, and user-provided -JWT signing credential; an empty origin does not inherit a production or development environment. -There is no HTTP config endpoint, environment adapter, runtime adapter switching, or compiled -environment profile. - ---- - -## Directory Structure +`PeerManager.delegate(capability, payload, routeToPeer)` discovers unexpired advertisements using +PostgreSQL time. An advertisement contains an exact capability ID plus one inbound interface: +```text +{ + id, + inbound: { + protocol, + parameters + } +} ``` -client-web/ -├── docs/ # Domain documentation -├── public/ # Static assets -├── src/ -│ ├── components/ # Vue components by domain -│ │ ├── source/ -│ │ ├── info-base/ -│ │ ├── extension/ -│ │ └── common/ -│ ├── composables/ # Vue composition functions -│ ├── locales/ # Internationalization -│ ├── views/ # Routed views and settings -│ ├── core.ts # Explicit core/config initialization -│ └── main.ts # Vue entry -├── package.json -├── vite.config.ts -└── tsconfig.json -``` - ---- - -## Core Systems - -### Business Layer - -Implements domain entities using BusinessClass pattern. Each entity has: - -- Zod schema for validation -- Static API clients -- Instance registries -- CRUD methods - -**Key Entities:** - -- **Source**: Data collection configurations -- **Client**: InKCre client instances -- **Extension**: Plugin modules -- **Block**: Information units in graph -- **Relation**: Connections between blocks - -### Extension System - -Module Federation-based plugins with lifecycle management. Extensions can: - -- Register custom resolvers for new content types -- Register custom storages for data retrieval -- Add UI components -- Extend business logic - -### Component Architecture - -Organized by business domain: -- Domain-specific components for each business area -- Shared common components -- Type-safe props using prop factories +The protocol selects a registered outbound. `core.peer.protocol.http.v1` carries normalized query, +headers, and optional JSON body; its static method and URL live only in inbound parameters. Peer JWT +authentication is part of that protocol. `routeToPeer` is caller-local routing state and never +enters the business payload. -### State Management +Automatic failover is allowed only after explicit +`InkCre-Peer-Execution: not-executed`. Browser Fetch rejection cannot prove that dispatch did not +occur, so it becomes `PeerOutcomeUnknown` and is never retried automatically. Exact-target routing +never substitutes a different Peer. -Pinia stores for global state: +## Domain Inbounds and Outbounds -- Auth store for JWT tokens and user state -- Local reactive state for component-specific data +Transport and business direction are different views of the same call: -### Configuration +- SemanticRetrievalOutbound is the caller-side `SemanticRetrievalManager` plus the selected Peer + protocol outbound. +- SemanticRetrievalInbound is the provider route plus its non-delegating local semantic retrieval + implementation. +- Organization rumination and Extension management follow the same structure. -- localStorage is the web runtime authority. -- Zod validates config before persistence and use. -- Static artifacts and source maps contain no InKCre environment origin or client identity. -- Portable export excludes the JWT signing credential. -- JWTs remain memory-only and are signed from the user-owned browser credential. +Domain managers own request and response models. PeerManager understands only exact capability +delegation and transport construction; it does not understand semantic retrieval, organization, or +extensions. ---- +## Configuration and Authentication -## Data Flow +The static application initializes browser-local meta config before mounting Vue: -### Read Flow +- PostgREST URL; +- current technical Peer ID (presented as Client ID in the UI); +- user-owned JWT signing secret. -User action → View component → BusinessClass.list() → DBAPIClient → PostgREST → Database → Response → Zod validation → Reactive UI update +The current Peer's deployment config is read from its database row. Static artifacts contain no +deployment origin, Peer identity, or JWT credential. JWTs are memory-only and use the generated +Peer contract. -### Write Flow +Local development runs core-py and PostgREST through the tracked database runtime. The deployment +orchestrator writes the core-py Peer's `config.http_public_base_url` through PostgREST, then waits +for core-py to publish the three exact capability advertisements and a live lease. The URL is not +an environment-variable authority. -User action → Component → BusinessClass instance → CoreAPIClient → core-py API → Validation & business logic → Database write → Response → UI update +## Extension Management -### Extension Flow +For the current browser Peer, extension lifecycle and config changes may directly update shared +state and local Module Federation runtime. Managing another Peer delegates +`core.extension.management.v1` to that exact Peer. The old per-Client REST URL and generic request +method do not exist. -Extension activation → Register remote → Load module → Initialize (register handlers) → Activate (start tasks) → Features available +## Rumination UI -### Content Resolution Flow +BlockDetailsPanel exposes explicit rumination. Success reloads the graph and reselects the focal +Block. `PeerOutcomeUnknown` is shown distinctly and is not retried or reported as success. -Block display → Get resolver → Fetch storage content → Resolve content → Cache result → Render via content component +## Build Contract ---- +- Vue 3, TypeScript, Vite, Pinia, Vue Router, Vue Flow, SCSS, and UnoCSS. +- `@inkcre/core` is source-aliased in workspace development and built as ESM by tsdown. +- The checked database contract generates relation types and Peer JWT/runtime metadata. +- The deployable output is an environment-neutral static artifact. -**Last Updated**: July 23, 2026 +**Last Updated**: August 7, 2026 diff --git a/apps/client-web/docs/development.md b/apps/client-web/docs/development.md index 59d6c66..6d4173b 100644 --- a/apps/client-web/docs/development.md +++ b/apps/client-web/docs/development.md @@ -27,8 +27,9 @@ remote is sufficient. ## Browser-local configuration -The settings page is the sole runtime authority for the PostgREST URL, client ID, and user-owned -JWT signing secret. A fresh origin has no default service or client identity. The secret is masked +The settings page is the sole runtime authority for the PostgREST URL, technical Peer ID (shown as +Client ID in the product UI), and user-owned JWT signing secret. A fresh origin has no default +service or Peer identity. The secret is masked and omitted from portable exports. Do not add a hardcoded endpoint, Vite environment variable, Worker binding, or shared Cloudflare secret for runtime configuration. diff --git a/apps/client-web/src/components/AGENTS.md b/apps/client-web/src/components/AGENTS.md index 4bc5610..75122e2 100644 --- a/apps/client-web/src/components/AGENTS.md +++ b/apps/client-web/src/components/AGENTS.md @@ -4,12 +4,13 @@ Read [/.github/instructions/component.instructions.md](/.github/instructions/com ## Quick Reference -- client: clientList +- peer: peerList, peerCard (product-facing copy may still say “client”) - common: AppSidePanel - extension: extensionCard, installExtension - info-base: BlockNode, BlockDetailsPanel, resolvers/* - obsrv: LogEntry, LogsViewer -- source: sourceCard, sourceForm, collectAtForm, collectJobForm +- source: sourceCard, sourceForm +- job: JobCard ## Component Pattern diff --git a/apps/client-web/src/components/ARCHITECTURE.md b/apps/client-web/src/components/ARCHITECTURE.md index 67696f2..9c40ba3 100644 --- a/apps/client-web/src/components/ARCHITECTURE.md +++ b/apps/client-web/src/components/ARCHITECTURE.md @@ -89,10 +89,10 @@ Components are grouped by business domain: ``` components/ -├── client/ # Client-related components ├── extension/ # Extension management ├── info-base/ # Information base (blocks, relations) ├── obsrv/ # Observation/logging +├── peer/ # Technical Peer management; UI copy remains product-facing ├── source/ # Data sources └── common/ # Shared utilities ``` diff --git a/apps/client-web/src/components/FILESYSTEM.md b/apps/client-web/src/components/FILESYSTEM.md index 2654715..fbde0a0 100644 --- a/apps/client-web/src/components/FILESYSTEM.md +++ b/apps/client-web/src/components/FILESYSTEM.md @@ -1,8 +1,6 @@ ``` src/components/ ├── AGENTS.md # Component guidelines -├── client/ # Client domain -│ └── clientList/ ├── common/ # Shared components │ ├── AGENTS.md │ └── AppSidePanel/ @@ -18,6 +16,9 @@ src/components/ │ ├── AGENTS.md │ ├── LogEntry/ │ └── LogsViewer/ +├── peer/ # Technical Peer domain +│ ├── peerCard/ +│ └── peerList/ └── source/ # Source domain ├── collectAtForm/ ├── sourceCard/ diff --git a/apps/client-web/src/components/client/clientCard/clientCard.md b/apps/client-web/src/components/client/clientCard/clientCard.md deleted file mode 100644 index afe539e..0000000 --- a/apps/client-web/src/components/client/clientCard/clientCard.md +++ /dev/null @@ -1,30 +0,0 @@ -# clientCard - -## Rationale - -Display and edit a single client in the client list. - -## Goals - -Allow users to view client details and edit client name and URL. - -## Key Concepts - -Client management, health status. - -## Specification - -Shows client name, ID, URL, and status. Has edit mode with inputs for name and URL, save/cancel buttons. - -## Implementation - -Uses reactive editing state, emits updated on save. - -### Props - -- `client` (`Client`, required): The client object -- `status` (`"online" | "offline" | "unknown"`, required): Health status - -### Events - -- `updated()`: Emitted after successful save diff --git a/apps/client-web/src/components/client/clientCard/clientCard.vue b/apps/client-web/src/components/client/clientCard/clientCard.vue deleted file mode 100644 index f4b001a..0000000 --- a/apps/client-web/src/components/client/clientCard/clientCard.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - diff --git a/apps/client-web/src/components/info-base/resolvers/ContentFile.vue b/apps/client-web/src/components/info-base/resolvers/ContentFile.vue new file mode 100644 index 0000000..620b22d --- /dev/null +++ b/apps/client-web/src/components/info-base/resolvers/ContentFile.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/apps/client-web/src/components/info-base/resolvers/ContentHtml.vue b/apps/client-web/src/components/info-base/resolvers/ContentHtml.vue index 3d4d978..f9d8e8b 100644 --- a/apps/client-web/src/components/info-base/resolvers/ContentHtml.vue +++ b/apps/client-web/src/components/info-base/resolvers/ContentHtml.vue @@ -1,35 +1,14 @@ diff --git a/apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.md b/apps/client-web/src/components/job/JobCard/JobCard.md similarity index 53% rename from apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.md rename to apps/client-web/src/components/job/JobCard/JobCard.md index 95d3411..0ea3b59 100644 --- a/apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.md +++ b/apps/client-web/src/components/job/JobCard/JobCard.md @@ -1,12 +1,12 @@ -# sourceCollectJobCard +# JobCard ## Rationale -Provides a card component to display source collect job information in a list. +Provides a card component to display Job information in a list. ## Goals -Display collect job details in a consistent, clickable card format. +Display Job details in a consistent, clickable card format. ## Specification @@ -18,7 +18,7 @@ Display collect job details in a consistent, clickable card format. ### Props -- `job` (`SourceCollectJob`, required): The job data to display +- `job` (`Job`, required): The job data to display ### Events diff --git a/apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.scss b/apps/client-web/src/components/job/JobCard/JobCard.scss similarity index 97% rename from apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.scss rename to apps/client-web/src/components/job/JobCard/JobCard.scss index 8e8f570..a1ed03d 100644 --- a/apps/client-web/src/components/source/sourceCollectJobCard/sourceCollectJobCard.scss +++ b/apps/client-web/src/components/job/JobCard/JobCard.scss @@ -1,4 +1,4 @@ -.source-collect-job-card { +.job-card { display: flex; flex-direction: column; gap: sys-var(space, xs); diff --git a/apps/client-web/src/components/job/JobCard/JobCard.ts b/apps/client-web/src/components/job/JobCard/JobCard.ts new file mode 100644 index 0000000..5d723a4 --- /dev/null +++ b/apps/client-web/src/components/job/JobCard/JobCard.ts @@ -0,0 +1,15 @@ +import type { PropType } from 'vue' +import { Job } from '@inkcre/core' + +// --- Props --- +export const jobCardProps = { + job: { + type: Object as PropType, + required: true, + }, +} as const + +// --- Emits --- +export const jobCardEmits = { + click: () => true, +} as const diff --git a/apps/client-web/src/components/job/JobCard/JobCard.vue b/apps/client-web/src/components/job/JobCard/JobCard.vue new file mode 100644 index 0000000..b053967 --- /dev/null +++ b/apps/client-web/src/components/job/JobCard/JobCard.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/extensions/mail/src/components/contentMimePart/contentMimePart.vue b/extensions/mail/src/components/contentMimePart/contentMimePart.vue new file mode 100644 index 0000000..644f503 --- /dev/null +++ b/extensions/mail/src/components/contentMimePart/contentMimePart.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/extensions/mail/src/index.ts b/extensions/mail/src/index.ts new file mode 100644 index 0000000..7940cd2 --- /dev/null +++ b/extensions/mail/src/index.ts @@ -0,0 +1,12 @@ +import type { ExtensionModule } from '@inkcre/core' + +import './resolver' + +const Extension: ExtensionModule = { + async initialize() {}, + async activate() {}, + async deactivate() {}, + async dispose() {}, +} + +export default Extension diff --git a/extensions/mail/src/main.ts b/extensions/mail/src/main.ts new file mode 100644 index 0000000..1c10cbf --- /dev/null +++ b/extensions/mail/src/main.ts @@ -0,0 +1,16 @@ +import { bootstrapExtension } from '@inkcre/ext-dev-utils' +import '@inkcre/ui-web/styles' + +import App from './DevApp.vue' +import Extension from './index' + +void bootstrapExtension({ + rootComponent: App, + extensionModule: Extension, + routes: [ + { + path: '/', + component: () => import('./views/DevHome.vue'), + }, + ], +}).catch((error) => console.error('Failed to bootstrap Mail extension:', error)) diff --git a/extensions/mail/src/resolver.ts b/extensions/mail/src/resolver.ts new file mode 100644 index 0000000..84398aa --- /dev/null +++ b/extensions/mail/src/resolver.ts @@ -0,0 +1,278 @@ +import { markRaw } from 'vue' +import { + Block, + PeerManager, + PeerProtocolResponseSchema, + Relation, + Resolver, + ResolverCache, + type ProjectionOptions, +} from '@inkcre/core' + +import ContentEmail from './components/contentEmail/contentEmail.vue' +import ContentMailFact from './components/contentMailFact/contentMailFact.vue' +import ContentMimePart from './components/contentMimePart/contentMimePart.vue' +import { + CanonicalEmailAddressSchema, + CanonicalEmailSchema, + CanonicalMailFlagSchema, + CanonicalMailboxSchema, + CanonicalMimePartSchema, + ComponentRelationSchema, + ContainsRelationSchema, + EmbeddedReferenceRelationSchema, + ParticipantRelationSchema, + parseJson, + type CanonicalEmailAddress, + type CanonicalMailFlag, + type CanonicalMailbox, + type SolvedBlock, + type SolvedEmail, + type SolvedMimePart, +} from './schema' + +export const MAIL_MIME_PART_MATERIALIZE_CAPABILITY = 'extensions.mail.mime_part.materialize.v1' + +async function solveBlock( + block: Block, + options: ProjectionOptions +): Promise> { + const resolver = await ResolverCache.getResolver(block) + return { + block, + solvedContent: (await resolver.getSolvedContent({ + ...options, + materializeMissing: false, + })) as Content, + } +} + +function parseRelation(content: string): unknown | null { + try { + return parseJson(content) + } catch { + return null + } +} + +async function relatedBlocks(relations: Relation[], focal: number): Promise> { + const refs = new Set( + relations.map((relation) => (relation.from_ === focal ? relation.to_ : relation.from_)) + ) + const blocks = await Promise.all([...refs].map((ref) => Block.find(ref))) + return new Map( + blocks.filter((block): block is Block => block !== null).map((block) => [block.id, block]) + ) +} + +export class EmailResolver extends Resolver { + static readonly type = 'extensions.mail.email.v1' + static readonly solvedContentRenderer = markRaw(ContentEmail) + + static { + Resolver.register(EmailResolver.type, EmailResolver) + } + + protected async _getSolvedContent(options: ProjectionOptions): Promise { + const root = CanonicalEmailSchema.parse(parseJson(await this.getRawContent(options))) + const relations = await this.getRelations({ refresh: options.refresh }) + const blocks = await relatedBlocks(relations, this.block.id) + const result: SolvedEmail = { + root, + bodies: [], + mimeParts: [], + participants: [], + mailboxes: [], + flags: [], + parents: [], + references: [], + embedded: [], + } + + for (const relation of relations) { + const outgoing = relation.from_ === this.block.id + const related = blocks.get(outgoing ? relation.to_ : relation.from_) + if (!related) continue + + if (outgoing) { + const component = ComponentRelationSchema.safeParse(parseRelation(relation.content)) + if (component.success && component.data.role === 'body') { + if (!['core.text.v1', 'core.html.v1'].includes(related.resolver)) continue + result.bodies.push({ + ...(await solveBlock(related, options)), + relation: component.data, + }) + continue + } + if (component.success && component.data.role !== 'body') { + if (related.resolver !== MailMimePartResolver.type) continue + result.mimeParts.push({ + ...(await solveBlock(related, options)), + relation: component.data, + }) + continue + } + + const participant = ParticipantRelationSchema.safeParse(parseRelation(relation.content)) + if (participant.success && related.resolver === EmailAddressResolver.type) { + result.participants.push({ + relation: participant.data, + address: await solveBlock(related, options), + }) + continue + } + if (related.resolver === EmailResolver.type && relation.content.startsWith('parent:')) { + result.parents.push({ + block: related, + solvedContent: CanonicalEmailSchema.parse(parseJson(related.content)), + }) + } else if ( + related.resolver === EmailResolver.type && + relation.content.startsWith('reference:') + ) { + result.references.push({ + block: related, + solvedContent: CanonicalEmailSchema.parse(parseJson(related.content)), + }) + } + continue + } + + const contains = ContainsRelationSchema.safeParse(parseRelation(relation.content)) + if (contains.success && related.resolver === MailboxResolver.type) { + result.mailboxes.push({ + relation: contains.data, + mailbox: await solveBlock(related, options), + }) + } else if (relation.content === 'tags' && related.resolver === MailFlagResolver.type) { + result.flags.push({ flag: await solveBlock(related, options) }) + } + } + + const mimeParts = new Map(result.mimeParts.map((part) => [part.block.id, part])) + for (const body of result.bodies.filter((item) => item.block.resolver === 'core.html.v1')) { + for (const relation of await Relation.getByBlock(body.block.id)) { + if (relation.from_ !== body.block.id) continue + const embedded = EmbeddedReferenceRelationSchema.safeParse(parseRelation(relation.content)) + const mimePart = mimeParts.get(relation.to_) + if (embedded.success && mimePart) { + result.embedded.push({ + body: body.block, + reference: embedded.data.reference, + mimePart, + }) + } + } + } + return result + } + + async materializeMimePart(blockRef: number): Promise { + const block = await Block.get(blockRef) + if (block.resolver !== MailMimePartResolver.type) { + throw new TypeError(`Block ${blockRef} is not a Mail MIME part`) + } + const resolver = await ResolverCache.getResolver(block) + await resolver.getSolvedContent({ refresh: true, materializeMissing: true }) + return this.getSolvedContent({ refresh: true, materializeMissing: false }) + } + + async getText(options: ProjectionOptions = {}): Promise { + const email = await this.getSolvedContent(options) + const body = email.bodies.find((item) => item.block.resolver === 'core.text.v1') + return [email.root.subject, body?.solvedContent].filter(Boolean).join('\n\n') || 'email' + } + + async getStrForEmbedding(options: ProjectionOptions = {}): Promise { + return this.getText(options) + } +} + +abstract class JsonMailResolver extends Resolver { + abstract readonly schema: { parse(value: unknown): Content } + + protected async _getSolvedContent(options: ProjectionOptions): Promise { + return this.schema.parse(parseJson(await this.getRawContent(options))) + } + + async getText(options: ProjectionOptions = {}): Promise { + return JSON.stringify(await this.getSolvedContent(options)) + } + + async getStrForEmbedding(options: ProjectionOptions = {}): Promise { + return this.getText(options) + } +} + +export class MailboxResolver extends JsonMailResolver { + static readonly type = 'extensions.mail.mailbox.v1' + static readonly solvedContentRenderer = markRaw(ContentMailFact) + readonly schema = CanonicalMailboxSchema + + static { + Resolver.register(MailboxResolver.type, MailboxResolver) + } +} + +export class EmailAddressResolver extends JsonMailResolver { + static readonly type = 'extensions.mail.email_address.v1' + static readonly solvedContentRenderer = markRaw(ContentMailFact) + readonly schema = CanonicalEmailAddressSchema + + static { + Resolver.register(EmailAddressResolver.type, EmailAddressResolver) + } +} + +export class MailFlagResolver extends JsonMailResolver { + static readonly type = 'extensions.mail.flag.v1' + static readonly solvedContentRenderer = markRaw(ContentMailFact) + readonly schema = CanonicalMailFlagSchema + + static { + Resolver.register(MailFlagResolver.type, MailFlagResolver) + } +} + +export class MailMimePartResolver extends Resolver { + static readonly type = 'extensions.mail.mime_part.v1' + static readonly solvedContentRenderer = markRaw(ContentMimePart) + + static { + Resolver.register(MailMimePartResolver.type, MailMimePartResolver) + } + + protected async _getSolvedContent(options: ProjectionOptions): Promise { + const root = CanonicalMimePartSchema.parse(parseJson(await this.getRawContent(options))) + const relations = await this.getRelations({ + refresh: options.refresh, + includeIn: false, + includeOut: true, + }) + const contentRelation = relations.find((relation) => relation.content === 'content') + if (contentRelation) { + const child = await Block.find(contentRelation.to_) + if (child) return { root, content: await solveBlock(child, options) } + } + if (!options.materializeMissing) return { root, content: null } + + const delegated = await PeerManager.delegate(MAIL_MIME_PART_MATERIALIZE_CAPABILITY, { + body: { block: this.block.id }, + }) + const response = PeerProtocolResponseSchema.parse(delegated) + if (response.status !== 200 || !Object.prototype.hasOwnProperty.call(response, 'body')) { + throw new Error(`Mail materialization Peer returned HTTP ${response.status}`) + } + const child = Block.parse(response.body) + return { root, content: await solveBlock(child, options) } + } + + async getText(options: ProjectionOptions = {}): Promise { + const { root } = await this.getSolvedContent({ ...options, materializeMissing: false }) + return [root.filename, root.description, root.media_type].filter(Boolean).join('\n') + } + + async getStrForEmbedding(options: ProjectionOptions = {}): Promise { + return this.getText(options) + } +} diff --git a/extensions/mail/src/schema.ts b/extensions/mail/src/schema.ts new file mode 100644 index 0000000..3839f03 --- /dev/null +++ b/extensions/mail/src/schema.ts @@ -0,0 +1,96 @@ +import { z } from 'zod' +import type { Block } from '@inkcre/core' + +export const CanonicalEmailSchema = z.strictObject({ + message_id: z.string().nullable().default(null), + email_id: z.string().nullable().default(null), + subject: z.string().nullable().default(null), + authored_at: z.coerce.date().nullable().default(null), +}) +export type CanonicalEmail = z.infer + +export const CanonicalMailboxSchema = z.strictObject({ + name: z.string(), + special_uses: z.array(z.string()).default([]), + mailbox_id: z.string().nullable().default(null), +}) +export type CanonicalMailbox = z.infer + +export const CanonicalEmailAddressSchema = z.strictObject({ address: z.string() }) +export type CanonicalEmailAddress = z.infer + +export const CanonicalMailFlagSchema = z.strictObject({ + name: z.string(), + description: z.string().nullable().default(null), +}) +export type CanonicalMailFlag = z.infer + +export const CanonicalMimePartSchema = z.strictObject({ + media_type: z.string(), + charset: z.string().nullable().default(null), + filename: z.string().nullable().default(null), + content_id: z.string().nullable().default(null), + description: z.string().nullable().default(null), + transfer_encoding: z.string().nullable().default(null), + encoded_size: z.number().int().nonnegative().nullable().default(null), + content_location: z.string().nullable().default(null), +}) +export type CanonicalMimePart = z.infer + +export const ComponentRelationSchema = z.strictObject({ + role: z.enum(['body', 'attachment', 'inline']), + part_id: z.string(), +}) +export type ComponentRelation = z.infer + +export const ParticipantRelationSchema = z.strictObject({ + role: z.enum(['from', 'sender', 'reply_to', 'to', 'cc', 'bcc']), + order: z.number().int().nonnegative(), + display_name: z.string().nullable().default(null), +}) +export type ParticipantRelation = z.infer + +export const ContainsRelationSchema = z.strictObject({ + type: z.literal('contains'), + uid_validity: z.number().int().positive(), + uid: z.number().int().positive(), +}) +export type ContainsRelation = z.infer + +export const EmbeddedReferenceRelationSchema = z.strictObject({ + type: z.literal('embeds'), + reference: z.string(), +}) + +export interface SolvedBlock { + block: Block + solvedContent: Content +} + +export interface SolvedMimePart { + root: CanonicalMimePart + content: SolvedBlock | null +} + +export interface SolvedEmail { + root: CanonicalEmail + bodies: Array & { relation: ComponentRelation }> + mimeParts: Array & { relation: ComponentRelation }> + participants: Array<{ + relation: ParticipantRelation + address: SolvedBlock + }> + mailboxes: Array<{ relation: ContainsRelation; mailbox: SolvedBlock }> + flags: Array<{ flag: SolvedBlock }> + parents: Array> + references: Array> + embedded: Array<{ + body: Block + reference: string + mimePart: SolvedBlock + }> +} + +export function parseJson(value: string): unknown { + return JSON.parse(value) +} diff --git a/extensions/mail/src/views/DevHome.vue b/extensions/mail/src/views/DevHome.vue new file mode 100644 index 0000000..bc9800f --- /dev/null +++ b/extensions/mail/src/views/DevHome.vue @@ -0,0 +1,6 @@ + diff --git a/extensions/mail/tsconfig.json b/extensions/mail/tsconfig.json new file mode 100644 index 0000000..c133d16 --- /dev/null +++ b/extensions/mail/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "outDir": "./dist/types", + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "jsx": "preserve", + "jsxImportSource": "vue", + "skipLibCheck": true, + "paths": { + "@/*": ["../../apps/client-web/src/*"], + "@inkcre/core": ["../../packages/core/src/index.ts"], + "@inkcre/core/*": ["../../packages/core/src/*"] + } + }, + "include": ["src/**/*", "src/**/*.vue"] +} diff --git a/extensions/mail/vite.config.ts b/extensions/mail/vite.config.ts new file mode 100644 index 0000000..548de9a --- /dev/null +++ b/extensions/mail/vite.config.ts @@ -0,0 +1,68 @@ +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { defineConfig, searchForWorkspaceRoot } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueJsx from '@vitejs/plugin-vue-jsx' +import { federation } from '@module-federation/vite' + +import mfShared from '../mf-shared' +import { + createUiSourceAliases, + isPathInside, + resolveUiSourceForVite, + uiSourceDedupe, +} from '../../scripts/ui-source.mjs' + +const extensionRoot = fileURLToPath(new URL('.', import.meta.url)) +const extensionComponents = fileURLToPath(new URL('./src/components', import.meta.url)) + +export default defineConfig(async ({ command }) => { + const uiSource = await resolveUiSourceForVite(command) + const uiSourceComponents = uiSource ? path.resolve(uiSource.root, 'src/components') : null + + return { + plugins: [ + vue(), + vueJsx(), + federation({ + name: 'extension.mail', + filename: 'remoteEntry.js', + exposes: { '.': path.resolve(__dirname, './src/index.ts') }, + shared: mfShared, + }), + ], + resolve: { + alias: uiSource ? createUiSourceAliases(uiSource) : [], + ...(uiSource ? { dedupe: uiSourceDedupe } : {}), + }, + ...(uiSource + ? { + optimizeDeps: { exclude: ['@inkcre/ui-web'] }, + server: { + fs: { allow: [searchForWorkspaceRoot(extensionRoot), uiSource.root] }, + }, + } + : {}), + base: '/mail/client-web/', + build: { + target: 'esnext', + outDir: 'dist/client-web', + sourcemap: true, + }, + css: { + preprocessorOptions: { + scss: { + additionalData: (source: string, file: string) => { + if ( + isPathInside(extensionComponents, file) || + (uiSourceComponents && isPathInside(uiSourceComponents, file)) + ) { + return `@use "@inkcre/ui-web/styles/mixins" as *;@use "@inkcre/ui-web/styles/functions" as *;${source}` + } + return source + }, + }, + }, + }, + } +}) diff --git a/extensions/twitter/src/components/contentTweet/contentTweet.vue b/extensions/twitter/src/components/contentTweet/contentTweet.vue index 3436188..da3c952 100644 --- a/extensions/twitter/src/components/contentTweet/contentTweet.vue +++ b/extensions/twitter/src/components/contentTweet/contentTweet.vue @@ -1,9 +1,9 @@