From c7abc095093d64f382297138f34430417131254e Mon Sep 17 00:00:00 2001 From: Pierre Date: Sun, 25 Jan 2026 22:02:30 +0100 Subject: [PATCH] move feature from meta-sort to meta-core --- packages/meta-sort-core/package.json | 2 + .../src/api/UnifiedAPIServer.ts | 1028 +---------- packages/meta-sort-core/src/api/api.test.ts | 4 +- .../meta-sort-core/src/config/EnvConfig.ts | 82 +- .../src/container-plugins/ContainerManager.ts | 58 +- .../src/events/EventSubscriber.ts | 292 +++ packages/meta-sort-core/src/index.ts | 115 +- .../src/logic/pipeline/StreamingPipeline.ts | 35 +- packages/meta-sort-editor/README.md | 140 -- packages/meta-sort-editor/eslint.config.js | 28 - packages/meta-sort-editor/index.html | 13 - packages/meta-sort-editor/package-lock.json | 1570 ----------------- packages/meta-sort-editor/package.json | 23 - packages/meta-sort-editor/src/App.css | 185 -- packages/meta-sort-editor/src/App.tsx | 246 --- .../meta-sort-editor/src/api/metadataApi.ts | 151 -- .../src/components/BulkMetadataEditor.css | 382 ---- .../src/components/BulkMetadataEditor.tsx | 280 --- .../src/components/DynamicFieldRenderer.css | 297 ---- .../src/components/DynamicFieldRenderer.tsx | 378 ---- .../src/components/DynamicPluginSection.css | 63 - .../src/components/DynamicPluginSection.tsx | 126 -- .../src/components/FileList.css | 174 -- .../src/components/FileList.tsx | 123 -- .../src/components/KVTreeView.css | 247 --- .../src/components/KVTreeView.tsx | 216 --- .../src/components/LanguageCombobox.css | 149 -- .../src/components/LanguageCombobox.tsx | 196 -- .../src/components/MetadataEditor.css | 438 ----- .../src/components/MetadataEditor.tsx | 392 ---- .../src/components/SearchBar.css | 52 - .../src/components/SearchBar.tsx | 40 - packages/meta-sort-editor/src/index.css | 90 - packages/meta-sort-editor/src/main.tsx | 10 - packages/meta-sort-editor/src/types/index.ts | 155 -- .../meta-sort-editor/src/utils/formatting.ts | 53 - packages/meta-sort-editor/src/vite-env.d.ts | 1 - packages/meta-sort-editor/tsconfig.json | 25 - .../meta-sort-editor/tsconfig.tsbuildinfo | 1 - packages/meta-sort-editor/vite.config.ts | 22 - packages/meta-sort-ui/src/App.tsx | 4 +- packages/meta-sort-ui/src/pages/Mounts.tsx | 637 ------- packages/meta-sort-ui/src/pages/Welcome.tsx | 10 - pnpm-lock.yaml | 28 + 44 files changed, 488 insertions(+), 8073 deletions(-) create mode 100644 packages/meta-sort-core/src/events/EventSubscriber.ts delete mode 100644 packages/meta-sort-editor/README.md delete mode 100644 packages/meta-sort-editor/eslint.config.js delete mode 100644 packages/meta-sort-editor/index.html delete mode 100644 packages/meta-sort-editor/package-lock.json delete mode 100644 packages/meta-sort-editor/package.json delete mode 100644 packages/meta-sort-editor/src/App.css delete mode 100644 packages/meta-sort-editor/src/App.tsx delete mode 100644 packages/meta-sort-editor/src/api/metadataApi.ts delete mode 100644 packages/meta-sort-editor/src/components/BulkMetadataEditor.css delete mode 100644 packages/meta-sort-editor/src/components/BulkMetadataEditor.tsx delete mode 100644 packages/meta-sort-editor/src/components/DynamicFieldRenderer.css delete mode 100644 packages/meta-sort-editor/src/components/DynamicFieldRenderer.tsx delete mode 100644 packages/meta-sort-editor/src/components/DynamicPluginSection.css delete mode 100644 packages/meta-sort-editor/src/components/DynamicPluginSection.tsx delete mode 100644 packages/meta-sort-editor/src/components/FileList.css delete mode 100644 packages/meta-sort-editor/src/components/FileList.tsx delete mode 100644 packages/meta-sort-editor/src/components/KVTreeView.css delete mode 100644 packages/meta-sort-editor/src/components/KVTreeView.tsx delete mode 100644 packages/meta-sort-editor/src/components/LanguageCombobox.css delete mode 100644 packages/meta-sort-editor/src/components/LanguageCombobox.tsx delete mode 100644 packages/meta-sort-editor/src/components/MetadataEditor.css delete mode 100644 packages/meta-sort-editor/src/components/MetadataEditor.tsx delete mode 100644 packages/meta-sort-editor/src/components/SearchBar.css delete mode 100644 packages/meta-sort-editor/src/components/SearchBar.tsx delete mode 100644 packages/meta-sort-editor/src/index.css delete mode 100644 packages/meta-sort-editor/src/main.tsx delete mode 100644 packages/meta-sort-editor/src/types/index.ts delete mode 100644 packages/meta-sort-editor/src/utils/formatting.ts delete mode 100644 packages/meta-sort-editor/src/vite-env.d.ts delete mode 100644 packages/meta-sort-editor/tsconfig.json delete mode 100644 packages/meta-sort-editor/tsconfig.tsbuildinfo delete mode 100644 packages/meta-sort-editor/vite.config.ts delete mode 100644 packages/meta-sort-ui/src/pages/Mounts.tsx diff --git a/packages/meta-sort-core/package.json b/packages/meta-sort-core/package.json index 985f81e..360eb0a 100644 --- a/packages/meta-sort-core/package.json +++ b/packages/meta-sort-core/package.json @@ -25,6 +25,7 @@ "chokidar": "^3.5.3", "dockerode": "^4.0.2", "dotenv": "^16.4.1", + "eventsource": "^4.1.0", "fastify": "^5.6.1", "fluent-ffmpeg": "^2.1.2", "franc-min": "^6.2.0", @@ -42,6 +43,7 @@ "devDependencies": { "@types/chai": "^4.3.11", "@types/dockerode": "^3.3.31", + "@types/eventsource": "^3.0.0", "@types/fluent-ffmpeg": "^2.1.24", "@types/lodash": "^4.17.0", "@types/mocha": "^10.0.6", diff --git a/packages/meta-sort-core/src/api/UnifiedAPIServer.ts b/packages/meta-sort-core/src/api/UnifiedAPIServer.ts index 6a64b7f..ec34964 100644 --- a/packages/meta-sort-core/src/api/UnifiedAPIServer.ts +++ b/packages/meta-sort-core/src/api/UnifiedAPIServer.ts @@ -7,7 +7,6 @@ import Fastify, { FastifyInstance } from 'fastify'; import fastifyCors from '@fastify/cors'; -import { execSync } from 'child_process'; import { VirtualFileSystem } from './VirtualFileSystem.js'; import { performanceMetrics } from '../metrics/PerformanceMetrics.js'; import { UnifiedProcessingStateManager } from '../logic/UnifiedProcessingStateManager.js'; @@ -191,15 +190,9 @@ export class UnifiedAPIServer { // Duplicates API routes (under /api/duplicates) this.setupDuplicatesRoutes(); - // Metadata API routes (under /api/metadata) - Nested key architecture - if (this.kvClient) { - this.setupMetadataRoutes(); - } - - // KV Browser API routes (under /api/kv) - For editor UI - if (this.kvClient) { - this.setupKVBrowserRoutes(); - } + // NOTE: Metadata API routes (/api/metadata/*) and KV Browser routes (/api/kv/*) + // have been moved to meta-core Go API. The editor UI now uses meta-core's endpoints. + // Only /api/metadata/clear (with rescan) is kept here in setupScanRoutes(). // Unified processing state routes (under /api/processing) if (this.unifiedStateManager) { @@ -234,8 +227,8 @@ export class UnifiedAPIServer { // File download routes (under /api/file) this.setupFileRoutes(); - // Mount management routes (under /api/mounts) - this.setupMountRoutes(); + // NOTE: Mount management routes are now handled by meta-core (Architecture V3) + // Access mount API at http://meta-core/api/mounts/* or via the nginx proxy } /** @@ -292,420 +285,15 @@ export class UnifiedAPIServer { }); } - /** - * Setup Mount Management API routes - * Manages NFS, SMB, and rclone remote storage mounts - */ - private setupMountRoutes(): void { - const MOUNTS_DIR = `${config.META_CORE_PATH}/mounts`; - const MOUNTS_FILE = `${MOUNTS_DIR}/mounts.json`; - const ERRORS_DIR = `${MOUNTS_DIR}/errors`; - const FILES_PATH = config.FILES_PATH; - - // Helper: ensure directories exist - const ensureDirs = async () => { - const fs = await import('fs/promises'); - await fs.mkdir(MOUNTS_DIR, { recursive: true }); - await fs.mkdir(ERRORS_DIR, { recursive: true }); - }; - - // Helper: sanitize name for filesystem - const sanitizeName = (name: string): string => { - return name - .toLowerCase() - .replace(/[^a-z0-9-_]/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 64); - }; - - // Helper: read mounts config - const readMountsConfig = async (): Promise<{ version: number; mounts: any[] }> => { - const fs = await import('fs/promises'); - try { - const data = await fs.readFile(MOUNTS_FILE, 'utf-8'); - return JSON.parse(data); - } catch (e: any) { - if (e.code === 'ENOENT') { - return { version: 1, mounts: [] }; - } - throw e; - } - }; - - // Helper: write mounts config - const writeMountsConfig = async (mountsConfig: { version: number; mounts: any[] }) => { - const fs = await import('fs/promises'); - await ensureDirs(); - await fs.writeFile(MOUNTS_FILE, JSON.stringify(mountsConfig, null, 2)); - }; - - // Helper: check if path is mounted - const isMounted = (mountPath: string): boolean => { - try { - const output = execSync(`findmnt -n "${mountPath}" 2>/dev/null || true`, { - encoding: 'utf-8', - timeout: 5000 // 5 second timeout to prevent hanging on slow mounts - }); - return output.trim().length > 0; - } catch { - return false; - } - }; - - // Helper: read error for mount - const readError = async (id: string): Promise => { - const fs = await import('fs/promises'); - try { - const errorFile = `${ERRORS_DIR}/${id}.error`; - const content = await fs.readFile(errorFile, 'utf-8'); - const lines = content.trim().split('\n'); - return lines.slice(1).join('\n'); // Skip timestamp line - } catch { - return undefined; - } - }; - - // GET /api/mounts - List all mounts with status - this.app.get('/api/mounts', async (request, reply) => { - try { - await ensureDirs(); - const mountsConfig = await readMountsConfig(); - - const mounts = await Promise.all(mountsConfig.mounts.map(async (mount: any) => { - const mounted = isMounted(mount.mountPath); - const error = await readError(mount.id); - return { - ...mount, - mounted, - error, - lastChecked: Date.now() - }; - })); - - return { mounts }; - } catch (error: any) { - return reply.status(500).send({ error: error.message }); - } - }); - - // GET /api/mounts/rclone/remotes - List available rclone remotes - this.app.get('/api/mounts/rclone/remotes', async (request, reply) => { - try { - const response = await fetch('http://127.0.0.1:5572/config/listremotes', { - headers: { - 'Authorization': 'Basic ' + Buffer.from('admin:admin').toString('base64') - } - }); - - if (!response.ok) { - return { remotes: [] }; - } - - const data = await response.json() as { remotes?: string[] }; - const remoteNames = data.remotes || []; - - // Get type for each remote - const remotes = await Promise.all(remoteNames.map(async (name: string) => { - try { - const typeRes = await fetch('http://127.0.0.1:5572/config/get', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Basic ' + Buffer.from('admin:admin').toString('base64') - }, - body: JSON.stringify({ name: name.replace(/:$/, '') }) - }); - const typeData = await typeRes.json() as { type?: string }; - return { - name: name.replace(/:$/, ''), - type: typeData.type || 'unknown' - }; - } catch { - return { name: name.replace(/:$/, ''), type: 'unknown' }; - } - })); - - return { remotes }; - } catch (error: any) { - return { remotes: [] }; - } - }); - - // POST /api/mounts - Create new mount - interface CreateMountBody { - name?: string; - type?: 'nfs' | 'smb' | 'rclone'; - enabled?: boolean; - nfsServer?: string; - nfsPath?: string; - smbServer?: string; - smbShare?: string; - smbUsername?: string; - smbPassword?: string; - smbDomain?: string; - rcloneRemote?: string; - rclonePath?: string; - } - - this.app.post<{ Body: CreateMountBody }>('/api/mounts', async (request, reply) => { - const { randomUUID } = await import('crypto'); - const fs = await import('fs/promises'); - - try { - await ensureDirs(); - const body = request.body; - - if (!body.name) { - return reply.status(400).send({ error: 'Mount name is required' }); - } - - if (!body.type || !['nfs', 'smb', 'rclone'].includes(body.type)) { - return reply.status(400).send({ error: 'Valid mount type (nfs, smb, rclone) is required' }); - } - - const id = randomUUID(); - const safeName = sanitizeName(body.name); - const mountPath = `${FILES_PATH}/${safeName}`; - - // Check if mount path already exists in config - const existingConfig = await readMountsConfig(); - const pathExists = existingConfig.mounts.some((m: any) => m.mountPath === mountPath); - if (pathExists) { - return reply.status(400).send({ error: `Mount path ${mountPath} already configured` }); - } - - const mount: Record = { - id, - name: body.name, - type: body.type, - enabled: body.enabled !== false, - desiredMounted: body.enabled !== false, // Auto-mount if enabled - mountPath - }; - - // Type-specific fields - if (body.type === 'nfs') { - if (!body.nfsServer || !body.nfsPath) { - return reply.status(400).send({ error: 'NFS server and path are required' }); - } - mount.nfsServer = body.nfsServer; - mount.nfsPath = body.nfsPath; - } else if (body.type === 'smb') { - if (!body.smbServer || !body.smbShare) { - return reply.status(400).send({ error: 'SMB server and share are required' }); - } - mount.smbServer = body.smbServer; - mount.smbShare = body.smbShare; - - // Store credentials - password is obscured using rclone - if (body.smbUsername) { - mount.smbUsername = body.smbUsername; - } - if (body.smbPassword) { - try { - // Obscure password using rclone (prevents plaintext storage) - const obscured = execSync(`rclone obscure "${body.smbPassword.replace(/"/g, '\\"')}"`, { - encoding: 'utf-8', - timeout: 5000 - }).trim(); - mount.smbPasswordObscured = obscured; - } catch (err) { - console.error('[Mounts] Failed to obscure password:', err); - return reply.status(500).send({ error: 'Failed to secure password' }); - } - } - if (body.smbDomain) { - mount.smbDomain = body.smbDomain; - } - } else if (body.type === 'rclone') { - if (!body.rcloneRemote) { - return reply.status(400).send({ error: 'rclone remote is required' }); - } - mount.rcloneRemote = body.rcloneRemote; - mount.rclonePath = body.rclonePath || ''; - } - - existingConfig.mounts.push(mount); - await writeMountsConfig(existingConfig); - - console.log(`[Mounts] Created mount config: ${mount.name} (${mount.type}) -> ${mount.mountPath}`); - return { mount }; - } catch (error: any) { - console.error('[Mounts] Error creating mount:', error); - return reply.status(500).send({ error: error.message }); - } - }); - - // POST /api/mounts/:id/mount - Request mount - this.app.post<{ Params: { id: string } }>('/api/mounts/:id/mount', async (request, reply) => { - try { - const mountsConfig = await readMountsConfig(); - const mount = mountsConfig.mounts.find((m: any) => m.id === request.params.id); - - if (!mount) { - return reply.status(404).send({ error: 'Mount not found' }); - } - - mount.desiredMounted = true; - await writeMountsConfig(mountsConfig); - - console.log(`[Mounts] Mount requested: ${mount.name}`); - return { status: 'ok', message: 'Mount requested' }; - } catch (error: any) { - return reply.status(500).send({ error: error.message }); - } - }); - - // POST /api/mounts/:id/unmount - Request unmount - this.app.post<{ Params: { id: string } }>('/api/mounts/:id/unmount', async (request, reply) => { - try { - const mountsConfig = await readMountsConfig(); - const mount = mountsConfig.mounts.find((m: any) => m.id === request.params.id); - - if (!mount) { - return reply.status(404).send({ error: 'Mount not found' }); - } - - mount.desiredMounted = false; - await writeMountsConfig(mountsConfig); - - console.log(`[Mounts] Unmount requested: ${mount.name}`); - return { status: 'ok', message: 'Unmount requested' }; - } catch (error: any) { - return reply.status(500).send({ error: error.message }); - } - }); - - // POST /api/mounts/:id/safe-unmount - Safe unmount with queue drain - // Closes gate, waits for queues to empty, then unmounts - this.app.post<{ - Params: { id: string }; - Querystring: { timeout?: string }; - }>('/api/mounts/:id/safe-unmount', async (request, reply) => { - const timeoutMs = parseInt(request.query.timeout || '60000', 10); - - try { - const mountsConfig = await readMountsConfig(); - const mount = mountsConfig.mounts.find((m: any) => m.id === request.params.id); - - if (!mount) { - return reply.status(404).send({ error: 'Mount not found' }); - } - - // Step 1: Close the gate to stop new jobs - if (this.containerPluginScheduler) { - console.log(`[Mounts] Safe unmount: closing gate for ${mount.name}`); - this.containerPluginScheduler.setGate(false); - - // Step 2: Wait for queues to drain - console.log(`[Mounts] Safe unmount: waiting for queues to drain (timeout: ${timeoutMs}ms)`); - const drained = await this.containerPluginScheduler.waitForEmpty(timeoutMs); - - if (!drained) { - // Re-open gate and abort - this.containerPluginScheduler.setGate(true); - const gateStatus = this.containerPluginScheduler.getGateStatus(); - return reply.status(408).send({ - status: 'timeout', - message: `Queue did not drain within ${timeoutMs}ms. Gate re-opened.`, - gateStatus - }); - } - } - - // Step 3: Request unmount - mount.desiredMounted = false; - await writeMountsConfig(mountsConfig); - - // Step 4: Wait for unmount to complete - console.log(`[Mounts] Safe unmount: waiting for mount to detach: ${mount.name}`); - let unmountSuccess = false; - for (let i = 0; i < 30; i++) { // 15 seconds max wait - if (!isMounted(mount.mountPath)) { - unmountSuccess = true; - break; - } - await new Promise(r => setTimeout(r, 500)); - } - - // Step 5: Re-open the gate - if (this.containerPluginScheduler) { - console.log(`[Mounts] Safe unmount: re-opening gate`); - this.containerPluginScheduler.setGate(true); - } - - if (!unmountSuccess) { - console.warn(`[Mounts] Safe unmount: mount still attached after timeout: ${mount.name}`); - return { - status: 'warning', - message: 'Unmount requested but mount may still be attached', - gateStatus: this.containerPluginScheduler?.getGateStatus() ?? null - }; - } - - console.log(`[Mounts] Safe unmount completed: ${mount.name}`); - return { - status: 'ok', - message: 'Safe unmount completed', - gateStatus: this.containerPluginScheduler?.getGateStatus() ?? null - }; - } catch (error: any) { - // Re-open gate on error - if (this.containerPluginScheduler) { - this.containerPluginScheduler.setGate(true); - } - console.error(`[Mounts] Safe unmount error: ${error.message}`); - return reply.status(500).send({ error: error.message }); - } - }); - - // DELETE /api/mounts/:id - Remove mount config - this.app.delete<{ Params: { id: string } }>('/api/mounts/:id', async (request, reply) => { - const fs = await import('fs/promises'); - - try { - const mountsConfig = await readMountsConfig(); - const index = mountsConfig.mounts.findIndex((m: any) => m.id === request.params.id); - - if (index === -1) { - return reply.status(404).send({ error: 'Mount not found' }); - } - - const mount = mountsConfig.mounts[index]; - - // Request unmount first - mount.desiredMounted = false; - await writeMountsConfig(mountsConfig); - - // Wait for unmount (poll for up to 15 seconds) - for (let i = 0; i < 30; i++) { - if (!isMounted(mount.mountPath)) break; - await new Promise(r => setTimeout(r, 500)); - } - - // Remove from config - mountsConfig.mounts.splice(index, 1); - await writeMountsConfig(mountsConfig); - - // Clean up files - try { - await fs.unlink(`${ERRORS_DIR}/${mount.id}.error`); - } catch { /* ignore */ } - try { - await fs.rmdir(mount.mountPath); - } catch { /* ignore */ } - - console.log(`[Mounts] Deleted mount: ${mount.name}`); - return { status: 'ok' }; - } catch (error: any) { - console.error('[Mounts] Error deleting mount:', error); - return reply.status(500).send({ error: error.message }); - } - }); - - console.log('[UnifiedAPIServer] Mount management routes registered at /api/mounts/*'); - } + // NOTE: Mount management routes have been moved to meta-core (Architecture V3) + // Access the mount API at http://meta-core/api/mounts/* + // - GET /api/mounts - List all mounts + // - POST /api/mounts - Create mount + // - GET /api/mounts/:id - Get mount + // - DELETE /api/mounts/:id - Delete mount + // - POST /api/mounts/:id/mount - Request mount + // - POST /api/mounts/:id/unmount - Request unmount + // - GET /api/mounts/rclone/remotes - List rclone remotes /** * Setup FUSE API routes @@ -956,575 +544,31 @@ export class UnifiedAPIServer { }); } - /** - * Setup Metadata API routes (nested key architecture) - * Provides access to file metadata stored in KV using the nested key structure - */ - private setupMetadataRoutes(): void { - if (!this.kvClient) { - return; - } - - // Get all hash IDs (all files in KV) - this.app.get('/api/metadata/hash-ids', async (request, reply) => { - try { - const hashIds = await this.kvClient!.getAllHashIds(); - return { - hashIds, - count: hashIds.length - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to retrieve hash IDs', - details: error.message - }); - } - }); - - // List all files with metadata (for Files view) - this.app.get<{ - Querystring: { limit?: string; offset?: string } - }>('/api/metadata/list', async (request, reply) => { - const limit = parseInt(request.query.limit || '100', 10); - const offset = parseInt(request.query.offset || '0', 10); - - try { - const allHashIds = await this.kvClient!.getAllHashIds(); - const paginatedIds = allHashIds.slice(offset, offset + limit); - - const files = await Promise.all( - paginatedIds.map(async (hashId) => { - const metadata = await this.kvClient!.getMetadataFlat(hashId); - if (!metadata) return null; - - // Extract fields for Files view - return { - hashId, - filename: metadata.fileName || metadata.filename || 'Unknown', - path: metadata.filePath || metadata.path || '', - size: metadata.fileSize || metadata.size || 0, - mimeType: metadata.mimeType, - mediaType: metadata.videoType === 'tvshow' || metadata.videoType === 'movie' ? 'video' : metadata.mediaType, - duration: metadata.fileinfo?.streamdetails?.video?.[0]?.durationinseconds || metadata.duration, - resolution: metadata.fileinfo?.streamdetails?.video?.[0]?.width && metadata.fileinfo?.streamdetails?.video?.[0]?.height - ? `${metadata.fileinfo.streamdetails.video[0].width}x${metadata.fileinfo.streamdetails.video[0].height}` - : metadata.resolution, - codec: metadata.fileinfo?.streamdetails?.video?.[0]?.codec || metadata.codec, - createdAt: metadata.createdAt, - processedAt: metadata.processedAt || metadata.mtime - }; - }) - ); - - return { - files: files.filter(f => f !== null), - total: allHashIds.length, - limit, - offset - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to list files', - details: error.message - }); - } - }); - - // Get complete metadata for a file by hash ID - this.app.get<{ Params: { hashId: string } }>('/api/metadata/:hashId', async (request, reply) => { - const { hashId } = request.params; - - if (!hashId) { - return reply.status(400).send({ - error: 'Missing hash ID parameter' - }); - } - - try { - const metadata = await this.kvClient!.getMetadataFlat(hashId); - - if (!metadata) { - return reply.status(404).send({ - error: 'Metadata not found for hash ID', - hashId - }); - } - - return metadata; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to retrieve metadata', - details: error.message - }); - } - }); - - // Get a specific property for a file by hash ID and property path - this.app.get<{ - Params: { hashId: string }; - Querystring: { property: string } - }>('/api/metadata/:hashId/property', async (request, reply) => { - const { hashId } = request.params; - const { property } = request.query; - - if (!hashId || !property) { - return reply.status(400).send({ - error: 'Missing hash ID or property parameter' - }); - } - - try { - const key = `/file/${hashId}/${property}`; - const value = await this.kvClient!.getProperty(key); - - if (value === null) { - return reply.status(404).send({ - error: 'Property not found', - hashId, - property - }); - } - - return { - hashId, - property, - value - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to retrieve property', - details: error.message - }); - } - }); - - // Get all properties matching a specific property path across all files - // Example: /api/metadata/query?property=video/codec to get all video codecs - this.app.get<{ - Querystring: { property: string } - }>('/api/metadata/query', async (request, reply) => { - const { property } = request.query; - - if (!property) { - return reply.status(400).send({ - error: 'Missing property parameter' - }); - } - - try { - // Get all hash IDs - const hashIds = await this.kvClient!.getAllHashIds(); - - // Query each file for the specified property - const results: Array<{ hashId: string; value: string }> = []; - - for (const hashId of hashIds) { - const key = `/file/${hashId}/${property}`; - const value = await this.kvClient!.getProperty(key); - - if (value !== null) { - results.push({ hashId, value }); - } - } - - return { - property, - count: results.length, - results - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to query property', - details: error.message - }); - } - }); - - // Update complete metadata for a file by hash ID - this.app.put<{ - Params: { hashId: string }; - Body: any - }>('/api/metadata/:hashId', async (request, reply) => { - const { hashId } = request.params; - const metadata = request.body; - - if (!hashId) { - return reply.status(400).send({ - error: 'Missing hash ID parameter' - }); - } - - if (!metadata || typeof metadata !== 'object') { - return reply.status(400).send({ - error: 'Invalid metadata: must be an object' - }); - } - - try { - // Store the updated metadata (excludes internal processing status) - await this.kvClient!.setMetadataFlat(hashId, metadata, ['processingStatus']); - - return { - status: 'ok', - hashId, - message: 'Metadata updated successfully' - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to update metadata', - details: error.message - }); - } - }); - - // Update a specific property for a file by hash ID - this.app.put<{ - Params: { hashId: string }; - Body: { property: string; value: string } - }>('/api/metadata/:hashId/property', async (request, reply) => { - const { hashId } = request.params; - const { property, value } = request.body; - - if (!hashId || !property) { - return reply.status(400).send({ - error: 'Missing hash ID or property parameter' - }); - } - - if (value === undefined || value === null) { - return reply.status(400).send({ - error: 'Missing value parameter' - }); - } - - try { - // Write directly to Redis Hash (not flat keys) - await this.kvClient!.setMetadataProperty(hashId, property, String(value)); - - return { - status: 'ok', - hashId, - property, - value, - message: 'Property updated successfully' - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to update property', - details: error.message - }); - } - }); - - // Search for files by various criteria - this.app.post<{ - Body: { - query?: string; // Search in title, filename, originaltitle - hashId?: string; // Exact hash ID match - property?: string; // Search by specific property - propertyValue?: string; // Value to match for property search - limit?: number; - } - }>('/api/metadata/search', async (request, reply) => { - const { query, hashId, property, propertyValue, limit = 100 } = request.body; - - try { - // If hashId is provided, do exact match - if (hashId) { - const metadata = await this.kvClient!.getMetadataFlat(hashId); - if (metadata) { - return { - results: [{ hashId, metadata }], - count: 1 - }; - } - return { results: [], count: 0 }; - } - - // Get all hash IDs - const hashIds = await this.kvClient!.getAllHashIds(); - const results: Array<{ hashId: string; metadata: any }> = []; - - // Search through files - for (const id of hashIds) { - if (results.length >= limit) break; - - const metadata = await this.kvClient!.getMetadataFlat(id); - if (!metadata) continue; - - let matches = false; - - // Property search - if (property && propertyValue !== undefined) { - const value = this.getNestedProperty(metadata, property); - if (value && String(value).toLowerCase().includes(String(propertyValue).toLowerCase())) { - matches = true; - } - } - - // General query search (searches title, filename, originaltitle, filepath, and hashId) - if (query && !property) { - const searchFields = ['title', 'fileName', 'originaltitle', 'showtitle', 'filePath']; - const queryLower = query.toLowerCase(); - - // Check if query matches hashId - if (id.toLowerCase().includes(queryLower)) { - matches = true; - } - - // Check other fields - if (!matches) { - for (const field of searchFields) { - const value = this.getNestedProperty(metadata, field); - if (value && String(value).toLowerCase().includes(queryLower)) { - matches = true; - break; - } - } - } - } - - if (matches || (!query && !property)) { - results.push({ hashId: id, metadata }); - } - } - - return { - results, - count: results.length, - total: hashIds.length - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to search metadata', - details: error.message - }); - } - }); - - // Batch update multiple files - this.app.post<{ - Body: { - updates: Array<{ - hashId: string; - metadata?: any; // Complete metadata update - properties?: Record; // Property updates - }> - } - }>('/api/metadata/batch', async (request, reply) => { - const { updates } = request.body; - - if (!updates || !Array.isArray(updates)) { - return reply.status(400).send({ - error: 'Invalid updates: must be an array' - }); - } - - try { - const results: Array<{ hashId: string; status: string; error?: string }> = []; - - for (const update of updates) { - const { hashId, metadata, properties } = update; - - if (!hashId) { - results.push({ - hashId: 'unknown', - status: 'error', - error: 'Missing hash ID' - }); - continue; - } - - try { - // Update complete metadata - if (metadata) { - await this.kvClient!.setMetadataFlat(hashId, metadata, ['processingStatus']); - } - - // Update individual properties (write to Redis Hash) - if (properties) { - for (const [property, value] of Object.entries(properties)) { - await this.kvClient!.setMetadataProperty(hashId, property, String(value)); - } - } - - results.push({ - hashId, - status: 'ok' - }); - } catch (error: any) { - results.push({ - hashId, - status: 'error', - error: error.message - }); - } - } - - const successCount = results.filter(r => r.status === 'ok').length; - const errorCount = results.filter(r => r.status === 'error').length; - - return { - status: 'completed', - total: results.length, - success: successCount, - errors: errorCount, - results - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to batch update metadata', - details: error.message - }); - } - }); - - // Delete metadata for a file by hash ID - this.app.delete<{ Params: { hashId: string } }>('/api/metadata/:hashId', async (request, reply) => { - const { hashId } = request.params; - - if (!hashId) { - return reply.status(400).send({ - error: 'Missing hash ID parameter' - }); - } - - try { - const deletedCount = await this.kvClient!.deleteMetadataFlat(hashId); - - return { - status: 'ok', - hashId, - deletedKeys: deletedCount - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to delete metadata', - details: error.message - }); - } - }); - } + // NOTE: setupMetadataRoutes() and setupKVBrowserRoutes() have been removed. + // The metadata editor API (/api/metadata/*) and KV browser API (/api/kv/*) + // are now implemented in meta-core Go API. + // Only /api/metadata/clear (with rescan trigger) is kept in setupScanRoutes(). /** - * Setup KV Browser API routes (for editor UI) - * Provides raw access to browse Redis keys and values + * REMOVED - Metadata API routes moved to meta-core Go API + * The following routes are now handled by meta-core: + * - GET /api/metadata/hash-ids + * - GET /api/metadata/list + * - GET /api/metadata/:hashId + * - PUT /api/metadata/:hashId + * - DELETE /api/metadata/:hashId + * - GET /api/metadata/:hashId/property + * - PUT /api/metadata/:hashId/property + * - POST /api/metadata/search + * - POST /api/metadata/batch + * - POST /api/metadata/clear (without rescan - see setupScanRoutes for clear+rescan) + * + * REMOVED - KV Browser API routes moved to meta-core Go API + * The following routes are now handled by meta-core: + * - GET /api/kv/info + * - GET /api/kv/keys + * - GET /api/kv/key/:key */ - private setupKVBrowserRoutes(): void { - if (!this.kvClient) { - return; - } - - // Get KV database info - this.app.get('/api/kv/info', async (request, reply) => { - try { - const hashIds = await this.kvClient!.getAllHashIds(); - const redis = (this.kvClient as any).getRedisClient?.(); - - let memoryUsage = 'N/A'; - if (redis) { - try { - const info = await redis.info('memory'); - const match = info.match(/used_memory_human:(\S+)/); - if (match) { - memoryUsage = match[1]; - } - } catch { - // Ignore memory info errors - } - } - - // Get total size from Redis sizeByte (eventually correct as files are processed) - const totalSize = await this.getTotalFileSizeFromRedis(); - - return { - prefix: 'file:', - fileCount: hashIds.length, - keyCount: hashIds.length, - totalSize, - memoryUsage - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to get KV info', - details: error.message - }); - } - }); - - // List keys with cursor pagination - this.app.get<{ - Querystring: { cursor?: string; count?: string } - }>('/api/kv/keys', async (request, reply) => { - const cursor = request.query.cursor || '0'; - const count = parseInt(request.query.count || '50', 10); - - try { - const allHashIds = await this.kvClient!.getAllHashIds(); - - // Simple offset-based pagination using cursor as offset - const offset = parseInt(cursor, 10) || 0; - const keys = allHashIds.slice(offset, offset + count).map(id => `file:${id}`); - const nextOffset = offset + keys.length; - const hasMore = nextOffset < allHashIds.length; - - return { - keys, - cursor: hasMore ? String(nextOffset) : '0', - hasMore - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to list keys', - details: error.message - }); - } - }); - - // Get value for a specific key - this.app.get<{ - Params: { key: string } - }>('/api/kv/key/:key', async (request, reply) => { - const { key } = request.params; - - if (!key) { - return reply.status(400).send({ - error: 'Missing key parameter' - }); - } - - try { - // Extract hash ID from key (format: file:{hashId}) - const hashIdMatch = key.match(/^file:(.+)$/); - if (!hashIdMatch) { - return reply.status(400).send({ - error: 'Invalid key format. Expected: file:{hashId}' - }); - } - - const hashId = hashIdMatch[1]; - const metadata = await this.kvClient!.getMetadataFlat(hashId); - - if (!metadata) { - return reply.status(404).send({ - error: 'Key not found', - key - }); - } - - return { - key, - type: 'hash', - value: metadata - }; - } catch (error: any) { - return reply.status(500).send({ - error: 'Failed to get key value', - details: error.message - }); - } - }); - } /** * Setup Unified Processing State API routes (4-state pipeline) diff --git a/packages/meta-sort-core/src/api/api.test.ts b/packages/meta-sort-core/src/api/api.test.ts index 4b2ec27..523c3f5 100644 --- a/packages/meta-sort-core/src/api/api.test.ts +++ b/packages/meta-sort-core/src/api/api.test.ts @@ -839,10 +839,10 @@ describe('UnifiedAPIServer', function() { }); // ========================================================================= - // Mounts API (Mounts Page) + // Mounts API (Mounts Page) - SKIPPED in Architecture V3 (moved to meta-core) // ========================================================================= - describe('Mounts API (/api/mounts/*)', function() { + describe.skip('Mounts API (/api/mounts/*)', function() { it('GET /api/mounts returns mount list', async function() { const response = await app.inject({ method: 'GET', diff --git a/packages/meta-sort-core/src/config/EnvConfig.ts b/packages/meta-sort-core/src/config/EnvConfig.ts index 423c82a..e06556e 100644 --- a/packages/meta-sort-core/src/config/EnvConfig.ts +++ b/packages/meta-sort-core/src/config/EnvConfig.ts @@ -1,24 +1,13 @@ import dotenv from 'dotenv'; dotenv.config(); -export interface PollingPathConfig { - path: string; - intervalMs: number; -} - interface EnvConfig { - /** Comma-separated list of folders to watch for changes.(mandatory) */ - WATCH_FOLDER_LIST: string; - /** Path to the index file within the NFO CID library. */ INDEX_FOLDER_PATH: string; /** Interval in milliseconds to update/check for changes. (default 30000) */ UPDATE_INTERVAL_MS: number; - /** Interval in milliseconds for polling watch folders. Set to 0 to disable polling. (default 0) */ - WATCH_FOLDER_POOLING_INTERVAL_MS: number; - /** CHMOD for new folder format (default 0o777) */ CHMOD_FOR_NEW_FOLDER: number; @@ -41,11 +30,8 @@ interface EnvConfig { /** Maximum number of worker threads for Piscina worker pool AND max concurrent file processing (default: CPU count) */ MAX_WORKER_THREADS?: number; - /** JSON array of polling path configurations. Example: '[{"path": "/data/watch/smb/downloads", "intervalMs": 60000}]' (optional) */ - POLLING_PATHS?: PollingPathConfig[]; - // ======================================================================== - // KV Storage Configuration (NEW) + // KV Storage Configuration // ======================================================================== /** Path to META_CORE_VOLUME - infrastructure volume for KV DB, locks, services (default '/meta-core') */ @@ -123,54 +109,30 @@ interface EnvConfig { // NOTE: Plugin output files are written via WebDAV (WEBDAV_URL/plugin//) // No PLUGIN_OUTPUT_HOST_PATH needed - plugins use HTTP PUT to write files -} -// Parse POLLING_PATHS from JSON string -function parsePollingPaths(): PollingPathConfig[] | undefined { - if (!process.env.POLLING_PATHS) { - return undefined; - } + // ======================================================================== + // Event Subscriber Configuration (Architecture V3) + // ======================================================================== - try { - const parsed = JSON.parse(process.env.POLLING_PATHS); - if (!Array.isArray(parsed)) { - console.error('POLLING_PATHS must be a JSON array'); - return undefined; - } - - // Validate structure - const valid = parsed.every(item => - item && typeof item === 'object' && - typeof item.path === 'string' && - typeof item.intervalMs === 'number' - ); - - if (!valid) { - console.error('POLLING_PATHS entries must have {path: string, intervalMs: number}'); - return undefined; - } - - return parsed as PollingPathConfig[]; - } catch (error) { - console.error('Failed to parse POLLING_PATHS:', error); - return undefined; - } + /** + * URL of meta-core API for event subscription (SSE). + * REQUIRED: meta-sort receives file events from meta-core via SSE. + * Example: "http://meta-core" or "http://localhost:8083" + */ + META_CORE_URL?: string; } export const config: EnvConfig = { - WATCH_FOLDER_LIST: process.env.WATCH_FOLDER_LIST, - INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash-index',//default /data/cache/hash-index - UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),//default 30000 - WATCH_FOLDER_POOLING_INTERVAL_MS: parseInt(process.env.WATCH_FOLDER_POOLING_INTERVAL_MS || "0", 10),//default 0 - CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),//default 0o777 (this is the default for mkdir) - CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache',//default /data/cache + INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash-index', + UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10), + CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8), + CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache', JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!, JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!, - FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),//default 3000 - FUSE_API_HOST: process.env.FUSE_API_HOST || '0.0.0.0',//default 0.0.0.0 - METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),//default 'meta' + FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10), + FUSE_API_HOST: process.env.FUSE_API_HOST || '0.0.0.0', + METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0), MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined, - POLLING_PATHS: parsePollingPaths(), // KV Storage Configuration META_CORE_PATH: process.env.META_CORE_PATH || '/meta-core', @@ -194,11 +156,7 @@ export const config: EnvConfig = { PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL, PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME, PLUGIN_CACHE_HOST_PATH: process.env.PLUGIN_CACHE_HOST_PATH, + + // Event Subscriber Configuration + META_CORE_URL: process.env.META_CORE_URL, }; - -if(process.env.TEST!=='true') { - if (!config.WATCH_FOLDER_LIST) { - console.error('Invalid configuration: WATCH_FOLDER_LIST is required'); - } -} - diff --git a/packages/meta-sort-core/src/container-plugins/ContainerManager.ts b/packages/meta-sort-core/src/container-plugins/ContainerManager.ts index d782abb..729390b 100644 --- a/packages/meta-sort-core/src/container-plugins/ContainerManager.ts +++ b/packages/meta-sort-core/src/container-plugins/ContainerManager.ts @@ -119,9 +119,31 @@ export class ContainerManager extends EventEmitter { /** * Discover service URLs from meta-core service discovery file - * Falls back to environment variables if discovery fails + * Explicit env vars (CONTAINER_CALLBACK_URL, PLUGIN_WEBDAV_URL) take priority + * Service discovery is only used as fallback */ private async discoverServiceUrls(): Promise { + // Check if explicit env vars are set - these take priority for container communication + const explicitCallbackUrl = config.CONTAINER_CALLBACK_URL; + const explicitWebdavUrl = config.PLUGIN_WEBDAV_URL; + + if (explicitCallbackUrl) { + this.callbackUrl = explicitCallbackUrl; + console.log(`[ContainerManager] Using explicit callback URL: ${this.callbackUrl}`); + } + + if (explicitWebdavUrl) { + this.webdavUrl = explicitWebdavUrl; + console.log(`[ContainerManager] Using explicit WebDAV URL: ${this.webdavUrl}`); + } + + // If both are explicitly set, skip service discovery + if (explicitCallbackUrl && explicitWebdavUrl) { + console.log('[ContainerManager] Using explicit env vars, skipping service discovery'); + return; + } + + // Fall back to service discovery for any missing URLs const serviceFile = join(config.META_CORE_PATH, 'services', 'meta-sort.json'); try { @@ -132,23 +154,27 @@ export class ContainerManager extends EventEmitter { const api = serviceInfo.api; const endpoints = serviceInfo.endpoints || {}; - // Update URLs from service discovery - if (endpoints.callback) { - this.callbackUrl = endpoints.callback; - console.log(`[ContainerManager] Discovered callback URL: ${this.callbackUrl}`); - } else if (api) { - // Fallback: construct from api base - this.callbackUrl = `${api}/api/plugins/callback`; - console.log(`[ContainerManager] Constructed callback URL: ${this.callbackUrl}`); + // Only use service discovery if env var not set + if (!explicitCallbackUrl) { + if (endpoints.callback) { + this.callbackUrl = endpoints.callback; + console.log(`[ContainerManager] Discovered callback URL: ${this.callbackUrl}`); + } else if (api) { + // Fallback: construct from api base + this.callbackUrl = `${api}/api/plugins/callback`; + console.log(`[ContainerManager] Constructed callback URL: ${this.callbackUrl}`); + } } - if (endpoints.webdav) { - this.webdavUrl = endpoints.webdav; - console.log(`[ContainerManager] Discovered WebDAV URL: ${this.webdavUrl}`); - } else if (api) { - // Fallback: construct from api base - this.webdavUrl = `${api}/webdav`; - console.log(`[ContainerManager] Constructed WebDAV URL: ${this.webdavUrl}`); + if (!explicitWebdavUrl) { + if (endpoints.webdav) { + this.webdavUrl = endpoints.webdav; + console.log(`[ContainerManager] Discovered WebDAV URL: ${this.webdavUrl}`); + } else if (api) { + // Fallback: construct from api base + this.webdavUrl = `${api}/webdav`; + console.log(`[ContainerManager] Constructed WebDAV URL: ${this.webdavUrl}`); + } } if (endpoints.health) { diff --git a/packages/meta-sort-core/src/events/EventSubscriber.ts b/packages/meta-sort-core/src/events/EventSubscriber.ts new file mode 100644 index 0000000..fb9d19c --- /dev/null +++ b/packages/meta-sort-core/src/events/EventSubscriber.ts @@ -0,0 +1,292 @@ +/** + * EventSubscriber + * + * Subscribes to file events from meta-core via SSE (Server-Sent Events). + * When events are received, they are forwarded to the StreamingPipeline. + * + * This enables meta-sort to run without direct filesystem access - it receives + * file discovery events from meta-core and processes files via WebDAV. + */ + +import { EventSource } from 'eventsource'; + +// File event types matching meta-core's watcher +interface FileEvent { + type: 'add' | 'change' | 'delete' | 'rename'; + path: string; // Relative to FILES_PATH + size?: number; + timestamp: number; + partialHash?: string; // Hash of first 64KB + oldPath?: string; // For rename events +} + +// Pipeline interface (minimal, matching StreamingPipeline) +interface Pipeline { + handleFileAdded(filePath: string): void; + handleFileChanged(filePath: string): void; + handleFileDeleted(filePath: string): void; +} + +export interface EventSubscriberOptions { + /** URL of meta-core API (e.g., http://meta-core or http://localhost:8083) */ + metaCoreUrl: string; + + /** Pipeline to receive events */ + pipeline: Pipeline; + + /** Base path for files (e.g., /files). Paths from meta-core are relative and need this prefix. */ + filesPath?: string; + + /** Whether to request an initial scan when connecting */ + requestInitialScan?: boolean; + + /** Reconnect delay in milliseconds */ + reconnectDelayMs?: number; + + /** Maximum reconnect attempts (0 = unlimited) */ + maxReconnectAttempts?: number; +} + +export class EventSubscriber { + private options: Required; + private eventSource: EventSource | null = null; + private isRunning = false; + private reconnectAttempts = 0; + private reconnectTimer: NodeJS.Timeout | null = null; + + constructor(options: EventSubscriberOptions) { + this.options = { + filesPath: '/files', + requestInitialScan: true, + reconnectDelayMs: 5000, + maxReconnectAttempts: 0, // Unlimited by default + ...options + }; + } + + /** + * Start subscribing to events from meta-core + */ + async start(): Promise { + if (this.isRunning) { + console.log('[EventSubscriber] Already running'); + return; + } + + this.isRunning = true; + console.log(`[EventSubscriber] Connecting to ${this.options.metaCoreUrl}/api/events/subscribe`); + + await this.connect(); + } + + /** + * Stop subscribing to events + */ + async stop(): Promise { + this.isRunning = false; + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + console.log('[EventSubscriber] Stopped'); + } + + /** + * Trigger a manual scan on meta-core + */ + async triggerScan(): Promise { + try { + const response = await fetch(`${this.options.metaCoreUrl}/api/scan/trigger`, { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + console.log('[EventSubscriber] Scan triggered on meta-core'); + } catch (error) { + console.error('[EventSubscriber] Failed to trigger scan:', error); + } + } + + /** + * Get watcher status from meta-core + */ + async getStatus(): Promise<{ + status: string; + scanning: boolean; + lastScan: number; + fileCount: number; + } | null> { + try { + const response = await fetch(`${this.options.metaCoreUrl}/api/scan/status`); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch (error) { + console.error('[EventSubscriber] Failed to get status:', error); + return null; + } + } + + /** + * Poll for events since a timestamp (fallback for SSE) + */ + async pollEvents(sinceMs: number, limit = 100): Promise { + try { + const url = `${this.options.metaCoreUrl}/api/events/poll?since=${sinceMs}&limit=${limit}`; + const response = await fetch(url); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + return data.events || []; + } catch (error) { + console.error('[EventSubscriber] Failed to poll events:', error); + return []; + } + } + + private async connect(): Promise { + if (!this.isRunning) return; + + const url = `${this.options.metaCoreUrl}/api/events/subscribe`; + + try { + this.eventSource = new EventSource(url); + + this.eventSource.onopen = () => { + console.log('[EventSubscriber] Connected to meta-core SSE'); + this.reconnectAttempts = 0; + + // Request initial scan if configured + if (this.options.requestInitialScan) { + this.triggerScan().catch(err => { + console.error('[EventSubscriber] Failed to trigger initial scan:', err); + }); + } + }; + + this.eventSource.onerror = (error) => { + console.error('[EventSubscriber] SSE connection error:', error); + this.scheduleReconnect(); + }; + + // Handle file events + this.eventSource.addEventListener('file', (event: MessageEvent) => { + try { + const fileEvent: FileEvent = JSON.parse(event.data); + this.handleEvent(fileEvent); + } catch (error) { + console.error('[EventSubscriber] Failed to parse event:', error); + } + }); + + // Handle connected event + this.eventSource.addEventListener('connected', () => { + console.log('[EventSubscriber] Received connected event'); + }); + + } catch (error) { + console.error('[EventSubscriber] Failed to connect:', error); + this.scheduleReconnect(); + } + } + + private handleEvent(event: FileEvent): void { + const { pipeline, filesPath } = this.options; + + // Convert relative path to absolute path + const toAbsolutePath = (relativePath: string): string => { + // If path is already absolute, return as-is + if (relativePath.startsWith('/')) { + return relativePath; + } + // Otherwise, prepend filesPath + return `${filesPath}/${relativePath}`; + }; + + const absolutePath = toAbsolutePath(event.path); + + switch (event.type) { + case 'add': + pipeline.handleFileAdded(absolutePath); + break; + + case 'change': + pipeline.handleFileChanged(absolutePath); + break; + + case 'delete': + pipeline.handleFileDeleted(absolutePath); + break; + + case 'rename': + // Handle rename as delete + add + if (event.oldPath) { + pipeline.handleFileDeleted(toAbsolutePath(event.oldPath)); + } + pipeline.handleFileAdded(absolutePath); + break; + + default: + console.warn(`[EventSubscriber] Unknown event type: ${event.type}`); + } + } + + private scheduleReconnect(): void { + if (!this.isRunning) return; + + // Close existing connection + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + + // Check reconnect limits + if (this.options.maxReconnectAttempts > 0 && + this.reconnectAttempts >= this.options.maxReconnectAttempts) { + console.error('[EventSubscriber] Max reconnect attempts reached, giving up'); + this.isRunning = false; + return; + } + + this.reconnectAttempts++; + const delay = this.options.reconnectDelayMs * Math.min(this.reconnectAttempts, 6); // Max 30s + + console.log(`[EventSubscriber] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } +} + +/** + * Create an EventSubscriber with default configuration + */ +export function createEventSubscriber( + metaCoreUrl: string, + pipeline: Pipeline, + options?: Partial +): EventSubscriber { + return new EventSubscriber({ + metaCoreUrl, + pipeline, + ...options + }); +} diff --git a/packages/meta-sort-core/src/index.ts b/packages/meta-sort-core/src/index.ts index b32fa99..704f237 100644 --- a/packages/meta-sort-core/src/index.ts +++ b/packages/meta-sort-core/src/index.ts @@ -1,4 +1,3 @@ -import {FolderWatcher, PollingWatcher} from "@metazla/meta-hash"; import {config} from "./config/EnvConfig.js"; import os from 'os'; @@ -12,6 +11,7 @@ import {KVManager} from "./kv/KVManager.js"; import {getPluginManagerInstance, initializePluginManager} from "./logic/fileProcessor/FileProcessorPiscina.js"; import {ContainerManager} from "./container-plugins/ContainerManager.js"; import {ContainerPluginScheduler} from "./container-plugins/ContainerPluginScheduler.js"; +import {EventSubscriber} from "./events/EventSubscriber.js"; try { // Get current user information @@ -31,7 +31,6 @@ console.log(config); process.umask(0);//This unable the process to create files with 777 permissions let fileProcessor = new WatchedFileProcessor(); -let folders = config.WATCH_FOLDER_LIST; // KV Manager for Redis-based storage with leader election (optional) let kvManager: KVManager | null = null; @@ -40,6 +39,9 @@ let kvManager: KVManager | null = null; let containerManager: ContainerManager | null = null; let containerPluginScheduler: ContainerPluginScheduler | null = null; +// Event Subscriber for receiving events from meta-core (Architecture V3) +let eventSubscriber: EventSubscriber | null = null; + // Calculate concurrency for pipeline stages const cpuCount = os.cpus().length; // Smart defaults optimized for network storage + HDD scenarios: @@ -69,13 +71,6 @@ const pipeline = new StreamingPipeline({ // Connect pipeline to fileProcessor for queue status monitoring fileProcessor.setPipeline(pipeline); -// Create generic file discovery -const folderWatcher = new FolderWatcher(); - -// Parse folder list -const folderList = folders.split(',').map(f => f.trim()).filter(f => f.length > 0); -console.log(`Watching folders: ${folderList.join(', ')}`); - // VFS and state manager are available immediately const vfs = fileProcessor.getVirtualFileSystem(); const unifiedStateManager = fileProcessor.getUnifiedStateManager(); @@ -84,52 +79,21 @@ const getDuplicateResult = () => fileProcessor.getDuplicateResult(); // API Server will be initialized after KV is ready (deferred to async init) let apiServer: UnifiedAPIServer | null = null; -// Shared progressive discovery and cleanup logic (used by both startup and manual scan) -const runProgressiveDiscovery = async () => { - console.log(`[Discovery] Progressive scan mode: ${folderList.length} folders`); - - // Process folders one at a time for progressive cleanup - for (const folder of folderList) { - console.log(`[Discovery] Scanning folder: ${folder}`); - - // Track discovered files for this folder - const discoveredFilesInFolder = new Set(); - - // Wrap discovery stream to track files - async function* trackDiscoveredFiles(stream: AsyncGenerator) { - for await (const filePath of stream) { - discoveredFilesInFolder.add(filePath); - yield filePath; - } - } - - const discoveryStream = folderWatcher.discoverFiles([folder]); - const trackedStream = trackDiscoveredFiles(discoveryStream); - - await pipeline.start(trackedStream); - - // Progressive cleanup: clean up after each folder completes - console.log(`[Discovery] Folder scan complete: ${folder}. Found ${discoveredFilesInFolder.size} files. Running cleanup...`); - await fileProcessor.cleanupStaleEntries([folder], discoveredFilesInFolder); - console.log(`[Discovery] Cleanup complete for folder: ${folder}`); - } - - console.log(`[Discovery] All folders scanned and cleaned up`); -}; - -// Manual scan trigger (called from API) -// Note: Stremio updates happen automatically via incremental add/remove notifications -// during file processing (like FUSE), so no explicit refresh needed here +// Manual scan trigger (called from API) - delegates to meta-core via EventSubscriber const triggerScan = async () => { - console.log('[Scan] Triggering manual scan from API...'); + console.log('[Scan] Triggering manual scan...'); // Reset pipeline counters and state before fresh scan // Also publishes reset event to meta-fuse await pipeline.reset(); - console.log('[Scan] Starting fresh discovery...'); - await runProgressiveDiscovery(); - console.log('[Scan] Manual scan complete'); + // Trigger scan on meta-core (which will send events back via SSE) + if (eventSubscriber) { + await eventSubscriber.triggerScan(); + console.log('[Scan] Scan triggered on meta-core'); + } else { + console.warn('[Scan] EventSubscriber not available, cannot trigger scan'); + } }; // Global error handlers - prevent crashes from unforeseen errors @@ -153,6 +117,12 @@ process.on('SIGINT', async () => { await apiServer.stop(); } + // Stop Event Subscriber if running + if (eventSubscriber) { + console.log('[Shutdown] Stopping Event Subscriber...'); + await eventSubscriber.stop(); + } + // Stop Container Manager if running if (containerManager) { console.log('[Shutdown] Stopping Container Manager...'); @@ -171,6 +141,13 @@ process.on('SIGINT', async () => { // Start initial discovery and processing (streaming pipeline) (async () => { try { + // Validate META_CORE_URL is configured + if (!config.META_CORE_URL) { + console.error('[Startup] ERROR: META_CORE_URL is required. Set META_CORE_URL environment variable.'); + console.error('[Startup] Example: META_CORE_URL=http://meta-core'); + process.exit(1); + } + // Initialize KV Manager if configured (Redis + leader election) if (config.META_CORE_PATH) { console.log('[Startup] Initializing KV Manager (Redis + leader election)...'); @@ -278,10 +255,6 @@ process.on('SIGINT', async () => { await fileProcessor.rebuildVFSFromKV(); console.log('[Startup] VFS rebuild completed'); - console.log('[Startup] Starting initial file discovery with progressive cleanup...'); - await runProgressiveDiscovery(); - console.log('[Startup] Initial discovery completed, processing continues in background'); - // Log cache performance summary const metrics = performanceMetrics.getMetrics(); if (metrics.cacheHits.midhash256 || metrics.cacheMisses.midhash256) { @@ -292,32 +265,18 @@ process.on('SIGINT', async () => { console.log(`[Cache] midhash256 cache: ${hits} hits, ${misses} misses (${hitRate}% hit rate)`); } - // Setup file watching for changes - console.log('[Startup] Setting up file watching...'); - folderWatcher.watch(folderList, { - onAdd: (filePath) => pipeline.handleFileAdded(filePath), - onChange: (filePath) => pipeline.handleFileChanged(filePath), - onUnlink: (filePath) => pipeline.handleFileDeleted(filePath) - }, { - interval: config.WATCH_FOLDER_POOLING_INTERVAL_MS || 30000, - stabilityThreshold: 30000, - pollInterval: 5000 + // Connect to meta-core for file events via SSE (Architecture V3) + console.log(`[Startup] Connecting to meta-core at ${config.META_CORE_URL}...`); + eventSubscriber = new EventSubscriber({ + metaCoreUrl: config.META_CORE_URL, + pipeline: pipeline, + requestInitialScan: true, // Request initial scan from meta-core + reconnectDelayMs: 5000, }); - console.log('[Startup] File watching active'); + await eventSubscriber.start(); + console.log('[Startup] EventSubscriber connected to meta-core'); + } catch (error) { - console.error('[Startup] Error during initial discovery:', error); + console.error('[Startup] Error during startup:', error); } })(); - -// Start PollingWatcher for path-specific polling (if configured) -if (config.POLLING_PATHS && config.POLLING_PATHS.length > 0) { - console.log(`PollingWatcher: ${config.POLLING_PATHS.length} paths configured`); - const pollingWatcher = new PollingWatcher(fileProcessor, config.POLLING_PATHS); - pollingWatcher.start().then(() => { - console.log('PollingWatcher started successfully'); - }).catch((error) => { - console.error('Error starting PollingWatcher:', error); - }); -} else { - console.log('PollingWatcher: No polling paths configured (skipped)'); -} diff --git a/packages/meta-sort-core/src/logic/pipeline/StreamingPipeline.ts b/packages/meta-sort-core/src/logic/pipeline/StreamingPipeline.ts index e5998ba..54feea0 100644 --- a/packages/meta-sort-core/src/logic/pipeline/StreamingPipeline.ts +++ b/packages/meta-sort-core/src/logic/pipeline/StreamingPipeline.ts @@ -527,18 +527,37 @@ export class StreamingPipeline { (this.config as any).containerPluginScheduler = scheduler; // Listen for file:complete events to transition files to done state - scheduler.on('file:complete', ({ fileHash, filePath }) => { + scheduler.on('file:complete', async ({ fileHash, filePath }) => { console.log(`[Pipeline] Container plugins complete for ${filePath} (${fileHash})`); - // Get virtual path from metadata if available + // Fetch metadata from Redis (container plugins write directly to Redis via /meta API) let virtualPath: string | undefined; - const metadata = this.config.fileProcessor?.getDatabase().get(filePath); - if (metadata) { - try { - virtualPath = this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath); - } catch { - // Renaming rule may fail for incomplete metadata + let metadata: Record | undefined; + + try { + if (this.config.kvClient) { + // Get fresh metadata from Redis + const redisMetadata = await this.config.kvClient.getMetadataFlat(fileHash); + if (redisMetadata && Object.keys(redisMetadata).length > 0) { + metadata = redisMetadata; + // Update local database to stay in sync + if (this.config.fileProcessor?.getDatabase()) { + this.config.fileProcessor.getDatabase().set(filePath, metadata); + } + } } + + // Fallback to local database if Redis fetch failed + if (!metadata) { + metadata = this.config.fileProcessor?.getDatabase().get(filePath); + } + + if (metadata) { + virtualPath = this.config.metaDataToFolderStruct.renamingRule(metadata as any, filePath); + } + } catch (error) { + // Renaming rule may fail for incomplete metadata + console.warn(`[Pipeline] Failed to compute virtualPath for ${filePath}:`, error); } // Transition file to done state diff --git a/packages/meta-sort-editor/README.md b/packages/meta-sort-editor/README.md deleted file mode 100644 index d66fc08..0000000 --- a/packages/meta-sort-editor/README.md +++ /dev/null @@ -1,140 +0,0 @@ -# @metazla/meta-editor - -User-friendly metadata editor for MetaMesh. Search and edit file metadata directly through a web interface. - -## Features - -- **Search**: Find files by title, filename, or hash ID -- **Edit**: Modify all metadata fields including title, season, episode, year, Jellyfin metadata, and more -- **Batch Operations**: Update multiple files simultaneously -- **Real-time**: Direct integration with etcd via REST API -- **User-friendly**: Clean, intuitive interface with form validation - -## Development - -### Prerequisites - -- Node.js 21.6.2+ -- pnpm 10.19.0+ -- Meta-mesh API server running (provides the `/api/metadata/*` endpoints) - -### Running Locally - -```bash -# From the root directory -pnpm run start:editor - -# Or from the package directory -cd packages/meta-editor -pnpm run dev -``` - -The editor will be available at `http://localhost:5173` with API proxying to `http://localhost:3000`. - -### Building - -```bash -# From the root directory -pnpm run build:editor - -# Or from the package directory -cd packages/meta-editor -pnpm run build -``` - -The built files will be in `dist/` directory. - -## Docker Deployment - -In the Docker development environment, the editor is automatically available at: - -``` -http://localhost/editor -``` - -The nginx configuration serves the built files from `/app/packages/meta-editor/dist/`. - -## API Integration - -The editor communicates with the UnifiedAPIServer (meta-mesh) via REST API: - -- `POST /api/metadata/search` - Search for files -- `GET /api/metadata/:hashId` - Get file metadata -- `PUT /api/metadata/:hashId` - Update complete metadata -- `PUT /api/metadata/:hashId/property` - Update specific property -- `POST /api/metadata/batch` - Batch update multiple files - -See `src/api/metadataApi.ts` for the API client implementation. - -## Usage - -### Searching for Files - -1. Select search type: "Search by Title/Filename" or "Search by Hash ID" -2. Enter your search query -3. Click "Search" -4. Results will appear in the left sidebar - -### Editing Metadata - -1. Select a file from the search results -2. Modify any fields in the editor form -3. Click "Save Changes" to persist updates -4. Use "Reset" to discard changes - -### Supported Metadata Fields - -**Basic Information:** -- Title, Original Title, Show Title -- Video Type (Movie, TV, Anime, Other) -- Year, Season, Episode - -**Jellyfin Metadata:** -- Plot, IMDb ID, TMDb ID, AniDB ID -- Director, Studio, Rating, Runtime -- Genres, Premiered Date, Release Date - -**File Information:** -- Filename, File Size, MIME Type -- Hash values (SHA-256, MD5, etc.) - read-only - -## Project Structure - -``` -packages/meta-editor/ -├── src/ -│ ├── api/ # API client for metadata operations -│ ├── components/ # React components -│ │ ├── SearchBar.tsx # Search interface -│ │ ├── FileList.tsx # Search results list -│ │ └── MetadataEditor.tsx # Main editing form -│ ├── types/ # TypeScript type definitions -│ ├── utils/ # Utility functions -│ ├── App.tsx # Main application component -│ ├── main.tsx # Entry point -│ └── index.css # Global styles -├── index.html -├── vite.config.ts -├── tsconfig.json -└── package.json -``` - -## Technology Stack - -- **React 18** - UI framework -- **TypeScript 5.7** - Type safety -- **Vite 6** - Build tool and dev server -- **Native CSS** - Styling (no CSS framework) - -## Contributing - -When adding new features: - -1. Add type definitions to `src/types/index.ts` -2. Update API client in `src/api/metadataApi.ts` -3. Create/update components as needed -4. Update this README with new features - -## License - -Part of the MetaMesh project. diff --git a/packages/meta-sort-editor/eslint.config.js b/packages/meta-sort-editor/eslint.config.js deleted file mode 100644 index 092408a..0000000 --- a/packages/meta-sort-editor/eslint.config.js +++ /dev/null @@ -1,28 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/packages/meta-sort-editor/index.html b/packages/meta-sort-editor/index.html deleted file mode 100644 index 7fd12a8..0000000 --- a/packages/meta-sort-editor/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - MetaMesh Metadata Editor - - -
- - - diff --git a/packages/meta-sort-editor/package-lock.json b/packages/meta-sort-editor/package-lock.json deleted file mode 100644 index 7bc4dea..0000000 --- a/packages/meta-sort-editor/package-lock.json +++ /dev/null @@ -1,1570 +0,0 @@ -{ - "name": "@meta-sort/editor", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@meta-sort/editor", - "version": "0.1.0", - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "typescript": "^5.3.3", - "vite": "^5.0.12" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "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, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true - }, - "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, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - } - } -} diff --git a/packages/meta-sort-editor/package.json b/packages/meta-sort-editor/package.json deleted file mode 100644 index c891c2a..0000000 --- a/packages/meta-sort-editor/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@meta-sort/editor", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "preview": "vite preview" - }, - "dependencies": { - "iso-639-3": "^3.0.1", - "react": "^18.2.0", - "react-dom": "^18.2.0" - }, - "devDependencies": { - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "@vitejs/plugin-react": "^4.2.1", - "typescript": "^5.3.3", - "vite": "^5.0.12" - } -} diff --git a/packages/meta-sort-editor/src/App.css b/packages/meta-sort-editor/src/App.css deleted file mode 100644 index 9cf5ff3..0000000 --- a/packages/meta-sort-editor/src/App.css +++ /dev/null @@ -1,185 +0,0 @@ -.app { - min-height: 100vh; - display: flex; - flex-direction: column; -} - -.app-header { - padding: 2rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - background-color: rgba(0, 0, 0, 0.2); -} - -.app-header h1 { - margin: 0 0 0.5rem 0; - color: #646cff; -} - -.app-description { - margin: 0; - color: rgba(255, 255, 255, 0.6); -} - -.app-main { - flex: 1; - padding: 2rem; - max-width: 1400px; - width: 100%; - margin: 0 auto; -} - -.app-error { - padding: 1rem; - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - border-radius: 8px; - color: #ff6b6b; - margin-bottom: 1rem; -} - -.app-loading { - text-align: center; - padding: 3rem 2rem; - color: rgba(255, 255, 255, 0.7); - font-size: 1.1rem; -} - -.app-welcome { - text-align: center; - padding: 4rem 2rem; - color: rgba(255, 255, 255, 0.6); -} - -.app-welcome h2 { - margin: 0 0 1rem 0; - color: rgba(255, 255, 255, 0.8); -} - -.app-welcome-features { - margin-top: 1rem; - margin-bottom: 1.5rem; - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.5); -} - -.app-kv-browser { - margin-top: 1.5rem; - text-align: left; - max-width: 900px; - margin-left: auto; - margin-right: auto; -} - -.app-content { - display: grid; - grid-template-columns: 350px 1fr; - gap: 1.5rem; - margin-top: 1rem; -} - -.app-sidebar { - min-height: 400px; -} - -.app-editor { - min-height: 400px; -} - -.app-no-selection { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - min-height: 400px; - background-color: rgba(255, 255, 255, 0.05); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - text-align: center; - padding: 2rem; -} - -.app-footer { - padding: 1.5rem 2rem; - border-top: 1px solid rgba(255, 255, 255, 0.1); - text-align: center; - color: rgba(255, 255, 255, 0.5); - font-size: 0.9rem; -} - -.app-footer a { - color: #646cff; - text-decoration: none; -} - -.app-footer a:hover { - text-decoration: underline; -} - -@media (max-width: 1024px) { - .app-content { - grid-template-columns: 1fr; - } - - .app-sidebar { - order: 2; - } - - .app-editor { - order: 1; - } -} - -@media (max-width: 768px) { - .app-header, - .app-main, - .app-footer { - padding: 1rem; - } - - .app-content { - gap: 1rem; - } -} - -@media (prefers-color-scheme: light) { - .app-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .app-description { - color: rgba(0, 0, 0, 0.6); - } - - .app-error { - background-color: rgba(255, 0, 0, 0.1); - border-color: rgba(255, 0, 0, 0.3); - color: #cc0000; - } - - .app-loading { - color: rgba(0, 0, 0, 0.7); - } - - .app-welcome { - color: rgba(0, 0, 0, 0.6); - } - - .app-welcome h2 { - color: rgba(0, 0, 0, 0.8); - } - - .app-welcome-features { - color: rgba(0, 0, 0, 0.5); - } - - .app-no-selection { - background-color: rgba(0, 0, 0, 0.02); - color: rgba(0, 0, 0, 0.5); - } - - .app-footer { - border-top-color: rgba(0, 0, 0, 0.1); - color: rgba(0, 0, 0, 0.5); - } -} diff --git a/packages/meta-sort-editor/src/App.tsx b/packages/meta-sort-editor/src/App.tsx deleted file mode 100644 index d0f5e64..0000000 --- a/packages/meta-sort-editor/src/App.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { useState, useEffect } from 'react'; -import { SearchBar } from './components/SearchBar'; -import { FileList } from './components/FileList'; -import { MetadataEditor } from './components/MetadataEditor'; -import { BulkMetadataEditor } from './components/BulkMetadataEditor'; -import { KVTreeView } from './components/KVTreeView'; -import { MetadataAPI } from './api/metadataApi'; -import { SearchResult } from './types'; -import './App.css'; - -function App() { - const [searchResults, setSearchResults] = useState([]); - const [selectedHashId, setSelectedHashId] = useState(); - const [selectedHashIds, setSelectedHashIds] = useState>(new Set()); - const [bulkEditMode, setBulkEditMode] = useState(false); - const [isSearching, setIsSearching] = useState(false); - const [isLoadingDirectHash, setIsLoadingDirectHash] = useState(false); - const [error, setError] = useState(null); - - // Extract hash from URL path (supports /editor/file/hash or /editor/?hash=...) - const getHashFromUrl = (): string | null => { - const path = window.location.pathname; - const fileMatch = path.match(/\/file\/([a-z0-9]+)$/i); - if (fileMatch) { - return fileMatch[1]; - } - // Fallback to query param for backwards compatibility - const params = new URLSearchParams(window.location.search); - return params.get('hash'); - }; - - // Update URL when file is selected - const updateUrlForFile = (hashId: string | undefined) => { - const basePath = window.location.pathname.replace(/\/file\/[a-z0-9]+$/i, '').replace(/\/$/, ''); - if (hashId) { - const newPath = `${basePath}/file/${hashId}`; - window.history.pushState({ hashId }, '', newPath); - } else { - window.history.pushState({}, '', basePath || '/editor/'); - } - }; - - // Check for hash parameter in URL on component mount - useEffect(() => { - const loadHashFromUrl = async () => { - const hashParam = getHashFromUrl(); - - if (hashParam) { - try { - setIsLoadingDirectHash(true); - setError(null); - - // Try to get metadata for this hash - const metadata = await MetadataAPI.getMetadata(hashParam); - - // Create a search result from the metadata - const searchResult: SearchResult = { - hashId: hashParam, - metadata: metadata, - score: 1.0, - }; - - // Set the search results and select this file - setSearchResults([searchResult]); - setSelectedHashId(hashParam); - } catch (err: any) { - setError(`Failed to load file with hash ${hashParam}: ${err.message}`); - setSearchResults([]); - setSelectedHashId(undefined); - } finally { - setIsLoadingDirectHash(false); - } - } - }; - - loadHashFromUrl(); - - // Handle browser back/forward - const handlePopState = (event: PopStateEvent) => { - const hashId = event.state?.hashId || getHashFromUrl(); - if (hashId) { - setSelectedHashId(hashId); - } else { - setSelectedHashId(undefined); - } - }; - window.addEventListener('popstate', handlePopState); - return () => window.removeEventListener('popstate', handlePopState); - }, []); // Empty dependency array means this runs once on mount - - const handleSearch = async (query: string) => { - try { - setIsSearching(true); - setError(null); - - // Unified search - searches across all fields - const response = await MetadataAPI.search({ query }); - - setSearchResults(response.results); - - // Auto-select first result if available - if (response.results.length > 0) { - setSelectedHashId(response.results[0].hashId); - } else { - setSelectedHashId(undefined); - } - } catch (err: any) { - setError(err.message); - setSearchResults([]); - setSelectedHashId(undefined); - } finally { - setIsSearching(false); - } - }; - - const handleSelectFile = (hashId: string) => { - if (!bulkEditMode) { - setSelectedHashId(hashId); - setSelectedHashIds(new Set()); - updateUrlForFile(hashId); - } - }; - - const handleToggleSelection = (hashId: string) => { - const newSelection = new Set(selectedHashIds); - if (newSelection.has(hashId)) { - newSelection.delete(hashId); - } else { - newSelection.add(hashId); - } - setSelectedHashIds(newSelection); - }; - - const handleSelectAll = () => { - const allHashIds = new Set(searchResults.map(r => r.hashId)); - setSelectedHashIds(allHashIds); - }; - - const handleClearSelection = () => { - setSelectedHashIds(new Set()); - setBulkEditMode(false); - }; - - const handleBulkEditMode = (enabled: boolean) => { - setBulkEditMode(enabled); - if (enabled) { - setSelectedHashId(undefined); - } else { - setSelectedHashIds(new Set()); - } - }; - - const handleMetadataSaved = () => { - // Optionally refresh the search results to reflect changes - console.log('Metadata saved successfully'); - }; - - const handleBulkMetadataSaved = () => { - console.log('Bulk metadata saved successfully'); - handleClearSelection(); - }; - - return ( -
-
-

MetaMesh Metadata Editor

-

- Search and edit metadata for your media files -

-
- -
- - - {error &&
{error}
} - - {isLoadingDirectHash && ( -
Loading file metadata...
- )} - - {!isSearching && !isLoadingDirectHash && searchResults.length === 0 && !error && ( -
-

Welcome to MetaMesh Metadata Editor

-

Search for files by title, filename, file path, or hash ID to get started.

-

- Unified search across all fields | Single file editing | Bulk editing | Direct access via URL -

-
- -
-
- )} - - {searchResults.length > 0 && ( -
-
- -
-
- {bulkEditMode && selectedHashIds.size > 0 ? ( - - ) : selectedHashId ? ( - - ) : ( -
- {bulkEditMode - ? 'Select files from the list to bulk edit metadata' - : 'Select a file from the list to edit its metadata'} -
- )} -
-
- )} -
- - -
- ); -} - -export default App; diff --git a/packages/meta-sort-editor/src/api/metadataApi.ts b/packages/meta-sort-editor/src/api/metadataApi.ts deleted file mode 100644 index deb209f..0000000 --- a/packages/meta-sort-editor/src/api/metadataApi.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - FileMetadata, - SearchResponse, - UpdateResult, - BatchUpdateResponse, -} from '../types'; - -const API_BASE = '/api/metadata'; - -export class MetadataAPI { - /** - * Search for files by query, hashId, or property - */ - static async search(params: { - query?: string; - hashId?: string; - property?: string; - propertyValue?: string; - limit?: number; - }): Promise { - const response = await fetch(`${API_BASE}/search`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(params), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Search failed'); - } - - return response.json(); - } - - /** - * Get metadata for a specific file by hash ID - */ - static async getMetadata(hashId: string): Promise { - const response = await fetch(`${API_BASE}/${hashId}`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get metadata'); - } - - return response.json(); - } - - /** - * Update complete metadata for a file - */ - static async updateMetadata( - hashId: string, - metadata: FileMetadata - ): Promise { - const response = await fetch(`${API_BASE}/${hashId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metadata), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update metadata'); - } - - return response.json(); - } - - /** - * Update a specific property for a file - */ - static async updateProperty( - hashId: string, - property: string, - value: string - ): Promise { - const response = await fetch(`${API_BASE}/${hashId}/property`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ property, value }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update property'); - } - - return response.json(); - } - - /** - * Batch update multiple files - */ - static async batchUpdate( - updates: Array<{ - hashId: string; - metadata?: FileMetadata; - properties?: Record; - }> - ): Promise { - const response = await fetch(`${API_BASE}/batch`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ updates }), - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Batch update failed'); - } - - return response.json(); - } - - /** - * Delete metadata for a file - */ - static async deleteMetadata(hashId: string): Promise { - const response = await fetch(`${API_BASE}/${hashId}`, { - method: 'DELETE', - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete metadata'); - } - } - - /** - * Get all hash IDs - */ - static async getAllHashIds(): Promise<{ hashIds: string[]; count: number }> { - const response = await fetch(`${API_BASE}/hash-ids`); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to get hash IDs'); - } - - return response.json(); - } -} diff --git a/packages/meta-sort-editor/src/components/BulkMetadataEditor.css b/packages/meta-sort-editor/src/components/BulkMetadataEditor.css deleted file mode 100644 index 5a461ff..0000000 --- a/packages/meta-sort-editor/src/components/BulkMetadataEditor.css +++ /dev/null @@ -1,382 +0,0 @@ -.bulk-metadata-editor { - background-color: rgba(255, 255, 255, 0.05); - border-radius: 8px; - padding: 1.5rem; -} - -.bulk-metadata-editor-header { - margin-bottom: 1rem; - padding-bottom: 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.bulk-metadata-editor-header h2 { - margin: 0 0 0.5rem 0; - font-size: 1.5rem; -} - -.bulk-info { - margin: 0 0 1rem 0; - color: rgba(255, 255, 255, 0.6); - font-size: 0.95rem; -} - -.bulk-metadata-editor-actions { - display: flex; - gap: 0.5rem; -} - -.bulk-metadata-editor-actions button.primary { - background-color: #646cff; - color: white; -} - -.bulk-metadata-editor-actions button.primary:hover:not(:disabled) { - background-color: #535bf2; -} - -.bulk-metadata-editor-message { - padding: 1rem; - border-radius: 4px; - margin-bottom: 1rem; -} - -.bulk-metadata-editor-message.success { - background-color: rgba(0, 200, 0, 0.1); - border: 1px solid rgba(0, 200, 0, 0.3); - color: #00ff00; -} - -.bulk-metadata-editor-message.error { - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - color: #ff6b6b; -} - -.bulk-metadata-editor-form { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.bulk-metadata-section { - border: 1px solid rgba(255, 255, 255, 0.1); - border-left: 4px solid #646cff; - border-radius: 8px; - padding: 1.5rem; - background-color: rgba(0, 0, 0, 0.2); -} - -.bulk-metadata-section-header { - margin-bottom: 1.5rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); -} - -.bulk-metadata-section-header h3 { - margin: 0 0 0.25rem 0; - font-size: 1.1rem; - color: #646cff; -} - -.section-hint { - margin: 0.25rem 0 0 0; - font-size: 0.85rem; - color: rgba(255, 255, 255, 0.5); - font-style: italic; -} - -.section-hint-bulk { - margin: 0.5rem 0 0 0; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.4); - font-weight: 500; -} - -/* Plugin-based bulk fields */ -.bulk-fields-container { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.bulk-field-wrapper { - border: 1px solid rgba(255, 255, 255, 0.05); - border-radius: 4px; - padding: 0.75rem; - background-color: rgba(0, 0, 0, 0.1); - transition: all 0.2s ease; -} - -.bulk-field-wrapper.has-value { - border-color: rgba(100, 108, 255, 0.3); - background-color: rgba(100, 108, 255, 0.05); -} - -.bulk-field-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; -} - -.field-key { - font-size: 0.85rem; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - text-transform: lowercase; - font-family: 'Courier New', monospace; -} - -.bulk-field-wrapper.has-value .field-key { - color: #646cff; -} - -.bulk-field-input { - /* Plugin component will render inside this */ -} - -/* Legacy bulk fields (for compatibility) */ -.bulk-metadata-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; -} - -.bulk-metadata-field:last-child { - margin-bottom: 0; -} - -.field-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.field-header label { - font-size: 0.9rem; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); -} - -.clear-field-btn { - width: 20px; - height: 20px; - padding: 0; - border: 1px solid rgba(255, 0, 0, 0.3); - background-color: rgba(255, 0, 0, 0.1); - color: #ff6b6b; - border-radius: 50%; - cursor: pointer; - font-size: 16px; - line-height: 1; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.clear-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); -} - -.bulk-metadata-field input, -.bulk-metadata-field textarea, -.bulk-metadata-field select { - width: 100%; -} - -.bulk-metadata-field-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; -} - -.bulk-metadata-field-row:last-child { - margin-bottom: 0; -} - -/* Modified fields summary */ -.modified-fields { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.3); -} - -.modified-fields h3 { - color: #646cff; -} - -.modified-fields-list { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.modified-field-item { - display: flex; - gap: 0.75rem; - align-items: center; - padding: 0.75rem; - background-color: rgba(100, 108, 255, 0.05); - border: 1px solid rgba(100, 108, 255, 0.2); - border-radius: 4px; -} - -.field-name { - font-weight: 600; - color: #646cff; - min-width: 150px; - font-family: 'Courier New', monospace; - font-size: 0.9rem; -} - -.field-value { - flex: 1; - word-break: break-word; - color: rgba(255, 255, 255, 0.8); - font-size: 0.9rem; -} - -.remove-field-btn { - padding: 0.25rem 0.75rem; - font-size: 0.85rem; - background-color: rgba(255, 0, 0, 0.1); - border: 1px solid rgba(255, 0, 0, 0.3); - color: #ff6b6b; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; - white-space: nowrap; -} - -.remove-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); -} - -@media (max-width: 768px) { - .bulk-metadata-editor-header { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .bulk-metadata-editor-actions { - width: 100%; - flex-direction: column; - } - - .bulk-metadata-editor-actions button { - width: 100%; - } - - .bulk-metadata-field-row { - grid-template-columns: 1fr; - } - - .modified-field-item { - flex-direction: column; - align-items: flex-start; - } - - .field-name { - min-width: auto; - } - - .remove-field-btn { - align-self: flex-end; - } -} - -@media (prefers-color-scheme: light) { - .bulk-metadata-editor { - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-metadata-editor-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - } - - .bulk-info { - color: rgba(0, 0, 0, 0.6); - } - - .bulk-metadata-section { - border-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-metadata-section-header { - border-bottom-color: rgba(0, 0, 0, 0.1); - } - - .section-hint { - color: rgba(0, 0, 0, 0.5); - } - - .section-hint-bulk { - color: rgba(0, 0, 0, 0.4); - } - - .bulk-field-wrapper { - border-color: rgba(0, 0, 0, 0.1); - background-color: rgba(0, 0, 0, 0.02); - } - - .bulk-field-wrapper.has-value { - border-color: rgba(100, 108, 255, 0.3); - background-color: rgba(100, 108, 255, 0.05); - } - - .field-key { - color: rgba(0, 0, 0, 0.6); - } - - .bulk-field-wrapper.has-value .field-key { - color: #646cff; - } - - .field-header label { - color: rgba(0, 0, 0, 0.7); - } - - .field-value { - color: rgba(0, 0, 0, 0.8); - } - - .bulk-metadata-editor-message.success { - background-color: rgba(0, 200, 0, 0.1); - border-color: rgba(0, 200, 0, 0.3); - color: #008800; - } - - .bulk-metadata-editor-message.error { - background-color: rgba(255, 0, 0, 0.1); - border-color: rgba(255, 0, 0, 0.3); - color: #cc0000; - } - - .modified-fields { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.3); - } - - .modified-field-item { - background-color: rgba(100, 108, 255, 0.05); - border-color: rgba(100, 108, 255, 0.2); - } - - .clear-field-btn { - border-color: rgba(255, 0, 0, 0.3); - background-color: rgba(255, 0, 0, 0.1); - color: #cc0000; - } - - .clear-field-btn:hover { - background-color: rgba(255, 0, 0, 0.2); - border-color: rgba(255, 0, 0, 0.5); - } -} diff --git a/packages/meta-sort-editor/src/components/BulkMetadataEditor.tsx b/packages/meta-sort-editor/src/components/BulkMetadataEditor.tsx deleted file mode 100644 index f054584..0000000 --- a/packages/meta-sort-editor/src/components/BulkMetadataEditor.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useState, useEffect } from 'react'; -import { FileMetadata, PluginSchema } from '../types'; -import { MetadataAPI } from '../api/metadataApi'; -import { DynamicFieldRenderer } from './DynamicFieldRenderer'; -import './BulkMetadataEditor.css'; - -interface PluginsResponse { - plugins: PluginSchema[]; -} - -interface BulkMetadataEditorProps { - hashIds: string[]; - onSaved?: () => void; - onCancel?: () => void; -} - -export function BulkMetadataEditor({ hashIds, onSaved, onCancel }: BulkMetadataEditorProps) { - const [plugins, setPlugins] = useState([]); - const [commonValues, setCommonValues] = useState>({}); - const [editedFields, setEditedFields] = useState>({}); - const [isLoading, setIsLoading] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [error, setError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - - // Load plugins and metadata on mount - useEffect(() => { - const loadData = async () => { - try { - setIsLoading(true); - setError(null); - - // Fetch plugins - const pluginsResponse = await fetch('/api/plugins'); - if (pluginsResponse.ok) { - const data: PluginsResponse = await pluginsResponse.json(); - // Sort by priority - const sorted = [...data.plugins].sort((a, b) => - (a.priority ?? 100) - (b.priority ?? 100) - ); - setPlugins(sorted); - } - - // Load metadata for all selected files - const metadataPromises = hashIds.map((hashId) => MetadataAPI.getMetadata(hashId)); - const metadata = await Promise.all(metadataPromises); - - // Calculate common values - const commons: Record = {}; - - // Get all editable fields from all plugins - const allFields: string[] = []; - plugins.forEach((plugin) => { - if (plugin.metadataSchema) { - Object.entries(plugin.metadataSchema).forEach(([key, field]) => { - if (!field.readonly) { - allFields.push(key); - } - }); - } - }); - - allFields.forEach((field) => { - const values = metadata.map((m) => m[field as keyof FileMetadata]); - - // Filter out undefined and null values - const definedValues = values.filter((v) => v !== undefined && v !== null); - - if (definedValues.length === 0) { - return; - } - - // Safely stringify values - const uniqueValues = Array.from( - new Set( - definedValues.map((v) => { - try { - return JSON.stringify(v); - } catch { - return String(v); - } - }) - ) - ); - - if (uniqueValues.length === 1) { - try { - commons[field] = JSON.parse(uniqueValues[0]); - } catch { - commons[field] = uniqueValues[0]; - } - } else { - commons[field] = '*'; - } - }); - - setCommonValues(commons); - } catch (err: any) { - setError(`Failed to load data: ${err.message}`); - } finally { - setIsLoading(false); - } - }; - - loadData(); - }, [hashIds]); - - const handleFieldChange = (field: string, value: any) => { - setEditedFields({ - ...editedFields, - [field]: value, - }); - }; - - const handleClearField = (field: string) => { - const newFields = { ...editedFields }; - delete newFields[field]; - setEditedFields(newFields); - }; - - const handleSave = async () => { - if (Object.keys(editedFields).length === 0) { - setError('No fields have been edited'); - return; - } - - try { - setIsSaving(true); - setError(null); - setSuccessMessage(null); - - // Build batch update payload - const updates = hashIds.map((hashId) => ({ - hashId, - metadata: editedFields, - })); - - const result = await MetadataAPI.batchUpdate(updates); - - if (result.errors > 0) { - setError( - `Updated ${result.success} files successfully, but ${result.errors} failed. Check console for details.` - ); - console.error('Batch update errors:', result.results.filter((r) => r.error)); - } else { - setSuccessMessage(`Successfully updated ${result.success} files!`); - } - - if (onSaved) { - onSaved(); - } - - setTimeout(() => setSuccessMessage(null), 3000); - } catch (err: any) { - setError(err.message); - } finally { - setIsSaving(false); - } - }; - - const hasChanges = Object.keys(editedFields).length > 0; - - // Filter to only editable plugins (those with non-readonly fields) - const editablePlugins = plugins.filter((plugin) => { - if (!plugin.metadataSchema) return false; - return Object.values(plugin.metadataSchema).some((field) => !field.readonly); - }); - - if (isLoading) { - return ( -
-
- Loading metadata for {hashIds.length} files... -
-
- ); - } - - // Build display metadata (edited values or common values) - const displayMetadata: Record = { ...commonValues, ...editedFields }; - - return ( -
-
-

Bulk Edit Metadata

-

- Editing {hashIds.length} file{hashIds.length !== 1 ? 's' : ''} - Only modified fields will - be updated. Fields showing "*" have different values across files. -

-
- {onCancel && ( - - )} - -
-
- - {successMessage && ( -
{successMessage}
- )} - {error &&
{error}
} - -
- {editablePlugins.map((plugin) => { - if (!plugin.metadataSchema) return null; - - // Get editable fields for this plugin - const editableFields = Object.entries(plugin.metadataSchema).filter( - ([, field]) => !field.readonly - ); - - if (editableFields.length === 0) return null; - - return ( -
-
-

{plugin.name}

- {plugin.description && ( -

{plugin.description}

- )} -

- Common values shown. "*" indicates different values. Edit to update all files. -

-
- -
- {editableFields.map(([key, field]) => ( - - ))} -
-
- ); - })} - - {/* Modified Fields Summary */} - {hasChanges && ( -
-

Fields to be Updated ({Object.keys(editedFields).length})

-
- {Object.entries(editedFields).map(([key, value]) => ( -
- {key}: - - {Array.isArray(value) - ? value.join(', ') - : typeof value === 'object' - ? JSON.stringify(value) - : String(value || '(empty)')} - - -
- ))} -
-
- )} -
-
- ); -} diff --git a/packages/meta-sort-editor/src/components/DynamicFieldRenderer.css b/packages/meta-sort-editor/src/components/DynamicFieldRenderer.css deleted file mode 100644 index fac5e4f..0000000 --- a/packages/meta-sort-editor/src/components/DynamicFieldRenderer.css +++ /dev/null @@ -1,297 +0,0 @@ -.dynamic-field { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.dynamic-field:last-child { - margin-bottom: 0; -} - -.dynamic-field label { - font-size: 0.9rem; - font-weight: 500; - color: rgba(255, 255, 255, 0.7); -} - -.dynamic-field input[type="text"], -.dynamic-field input[type="number"], -.dynamic-field select, -.dynamic-field textarea { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - font-size: 0.9rem; - background: rgba(0, 0, 0, 0.3); - color: #fff; - transition: border-color 0.2s; -} - -.dynamic-field input:focus, -.dynamic-field select:focus, -.dynamic-field textarea:focus { - outline: none; - border-color: #646cff; -} - -.dynamic-field input:disabled, -.dynamic-field select:disabled, -.dynamic-field textarea:disabled { - background: rgba(0, 0, 0, 0.2); - color: rgba(255, 255, 255, 0.5); - cursor: not-allowed; -} - -.dynamic-field textarea { - resize: vertical; - min-height: 80px; - font-family: inherit; -} - -.field-hint { - display: block; - font-size: 0.8rem; - color: rgba(255, 255, 255, 0.4); -} - -.checkbox-label { - display: flex; - align-items: center; - gap: 0.5rem; - cursor: pointer; - font-weight: normal; - color: rgba(255, 255, 255, 0.7); -} - -.checkbox-label input[type="checkbox"] { - width: 1.1rem; - height: 1.1rem; - cursor: pointer; - accent-color: #646cff; -} - -.checkbox-label input:disabled { - cursor: not-allowed; - opacity: 0.5; -} - -.readonly-value { - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: rgba(255, 255, 255, 0.7); - font-family: 'Courier New', monospace; - font-size: 0.9rem; - word-break: break-all; -} - -.field-link { - color: #646cff; - text-decoration: none; -} - -.field-link:hover { - text-decoration: underline; -} - -/* CID field styling */ -.cid-field .cid-value-container { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.cid-field .cid-value { - flex: 1; - padding: 0.5rem 0.75rem; - background: rgba(0, 0, 0, 0.3); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.85rem; - color: #a0d8ef; - cursor: pointer; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cid-field .cid-value:hover { - background: rgba(0, 0, 0, 0.4); - border-color: #646cff; -} - -.cid-field .cid-open-button { - display: inline-flex; - align-items: center; - padding: 0.5rem 0.75rem; - background: #646cff; - color: #fff; - border-radius: 4px; - text-decoration: none; - font-size: 0.85rem; - font-weight: 500; - white-space: nowrap; - transition: background 0.2s; -} - -.cid-field .cid-open-button:hover { - background: #535bf2; -} - -/* JSON field styling */ -.json-field .json-viewer { - background: rgba(0, 0, 0, 0.4); - color: #d4d4d4; - padding: 1rem; - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.85rem; - overflow-x: auto; - max-height: 300px; - overflow-y: auto; - margin: 0; - white-space: pre-wrap; - word-break: break-all; - border: 1px solid rgba(255, 255, 255, 0.1); -} - -/* Language string field styling */ -.language-string-field .language-entries { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.language-string-field .language-entry { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.language-string-field .lang-input { - flex: 1; - min-width: 0; -} - -.language-string-field .lang-code { - display: inline-block; - width: 50px; - padding: 0.4rem 0.5rem; - background: rgba(100, 108, 255, 0.2); - border: 1px solid rgba(100, 108, 255, 0.3); - border-radius: 4px; - font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace; - font-size: 0.8rem; - color: #a0d8ef; - text-align: center; - flex-shrink: 0; -} - -.language-string-field .lang-text { - flex: 1; - padding: 0.4rem 0.75rem; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - color: rgba(255, 255, 255, 0.8); - font-size: 0.9rem; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.language-string-field .lang-remove-btn { - width: 32px; - height: 32px; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - background: rgba(255, 100, 100, 0.2); - border: 1px solid rgba(255, 100, 100, 0.3); - border-radius: 4px; - color: #ff6b6b; - font-size: 1.2rem; - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.language-string-field .lang-remove-btn:hover { - background: rgba(255, 100, 100, 0.3); - border-color: rgba(255, 100, 100, 0.5); -} - -.language-string-field .lang-add-btn { - align-self: flex-start; - padding: 0.4rem 0.75rem; - background: rgba(100, 108, 255, 0.2); - border: 1px solid rgba(100, 108, 255, 0.3); - border-radius: 4px; - color: #646cff; - font-size: 0.85rem; - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.language-string-field .lang-add-btn:hover { - background: rgba(100, 108, 255, 0.3); - border-color: rgba(100, 108, 255, 0.5); -} - -/* Light theme */ -@media (prefers-color-scheme: light) { - .dynamic-field label { - color: rgba(0, 0, 0, 0.7); - } - - .dynamic-field input[type="text"], - .dynamic-field input[type="number"], - .dynamic-field select, - .dynamic-field textarea { - border-color: rgba(0, 0, 0, 0.1); - background: #fff; - color: #333; - } - - .dynamic-field input:disabled, - .dynamic-field select:disabled, - .dynamic-field textarea:disabled { - background: #f5f5f5; - color: rgba(0, 0, 0, 0.5); - } - - .field-hint { - color: rgba(0, 0, 0, 0.5); - } - - .checkbox-label { - color: rgba(0, 0, 0, 0.7); - } - - .readonly-value { - background: #f8f9fa; - border-color: #e9ecef; - color: #495057; - } - - .json-field .json-viewer { - background: #1e1e1e; - border-color: rgba(0, 0, 0, 0.2); - } - - .language-string-field .lang-code { - background: rgba(100, 108, 255, 0.1); - color: #4a50c8; - } - - .language-string-field .lang-text { - background: #f8f9fa; - border-color: #e9ecef; - color: #495057; - } -} diff --git a/packages/meta-sort-editor/src/components/DynamicFieldRenderer.tsx b/packages/meta-sort-editor/src/components/DynamicFieldRenderer.tsx deleted file mode 100644 index 9a0d885..0000000 --- a/packages/meta-sort-editor/src/components/DynamicFieldRenderer.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { SchemaField, FileMetadata } from '../types'; -import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox'; -import './DynamicFieldRenderer.css'; - -interface DynamicFieldRendererProps { - fieldKey: string; - schema: SchemaField; - metadata: FileMetadata; - onChange: (key: string, value: any) => void; - readonly?: boolean; -} - -/** - * Renders a single metadata field based on its schema definition - */ -export function DynamicFieldRenderer({ - fieldKey, - schema, - metadata, - onChange, - readonly = false, -}: DynamicFieldRendererProps) { - const isReadonly = readonly || schema.readonly; - const fieldType = schema.type || 'string'; - - // Get value from metadata using path-style key - const getValue = (): any => { - // Handle path-style keys like "stream/duration" or "nfo/title" - const parts = fieldKey.split('/'); - let value: any = metadata; - for (const part of parts) { - if (value === undefined || value === null) return undefined; - value = value[part]; - } - return value; - }; - - // Set value in metadata using path-style key - const setValue = (newValue: any) => { - onChange(fieldKey, newValue); - }; - - const value = getValue(); - - // Render link if configured - const renderValueWithLink = (displayValue: string) => { - if (schema.link && displayValue) { - const url = schema.link.replace('{value}', encodeURIComponent(displayValue)); - return ( - - {displayValue} - - ); - } - return displayValue; - }; - - // Render based on field type - switch (fieldType) { - case 'boolean': - return ( -
- - {schema.hint && {schema.hint}} -
- ); - - case 'number': - return ( -
- - setValue(e.target.value ? Number(e.target.value) : undefined)} - disabled={isReadonly} - min={schema.min} - max={schema.max} - placeholder={schema.placeholder} - /> - {schema.hint && {schema.hint}} -
- ); - - case 'select': - return ( -
- - - {schema.hint && {schema.hint}} -
- ); - - case 'text': - return ( -
- -