move feature from meta-sort to meta-core
This commit is contained in:
@@ -20,3 +20,4 @@ go.work
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
dashboard/node_modules/*
|
||||||
|
|||||||
+1
File diff suppressed because one or more lines are too long
+67
File diff suppressed because one or more lines are too long
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>meta-core Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script type="module" crossorigin src="/assets/index-ycUtrPCn.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BGbUc1fg.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>meta-core Dashboard</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1779
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@meta-core/dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0",
|
||||||
|
"iso-639-3": "^3.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.38",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Mounts from './pages/Mounts';
|
||||||
|
import Files from './pages/Files';
|
||||||
|
import ServiceNav from './components/ServiceNav';
|
||||||
|
|
||||||
|
const navStyle: React.CSSProperties = {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '1rem',
|
||||||
|
padding: '0.75rem 2rem',
|
||||||
|
background: '#16213e',
|
||||||
|
borderBottom: '1px solid #0f3460',
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkStyle: React.CSSProperties = {
|
||||||
|
color: '#888',
|
||||||
|
textDecoration: 'none',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeLinkStyle: React.CSSProperties = {
|
||||||
|
...linkStyle,
|
||||||
|
color: '#fff',
|
||||||
|
background: '#0f3460',
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Editor page with iframe to standalone editor
|
||||||
|
function EditorPage() {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src="/editor/"
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||||
|
title="Metadata Editor"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<nav style={navStyle}>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/mounts"
|
||||||
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
|
>
|
||||||
|
Mounts
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/files"
|
||||||
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/editor"
|
||||||
|
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
|
||||||
|
>
|
||||||
|
Editor
|
||||||
|
</NavLink>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<ServiceNav />
|
||||||
|
</nav>
|
||||||
|
<div style={contentStyle}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/mounts" element={<Mounts />} />
|
||||||
|
<Route path="/files" element={<Files />} />
|
||||||
|
<Route path="/editor" element={<EditorPage />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface ServiceInfo {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
api: string;
|
||||||
|
status: string;
|
||||||
|
capabilities: string[];
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServicesResponse {
|
||||||
|
services: ServiceInfo[];
|
||||||
|
current: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceIcons: Record<string, string> = {
|
||||||
|
'meta-core': '🗄️',
|
||||||
|
'meta-sort': '📁',
|
||||||
|
'meta-fuse': '🗂️',
|
||||||
|
'meta-stremio': '🎬',
|
||||||
|
'meta-orbit': '🌐',
|
||||||
|
'default': '📦'
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatServiceName(name: string): string {
|
||||||
|
return name.split('-').map(word =>
|
||||||
|
word.charAt(0).toUpperCase() + word.slice(1)
|
||||||
|
).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceNav() {
|
||||||
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const currentService = 'meta-core';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchServices = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/services');
|
||||||
|
if (response.ok) {
|
||||||
|
const data: ServicesResponse = await response.json();
|
||||||
|
setServices(data.services || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch services:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchServices();
|
||||||
|
const interval = setInterval(fetchServices, 30000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
if (sortedServices.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="services-nav">
|
||||||
|
<span className="services-nav-label">Services:</span>
|
||||||
|
<div className="services-nav-items">
|
||||||
|
{sortedServices.map(service => {
|
||||||
|
const icon = serviceIcons[service.name] || serviceIcons.default;
|
||||||
|
const isActive = service.name === currentService;
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={service.name}
|
||||||
|
href={isActive ? '#' : service.url}
|
||||||
|
className={`service-link${isActive ? ' active' : ''}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!isActive) {
|
||||||
|
window.location.href = service.url;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="service-icon">{icon}</span>
|
||||||
|
<span>{formatServiceName(service.name)}</span>
|
||||||
|
<span className="service-status"></span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.services-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-nav-label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-nav-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: #ccc;
|
||||||
|
background: #0f3460;
|
||||||
|
border: 1px solid #1a4a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link:hover {
|
||||||
|
background: #1a4a7a;
|
||||||
|
border-color: rgba(78, 205, 196, 0.5);
|
||||||
|
text-decoration: none;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link.active {
|
||||||
|
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
|
||||||
|
border-color: rgba(78, 205, 196, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link .service-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-link .service-status {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4ecdc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.services-nav-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceNav;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0f;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #1a1a2e;
|
||||||
|
--text-primary: #e0e0e0;
|
||||||
|
--text-secondary: #a0a0a0;
|
||||||
|
--accent-primary: #4ecdc4;
|
||||||
|
--border-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface HealthStatus {
|
||||||
|
status: string;
|
||||||
|
role: string;
|
||||||
|
redis: boolean;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanStatus {
|
||||||
|
status: string;
|
||||||
|
scanning: boolean;
|
||||||
|
lastScan: number;
|
||||||
|
fileCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceInfo {
|
||||||
|
name: string;
|
||||||
|
api: string;
|
||||||
|
capabilities: string[];
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#16213e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||||
|
gap: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statStyle: React.CSSProperties = {
|
||||||
|
fontSize: '2rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#4ade80',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
|
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
|
||||||
|
const [services, setServices] = useState<ServiceInfo[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
// Fetch health
|
||||||
|
const healthRes = await fetch('/health');
|
||||||
|
if (healthRes.ok) {
|
||||||
|
setHealth(await healthRes.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch scan status
|
||||||
|
const scanRes = await fetch('/api/scan/status');
|
||||||
|
if (scanRes.ok) {
|
||||||
|
setScanStatus(await scanRes.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch services
|
||||||
|
const servicesRes = await fetch('/services');
|
||||||
|
if (servicesRes.ok) {
|
||||||
|
const data = await servicesRes.json();
|
||||||
|
setServices(data.services || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
const interval = setInterval(fetchData, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTriggerScan = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/scan/trigger', { method: 'POST' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to trigger scan:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={gridStyle}>
|
||||||
|
{/* Health Card */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3>
|
||||||
|
{health ? (
|
||||||
|
<>
|
||||||
|
<div style={statStyle}>
|
||||||
|
{health.status === 'ok' ? 'Healthy' : health.status}
|
||||||
|
</div>
|
||||||
|
<p>Role: <strong>{health.role}</strong></p>
|
||||||
|
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scan Status Card */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watcher</h3>
|
||||||
|
{scanStatus ? (
|
||||||
|
<>
|
||||||
|
<div style={statStyle}>{scanStatus.fileCount}</div>
|
||||||
|
<p>Files discovered</p>
|
||||||
|
<p>Status: {scanStatus.scanning ? 'Scanning...' : scanStatus.status}</p>
|
||||||
|
{scanStatus.lastScan > 0 && (
|
||||||
|
<p>Last scan: {new Date(scanStatus.lastScan).toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleTriggerScan}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#0f3460',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Trigger Scan
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services Card */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
|
||||||
|
<div style={statStyle}>{services.length}</div>
|
||||||
|
<p>Services registered</p>
|
||||||
|
{services.length > 0 && (
|
||||||
|
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
|
||||||
|
{services.map((svc) => (
|
||||||
|
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
|
||||||
|
<strong>{svc.name}</strong>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: '#888', fontSize: '0.9rem' }}>
|
||||||
|
{svc.capabilities?.join(', ') || 'No capabilities'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface FileEvent {
|
||||||
|
type: 'add' | 'change' | 'delete' | 'rename';
|
||||||
|
path: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#16213e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Files() {
|
||||||
|
const [recentEvents, setRecentEvents] = useState<FileEvent[]>([]);
|
||||||
|
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/events/poll?limit=20');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setRecentEvents(data.events || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch events:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents();
|
||||||
|
const interval = setInterval(fetchEvents, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
return new Date(timestamp).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<h1 style={{ margin: '0 0 1rem 0', flexShrink: 0 }}>File Browser</h1>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '2fr 1fr',
|
||||||
|
gap: '1rem',
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0
|
||||||
|
}}>
|
||||||
|
{/* WebDAV Browser Iframe */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<iframe
|
||||||
|
src="/webdav-browser/"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
flex: 1,
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#fff',
|
||||||
|
display: 'block',
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
title="File Browser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Events */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ margin: '0 0 0.75rem 0', flexShrink: 0 }}>Recent File Events</h3>
|
||||||
|
{recentEvents.length === 0 ? (
|
||||||
|
<p style={{ color: '#888', margin: 0 }}>No recent events</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||||
|
{recentEvents.map((event, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem',
|
||||||
|
borderBottom: '1px solid #0f3460',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '0.1rem 0.3rem',
|
||||||
|
borderRadius: '3px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
marginRight: '0.5rem',
|
||||||
|
background:
|
||||||
|
event.type === 'add'
|
||||||
|
? '#166534'
|
||||||
|
: event.type === 'delete'
|
||||||
|
? '#7f1d1d'
|
||||||
|
: '#854d0e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{event.type}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: '#888' }}>{event.path}</span>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: '#555', fontSize: '0.8rem' }}>
|
||||||
|
{formatTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Mount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'nfs' | 'smb' | 'rclone';
|
||||||
|
enabled: boolean;
|
||||||
|
desiredMounted: boolean;
|
||||||
|
mountPath: string;
|
||||||
|
mounted: boolean;
|
||||||
|
error?: string;
|
||||||
|
lastChecked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RcloneRemote {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
background: '#16213e',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#0f3460',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '0.5rem',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dangerButtonStyle: React.CSSProperties = {
|
||||||
|
...buttonStyle,
|
||||||
|
background: '#7f1d1d',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Mounts() {
|
||||||
|
const [mounts, setMounts] = useState<Mount[]>([]);
|
||||||
|
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'nfs' as 'nfs' | 'smb' | 'rclone',
|
||||||
|
nfsServer: '',
|
||||||
|
nfsPath: '',
|
||||||
|
smbServer: '',
|
||||||
|
smbShare: '',
|
||||||
|
smbUsername: '',
|
||||||
|
smbPassword: '',
|
||||||
|
rcloneRemote: '',
|
||||||
|
rclonePath: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMounts = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mounts');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setMounts(data.mounts || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch mounts:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRemotes = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mounts/rclone/remotes');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setRemotes(data.remotes || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch remotes:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMounts();
|
||||||
|
fetchRemotes();
|
||||||
|
const interval = setInterval(fetchMounts, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMount = async (id: string) => {
|
||||||
|
await fetch(`/api/mounts/${id}/mount`, { method: 'POST' });
|
||||||
|
fetchMounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnmount = async (id: string) => {
|
||||||
|
await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' });
|
||||||
|
fetchMounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this mount?')) return;
|
||||||
|
await fetch(`/api/mounts/${id}`, { method: 'DELETE' });
|
||||||
|
fetchMounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: formData.name,
|
||||||
|
type: formData.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.type === 'nfs') {
|
||||||
|
body.nfsServer = formData.nfsServer;
|
||||||
|
body.nfsPath = formData.nfsPath;
|
||||||
|
} else if (formData.type === 'smb') {
|
||||||
|
body.smbServer = formData.smbServer;
|
||||||
|
body.smbShare = formData.smbShare;
|
||||||
|
if (formData.smbUsername) body.smbUsername = formData.smbUsername;
|
||||||
|
if (formData.smbPassword) body.smbPassword = formData.smbPassword;
|
||||||
|
} else if (formData.type === 'rclone') {
|
||||||
|
body.rcloneRemote = formData.rcloneRemote;
|
||||||
|
body.rclonePath = formData.rclonePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch('/api/mounts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
setShowForm(false);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
type: 'nfs',
|
||||||
|
nfsServer: '',
|
||||||
|
nfsPath: '',
|
||||||
|
smbServer: '',
|
||||||
|
smbShare: '',
|
||||||
|
smbUsername: '',
|
||||||
|
smbPassword: '',
|
||||||
|
rcloneRemote: '',
|
||||||
|
rclonePath: '',
|
||||||
|
});
|
||||||
|
fetchMounts();
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.5rem',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
background: '#1a1a2e',
|
||||||
|
border: '1px solid #0f3460',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#fff',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div style={{ padding: '2rem' }}>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
|
||||||
|
<h1>Mount Management</h1>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||||
|
<a
|
||||||
|
href="/rclone/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ ...buttonStyle, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}
|
||||||
|
>
|
||||||
|
rclone UI
|
||||||
|
</a>
|
||||||
|
<button style={buttonStyle} onClick={() => setShowForm(!showForm)}>
|
||||||
|
{showForm ? 'Cancel' : 'Add Mount'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<h3 style={{ marginBottom: '1rem' }}>Add New Mount</h3>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Mount Name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'nfs' | 'smb' | 'rclone' })}
|
||||||
|
>
|
||||||
|
<option value="nfs">NFS</option>
|
||||||
|
<option value="smb">SMB/CIFS</option>
|
||||||
|
<option value="rclone">rclone</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{formData.type === 'nfs' && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="NFS Server (e.g., 192.168.1.100)"
|
||||||
|
value={formData.nfsServer}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nfsServer: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="NFS Path (e.g., /export/media)"
|
||||||
|
value={formData.nfsPath}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nfsPath: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.type === 'smb' && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="SMB Server (e.g., 192.168.1.100)"
|
||||||
|
value={formData.smbServer}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smbServer: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Share Name (e.g., media)"
|
||||||
|
value={formData.smbShare}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smbShare: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Username (optional)"
|
||||||
|
value={formData.smbUsername}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smbUsername: e.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
type="password"
|
||||||
|
placeholder="Password (optional)"
|
||||||
|
value={formData.smbPassword}
|
||||||
|
onChange={(e) => setFormData({ ...formData, smbPassword: e.target.value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.type === 'rclone' && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
style={inputStyle}
|
||||||
|
value={formData.rcloneRemote}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rcloneRemote: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select Remote</option>
|
||||||
|
{remotes.map((r) => (
|
||||||
|
<option key={r.name} value={r.name}>
|
||||||
|
{r.name} ({r.type})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Remote Path (e.g., /path/to/folder)"
|
||||||
|
value={formData.rclonePath}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rclonePath: e.target.value })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" style={buttonStyle}>
|
||||||
|
Create Mount
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mounts.length === 0 ? (
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<p>No mounts configured. Click "Add Mount" to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
mounts.map((mount) => (
|
||||||
|
<div key={mount.id} style={cardStyle}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<h3>{mount.name}</h3>
|
||||||
|
<p style={{ color: '#888' }}>
|
||||||
|
Type: {mount.type} | Path: {mount.mountPath}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Status:{' '}
|
||||||
|
<span style={{ color: mount.mounted ? '#4ade80' : '#f87171' }}>
|
||||||
|
{mount.mounted ? 'Mounted' : 'Not mounted'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{mount.error && (
|
||||||
|
<p style={{ color: '#f87171', marginTop: '0.5rem' }}>Error: {mount.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{mount.mounted ? (
|
||||||
|
<button style={buttonStyle} onClick={() => handleUnmount(mount.id)}>
|
||||||
|
Unmount
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button style={buttonStyle} onClick={() => handleMount(mount.id)}>
|
||||||
|
Mount
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button style={dangerButtonStyle} onClick={() => handleDelete(mount.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:9000',
|
||||||
|
'/health': 'http://localhost:9000',
|
||||||
|
'/meta': 'http://localhost:9000',
|
||||||
|
'/webdav': 'http://localhost:80',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# meta-core Standalone Container
|
||||||
|
# Multi-stage build for meta-core as a standalone service
|
||||||
|
#
|
||||||
|
# Includes:
|
||||||
|
# - meta-core Go binary (leader election, Redis, HTTP API, Mount API, Watcher)
|
||||||
|
# - nginx (dashboard, WebDAV, API proxy)
|
||||||
|
# - Redis (leader-managed)
|
||||||
|
# - rclone (remote mount daemon)
|
||||||
|
# - supervisord (process management)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage 1: Build Go binary
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM golang:1.21-alpine AS go-builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files first for caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Download dependencies and update go.sum
|
||||||
|
RUN go mod tidy && go mod download
|
||||||
|
|
||||||
|
# Build the binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o meta-core ./cmd/meta-core
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage 2: Build Dashboard
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM node:21-alpine AS dashboard-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY dashboard/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY dashboard/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage 3: Build Metadata Editor (standalone)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM node:21-alpine AS editor-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY editor/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY editor/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Stage 4: Final runtime image
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Container registry metadata
|
||||||
|
LABEL org.opencontainers.image.source=https://github.com/worph/meta-core
|
||||||
|
LABEL org.opencontainers.image.description="MetaMesh meta-core - centralized data layer with leader election, Redis, WebDAV, mounts"
|
||||||
|
LABEL org.opencontainers.image.licenses=MIT
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
# Core services
|
||||||
|
redis \
|
||||||
|
nginx \
|
||||||
|
nginx-mod-http-dav-ext \
|
||||||
|
supervisor \
|
||||||
|
rclone \
|
||||||
|
fuse3 \
|
||||||
|
# Mount utilities
|
||||||
|
nfs-utils \
|
||||||
|
cifs-utils \
|
||||||
|
# Tools
|
||||||
|
bash \
|
||||||
|
curl \
|
||||||
|
python3 \
|
||||||
|
ca-certificates \
|
||||||
|
apache2-utils \
|
||||||
|
# Process utilities
|
||||||
|
procps
|
||||||
|
|
||||||
|
# Copy Go binary from builder
|
||||||
|
COPY --from=go-builder /build/meta-core /usr/local/bin/meta-core
|
||||||
|
RUN chmod +x /usr/local/bin/meta-core
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
RUN mkdir -p \
|
||||||
|
/meta-core/locks \
|
||||||
|
/meta-core/db/redis \
|
||||||
|
/meta-core/services \
|
||||||
|
/meta-core/mounts/errors \
|
||||||
|
/files \
|
||||||
|
/app/dashboard/dist \
|
||||||
|
/app/editor/dist \
|
||||||
|
/var/log/supervisor \
|
||||||
|
/var/run/supervisor \
|
||||||
|
/etc/nginx/ssl
|
||||||
|
|
||||||
|
# Copy configuration files
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY docker/mount-watcher.sh /app/scripts/mount-watcher.sh
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
# Copy dashboard static files from builder
|
||||||
|
COPY --from=dashboard-builder /app/dist /app/dashboard/dist
|
||||||
|
|
||||||
|
# Copy editor static files from builder
|
||||||
|
COPY --from=editor-builder /app/dist /app/editor/dist
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chmod +x /app/scripts/mount-watcher.sh /entrypoint.sh
|
||||||
|
|
||||||
|
# Create htpasswd for rclone basic auth (admin:admin)
|
||||||
|
RUN htpasswd -bc /etc/nginx/.htpasswd admin admin
|
||||||
|
|
||||||
|
# Environment defaults
|
||||||
|
ENV META_CORE_PATH=/meta-core \
|
||||||
|
FILES_PATH=/files \
|
||||||
|
SERVICE_NAME=meta-core \
|
||||||
|
SERVICE_VERSION=1.0.0 \
|
||||||
|
META_CORE_HTTP_PORT=9000 \
|
||||||
|
META_CORE_HTTP_HOST=0.0.0.0 \
|
||||||
|
REDIS_PORT=6379 \
|
||||||
|
WATCH_FOLDER_LIST=/files/ \
|
||||||
|
WATCH_INTERVAL_MS=1000
|
||||||
|
|
||||||
|
# Expose ports
|
||||||
|
# 80: nginx (dashboard + API proxy + WebDAV)
|
||||||
|
# 6379: Redis (for other services)
|
||||||
|
EXPOSE 80 6379
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -sf http://localhost/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# meta-core Entrypoint Script
|
||||||
|
#
|
||||||
|
# Initializes directories and starts supervisord
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
mkdir -p /meta-core/locks
|
||||||
|
mkdir -p /meta-core/db/redis
|
||||||
|
mkdir -p /meta-core/services
|
||||||
|
mkdir -p /meta-core/mounts/errors
|
||||||
|
mkdir -p /files
|
||||||
|
mkdir -p /var/log/supervisor
|
||||||
|
mkdir -p /var/log/nginx
|
||||||
|
mkdir -p /var/run/supervisor
|
||||||
|
|
||||||
|
# Ensure proper permissions
|
||||||
|
chmod 755 /meta-core
|
||||||
|
chmod 755 /files
|
||||||
|
|
||||||
|
# Initialize mounts config if not exists
|
||||||
|
if [ ! -f /meta-core/mounts/mounts.json ]; then
|
||||||
|
echo '{"version":1,"mounts":[]}' > /meta-core/mounts/mounts.json
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[entrypoint] meta-core starting..."
|
||||||
|
echo "[entrypoint] META_CORE_PATH=${META_CORE_PATH:-/meta-core}"
|
||||||
|
echo "[entrypoint] FILES_PATH=${FILES_PATH:-/files}"
|
||||||
|
echo "[entrypoint] SERVICE_NAME=${SERVICE_NAME:-meta-core}"
|
||||||
|
echo "[entrypoint] WATCH_FOLDER_LIST=${WATCH_FOLDER_LIST:-/files/}"
|
||||||
|
|
||||||
|
# Execute the main command (supervisord)
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# =============================================================================
|
||||||
|
# Mount Watcher Script
|
||||||
|
#
|
||||||
|
# Monitors /meta-core/mounts/mounts.json and manages NFS, SMB, and rclone mounts.
|
||||||
|
# Runs as a supervisord program, polling every 5 seconds.
|
||||||
|
#
|
||||||
|
# Uses findmnt to check actual mount status (source of truth).
|
||||||
|
# Writes errors to /meta-core/mounts/errors/{id}.error
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
CONFIG_DIR="${META_CORE_PATH:-/meta-core}/mounts"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/mounts.json"
|
||||||
|
ERROR_DIR="$CONFIG_DIR/errors"
|
||||||
|
FILES_PATH="${FILES_PATH:-/files}"
|
||||||
|
POLL_INTERVAL="${MOUNT_POLL_INTERVAL:-5}"
|
||||||
|
|
||||||
|
# ANSI colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_success() {
|
||||||
|
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${GREEN}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${YELLOW}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "[$(date -Iseconds)] [${CYAN}mount-watcher${NC}] ${RED}$1${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Initialize directories
|
||||||
|
init_dirs() {
|
||||||
|
mkdir -p "$CONFIG_DIR" "$ERROR_DIR"
|
||||||
|
|
||||||
|
# Initialize empty config if not exists
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
echo '{"version":1,"mounts":[]}' > "$CONFIG_FILE"
|
||||||
|
log "Initialized empty mounts config"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if path is mounted
|
||||||
|
is_mounted() {
|
||||||
|
local mount_path="$1"
|
||||||
|
findmnt -n "$mount_path" > /dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear error file for mount
|
||||||
|
clear_error() {
|
||||||
|
local id="$1"
|
||||||
|
rm -f "$ERROR_DIR/$id.error"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write error to file
|
||||||
|
write_error() {
|
||||||
|
local id="$1"
|
||||||
|
local error="$2"
|
||||||
|
echo "$(date -Iseconds)" > "$ERROR_DIR/$id.error"
|
||||||
|
echo "$error" >> "$ERROR_DIR/$id.error"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount NFS share (always read-only)
|
||||||
|
do_mount_nfs() {
|
||||||
|
local id="$1"
|
||||||
|
local server="$2"
|
||||||
|
local path="$3"
|
||||||
|
local mount_path="$4"
|
||||||
|
local options="${5:-}"
|
||||||
|
|
||||||
|
mkdir -p "$mount_path"
|
||||||
|
|
||||||
|
# Always mount read-only for safety
|
||||||
|
local mount_opts="ro"
|
||||||
|
[ -n "$options" ] && mount_opts="${mount_opts},${options}"
|
||||||
|
|
||||||
|
log "Mounting NFS (read-only): ${server}:${path} -> ${mount_path}"
|
||||||
|
output=$(mount -t nfs -o "${mount_opts}" "${server}:${path}" "${mount_path}" 2>&1)
|
||||||
|
local rc=$?
|
||||||
|
|
||||||
|
if [ $rc -eq 0 ]; then
|
||||||
|
clear_error "$id"
|
||||||
|
log_success "NFS mount successful: $mount_path"
|
||||||
|
else
|
||||||
|
write_error "$id" "$output"
|
||||||
|
log_error "NFS mount failed: $output"
|
||||||
|
fi
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount SMB/CIFS share (always read-only)
|
||||||
|
do_mount_smb() {
|
||||||
|
local id="$1"
|
||||||
|
local server="$2"
|
||||||
|
local share="$3"
|
||||||
|
local mount_path="$4"
|
||||||
|
local username="${5:-}"
|
||||||
|
local password_obscured="${6:-}"
|
||||||
|
local domain="${7:-}"
|
||||||
|
local options="${8:-}"
|
||||||
|
|
||||||
|
mkdir -p "$mount_path"
|
||||||
|
|
||||||
|
# Always mount read-only for safety
|
||||||
|
local mount_opts="ro"
|
||||||
|
|
||||||
|
# If we have credentials, reveal password and build options
|
||||||
|
if [ -n "$username" ]; then
|
||||||
|
mount_opts="${mount_opts},username=${username}"
|
||||||
|
|
||||||
|
if [ -n "$password_obscured" ]; then
|
||||||
|
# Reveal password using rclone (only exists in memory briefly)
|
||||||
|
local password
|
||||||
|
password=$(rclone reveal "$password_obscured" 2>/dev/null)
|
||||||
|
if [ -n "$password" ]; then
|
||||||
|
mount_opts="${mount_opts},password=${password}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$domain" ]; then
|
||||||
|
mount_opts="${mount_opts},domain=${domain}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add any additional options
|
||||||
|
if [ -n "$options" ]; then
|
||||||
|
if [ -n "$mount_opts" ]; then
|
||||||
|
mount_opts="${mount_opts},${options}"
|
||||||
|
else
|
||||||
|
mount_opts="$options"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Mounting SMB (read-only): //${server}/${share} -> ${mount_path}"
|
||||||
|
|
||||||
|
# Execute mount directly (not via eval) to handle special chars in password
|
||||||
|
local output
|
||||||
|
local rc
|
||||||
|
if [ -n "$mount_opts" ]; then
|
||||||
|
output=$(mount -t cifs "//${server}/${share}" "${mount_path}" -o "${mount_opts}" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
else
|
||||||
|
output=$(mount -t cifs "//${server}/${share}" "${mount_path}" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clear password from any variables (best effort)
|
||||||
|
password=""
|
||||||
|
mount_opts=""
|
||||||
|
|
||||||
|
if [ $rc -eq 0 ]; then
|
||||||
|
clear_error "$id"
|
||||||
|
log_success "SMB mount successful: $mount_path"
|
||||||
|
else
|
||||||
|
write_error "$id" "$output"
|
||||||
|
log_error "SMB mount failed: $output"
|
||||||
|
fi
|
||||||
|
return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mount rclone remote using RC API (always read-only)
|
||||||
|
do_mount_rclone() {
|
||||||
|
local id="$1"
|
||||||
|
local remote="$2"
|
||||||
|
local remote_path="$3"
|
||||||
|
local mount_path="$4"
|
||||||
|
local options="${5:-}"
|
||||||
|
|
||||||
|
mkdir -p "$mount_path"
|
||||||
|
|
||||||
|
# Build the remote source string
|
||||||
|
local fs="${remote}:${remote_path}"
|
||||||
|
|
||||||
|
# Use rclone RC API to mount (always read-only for safety)
|
||||||
|
log "Mounting rclone (read-only): ${fs} -> ${mount_path}"
|
||||||
|
|
||||||
|
# Build JSON body for rclone mount with read-only option
|
||||||
|
local json_body
|
||||||
|
json_body=$(cat <<EOF
|
||||||
|
{
|
||||||
|
"fs": "${fs}",
|
||||||
|
"mountPoint": "${mount_path}",
|
||||||
|
"mountOpt": {
|
||||||
|
"AllowOther": true,
|
||||||
|
"ReadOnly": true
|
||||||
|
},
|
||||||
|
"vfsOpt": {
|
||||||
|
"CacheMode": 2,
|
||||||
|
"ReadOnly": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
output=$(curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-u admin:admin \
|
||||||
|
-d "$json_body" \
|
||||||
|
"http://127.0.0.1:5572/mount/mount" 2>&1)
|
||||||
|
|
||||||
|
# Wait a moment for mount to establish
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
if is_mounted "$mount_path"; then
|
||||||
|
clear_error "$id"
|
||||||
|
log_success "rclone mount successful: $mount_path"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
local error_msg="rclone mount failed"
|
||||||
|
# Try to extract error from response
|
||||||
|
if echo "$output" | grep -q "error"; then
|
||||||
|
error_msg="$output"
|
||||||
|
fi
|
||||||
|
write_error "$id" "$error_msg"
|
||||||
|
log_error "rclone mount failed: $error_msg"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unmount a path
|
||||||
|
do_unmount() {
|
||||||
|
local id="$1"
|
||||||
|
local mount_path="$2"
|
||||||
|
local type="$3"
|
||||||
|
|
||||||
|
if ! is_mounted "$mount_path"; then
|
||||||
|
log "Already unmounted: $mount_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$type" = "rclone" ]; then
|
||||||
|
# Use rclone RC API to unmount
|
||||||
|
log "Unmounting rclone: $mount_path"
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-u admin:admin \
|
||||||
|
-d "{\"mountPoint\":\"${mount_path}\"}" \
|
||||||
|
"http://127.0.0.1:5572/mount/unmount" > /dev/null 2>&1
|
||||||
|
else
|
||||||
|
log "Unmounting: $mount_path"
|
||||||
|
umount "$mount_path" 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait a moment
|
||||||
|
sleep 1
|
||||||
|
|
||||||
|
# Check if still mounted
|
||||||
|
if is_mounted "$mount_path"; then
|
||||||
|
# Force unmount with lazy option
|
||||||
|
log_warn "Force unmounting (lazy): $mount_path"
|
||||||
|
umount -l "$mount_path" 2>&1
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if is_mounted "$mount_path"; then
|
||||||
|
log_error "Failed to unmount: $mount_path"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Unmounted: $mount_path"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process all mounts from config
|
||||||
|
process_mounts() {
|
||||||
|
# Check if config file exists
|
||||||
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse JSON using python3
|
||||||
|
local mounts
|
||||||
|
mounts=$(python3 -c '
|
||||||
|
import sys, json
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("'"$CONFIG_FILE"'", "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for m in data.get("mounts", []):
|
||||||
|
# Output pipe-separated values
|
||||||
|
fields = [
|
||||||
|
m.get("id", ""),
|
||||||
|
m.get("type", ""),
|
||||||
|
str(m.get("enabled", True)),
|
||||||
|
str(m.get("desiredMounted", False)),
|
||||||
|
m.get("mountPath", ""),
|
||||||
|
m.get("nfsServer", ""),
|
||||||
|
m.get("nfsPath", ""),
|
||||||
|
m.get("smbServer", ""),
|
||||||
|
m.get("smbShare", ""),
|
||||||
|
m.get("smbUsername", ""),
|
||||||
|
m.get("smbPasswordObscured", ""),
|
||||||
|
m.get("smbDomain", ""),
|
||||||
|
m.get("rcloneRemote", ""),
|
||||||
|
m.get("rclonePath", ""),
|
||||||
|
m.get("options", "")
|
||||||
|
]
|
||||||
|
print("|".join(fields))
|
||||||
|
' 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$mounts" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS='|' read -r id type enabled desired mount_path nfs_server nfs_path smb_server smb_share smb_username smb_password_obscured smb_domain rclone_remote rclone_path options; do
|
||||||
|
# Skip empty lines
|
||||||
|
[ -z "$id" ] && continue
|
||||||
|
|
||||||
|
# Check actual mount status
|
||||||
|
local is_mounted_now=false
|
||||||
|
is_mounted "$mount_path" && is_mounted_now=true
|
||||||
|
|
||||||
|
# Handle disabled mounts - unmount if currently mounted
|
||||||
|
if [ "$enabled" != "True" ] && [ "$enabled" != "true" ]; then
|
||||||
|
if [ "$is_mounted_now" = true ]; then
|
||||||
|
log "Mount $id ($mount_path) disabled, unmounting..."
|
||||||
|
do_unmount "$id" "$mount_path" "$type"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle desired state
|
||||||
|
if [ "$desired" = "True" ] || [ "$desired" = "true" ]; then
|
||||||
|
# Should be mounted
|
||||||
|
if [ "$is_mounted_now" = false ]; then
|
||||||
|
log "Mount $id ($mount_path) desired but not mounted, mounting..."
|
||||||
|
case "$type" in
|
||||||
|
nfs)
|
||||||
|
do_mount_nfs "$id" "$nfs_server" "$nfs_path" "$mount_path" "$options"
|
||||||
|
;;
|
||||||
|
smb)
|
||||||
|
do_mount_smb "$id" "$smb_server" "$smb_share" "$mount_path" "$smb_username" "$smb_password_obscured" "$smb_domain" "$options"
|
||||||
|
;;
|
||||||
|
rclone)
|
||||||
|
do_mount_rclone "$id" "$rclone_remote" "$rclone_path" "$mount_path" "$options"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
log_error "Unknown mount type: $type"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Should be unmounted
|
||||||
|
if [ "$is_mounted_now" = true ]; then
|
||||||
|
log "Mount $id ($mount_path) not desired, unmounting..."
|
||||||
|
do_unmount "$id" "$mount_path" "$type"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done <<< "$mounts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
main() {
|
||||||
|
log "Starting mount watcher (poll interval: ${POLL_INTERVAL}s)"
|
||||||
|
|
||||||
|
init_dirs
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
process_mounts
|
||||||
|
sleep "$POLL_INTERVAL"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle signals for graceful shutdown
|
||||||
|
cleanup() {
|
||||||
|
log "Shutting down mount watcher..."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||||
|
|
||||||
|
# Upstream: meta-core Go API
|
||||||
|
upstream meta-core-api {
|
||||||
|
server 127.0.0.1:9000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upstream: rclone RC API
|
||||||
|
upstream rclone {
|
||||||
|
server 127.0.0.1:5572;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Dashboard (React SPA)
|
||||||
|
# =========================================================================
|
||||||
|
root /app/dashboard/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Go API routes
|
||||||
|
# All /api/* routes are proxied to meta-core Go server
|
||||||
|
# =========================================================================
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://meta-core-api/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# SSE support for /api/events/subscribe
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 86400s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Health check (direct to Go API)
|
||||||
|
# =========================================================================
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://meta-core-api/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Metadata API (for services to access metadata)
|
||||||
|
# =========================================================================
|
||||||
|
location /meta/ {
|
||||||
|
proxy_pass http://meta-core-api/meta/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /meta {
|
||||||
|
proxy_pass http://meta-core-api/meta;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# File/Data API endpoints
|
||||||
|
# =========================================================================
|
||||||
|
location /file/ {
|
||||||
|
proxy_pass http://meta-core-api/file/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /data/ {
|
||||||
|
proxy_pass http://meta-core-api/data/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Service discovery
|
||||||
|
# =========================================================================
|
||||||
|
location /services {
|
||||||
|
proxy_pass http://meta-core-api/services;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /services/ {
|
||||||
|
proxy_pass http://meta-core-api/services/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Leader/role endpoints
|
||||||
|
# =========================================================================
|
||||||
|
location /leader {
|
||||||
|
proxy_pass http://meta-core-api/leader;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /role {
|
||||||
|
proxy_pass http://meta-core-api/role;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# rclone Web UI (proxied with basic auth)
|
||||||
|
# =========================================================================
|
||||||
|
location /rclone/ {
|
||||||
|
proxy_pass http://rclone/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Basic auth for rclone
|
||||||
|
auth_basic "rclone";
|
||||||
|
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Metadata Editor (standalone React app)
|
||||||
|
# Serves static files for the metadata editor
|
||||||
|
# API routes (/api/metadata/*, /api/kv/*) are handled by the Go API
|
||||||
|
# via the /api/ proxy above
|
||||||
|
# =========================================================================
|
||||||
|
location /editor/ {
|
||||||
|
alias /app/editor/dist/;
|
||||||
|
index index.html;
|
||||||
|
try_files $uri $uri/ /editor/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# WebDAV HTML browser (for iframe embedding in dashboard)
|
||||||
|
# Returns HTML directory listings instead of JSON
|
||||||
|
# =========================================================================
|
||||||
|
location /webdav-browser/ {
|
||||||
|
alias /files/;
|
||||||
|
autoindex on;
|
||||||
|
# No autoindex_format = defaults to HTML
|
||||||
|
charset utf-8;
|
||||||
|
}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# WebDAV endpoint for file access
|
||||||
|
# Exposes /files directory for read/write operations
|
||||||
|
# Supports: GET (read), PUT (write), DELETE, MKCOL (mkdir), PROPFIND
|
||||||
|
# =========================================================================
|
||||||
|
location /webdav/ {
|
||||||
|
alias /files/;
|
||||||
|
|
||||||
|
# WebDAV methods
|
||||||
|
dav_methods PUT DELETE MKCOL COPY MOVE;
|
||||||
|
dav_access user:rw group:rw all:r;
|
||||||
|
|
||||||
|
# Enable autoindex for directory listings (alternative to PROPFIND)
|
||||||
|
autoindex on;
|
||||||
|
autoindex_format json;
|
||||||
|
|
||||||
|
# Allow creating directories automatically
|
||||||
|
create_full_put_path on;
|
||||||
|
|
||||||
|
# No size limit for uploads (large media files)
|
||||||
|
client_max_body_size 0;
|
||||||
|
|
||||||
|
# Optimize for large file transfers
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
|
||||||
|
# Output buffering for large files
|
||||||
|
output_buffers 1 512k;
|
||||||
|
aio threads;
|
||||||
|
|
||||||
|
# CORS headers for cross-container access
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, OPTIONS' always;
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Depth, Range, If-Range' always;
|
||||||
|
add_header 'Access-Control-Expose-Headers' 'Content-Length, Content-Range, Accept-Ranges' always;
|
||||||
|
|
||||||
|
# Handle OPTIONS preflight
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
add_header 'Access-Control-Allow-Methods' 'GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, OPTIONS';
|
||||||
|
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Depth, Range, If-Range';
|
||||||
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||||
|
add_header 'Content-Length' 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
loglevel=info
|
||||||
|
|
||||||
|
[unix_http_server]
|
||||||
|
file=/var/run/supervisor.sock
|
||||||
|
chmod=0700
|
||||||
|
|
||||||
|
[supervisorctl]
|
||||||
|
serverurl=unix:///var/run/supervisor.sock
|
||||||
|
|
||||||
|
[rpcinterface:supervisor]
|
||||||
|
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# meta-core: Leader election, Redis management, HTTP API, Mount API, File Watcher
|
||||||
|
# =============================================================================
|
||||||
|
[program:meta-core]
|
||||||
|
command=/usr/local/bin/meta-core
|
||||||
|
priority=5
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
startsecs=3
|
||||||
|
environment=
|
||||||
|
META_CORE_PATH="%(ENV_META_CORE_PATH)s",
|
||||||
|
FILES_PATH="%(ENV_FILES_PATH)s",
|
||||||
|
SERVICE_NAME="%(ENV_SERVICE_NAME)s",
|
||||||
|
SERVICE_VERSION="%(ENV_SERVICE_VERSION)s",
|
||||||
|
META_CORE_HTTP_PORT="%(ENV_META_CORE_HTTP_PORT)s",
|
||||||
|
META_CORE_HTTP_HOST="%(ENV_META_CORE_HTTP_HOST)s",
|
||||||
|
REDIS_PORT="%(ENV_REDIS_PORT)s",
|
||||||
|
WATCH_FOLDER_LIST="%(ENV_WATCH_FOLDER_LIST)s",
|
||||||
|
WATCH_INTERVAL_MS="%(ENV_WATCH_INTERVAL_MS)s",
|
||||||
|
API_PORT="80",
|
||||||
|
BASE_URL="%(ENV_BASE_URL)s"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# nginx: Dashboard, API proxy, WebDAV file serving
|
||||||
|
# =============================================================================
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
priority=20
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/var/log/supervisor/nginx.log
|
||||||
|
stderr_logfile=/var/log/supervisor/nginx_error.log
|
||||||
|
startsecs=2
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# rclone: Remote storage mount daemon (RC API on port 5572)
|
||||||
|
# =============================================================================
|
||||||
|
[program:rclone]
|
||||||
|
command=/usr/bin/rclone rcd --rc-web-gui --rc-web-gui-no-open-browser --rc-addr=:5572 --rc-user=admin --rc-pass=admin
|
||||||
|
priority=30
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/var/log/supervisor/rclone.log
|
||||||
|
stderr_logfile=/var/log/supervisor/rclone_error.log
|
||||||
|
startsecs=3
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# mount-watcher: Monitors mounts.json and manages NFS/SMB/rclone mounts
|
||||||
|
# =============================================================================
|
||||||
|
[program:mount-watcher]
|
||||||
|
command=/app/scripts/mount-watcher.sh
|
||||||
|
priority=35
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/var/log/supervisor/mount-watcher.log
|
||||||
|
stderr_logfile=/var/log/supervisor/mount-watcher_error.log
|
||||||
|
startsecs=3
|
||||||
|
environment=
|
||||||
|
META_CORE_PATH="%(ENV_META_CORE_PATH)s",
|
||||||
|
FILES_PATH="%(ENV_FILES_PATH)s"
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# @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.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>MetaMesh Metadata Editor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1570
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "@meta-core/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
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<SearchResult[]>([]);
|
||||||
|
const [selectedHashId, setSelectedHashId] = useState<string | undefined>();
|
||||||
|
const [selectedHashIds, setSelectedHashIds] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkEditMode, setBulkEditMode] = useState(false);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [isLoadingDirectHash, setIsLoadingDirectHash] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>MetaMesh Metadata Editor</h1>
|
||||||
|
<p className="app-description">
|
||||||
|
Search and edit metadata for your media files
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="app-main">
|
||||||
|
<SearchBar onSearch={handleSearch} isLoading={isSearching} />
|
||||||
|
|
||||||
|
{error && <div className="app-error">{error}</div>}
|
||||||
|
|
||||||
|
{isLoadingDirectHash && (
|
||||||
|
<div className="app-loading">Loading file metadata...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isSearching && !isLoadingDirectHash && searchResults.length === 0 && !error && (
|
||||||
|
<div className="app-welcome">
|
||||||
|
<h2>Welcome to MetaMesh Metadata Editor</h2>
|
||||||
|
<p>Search for files by title, filename, file path, or hash ID to get started.</p>
|
||||||
|
<p className="app-welcome-features">
|
||||||
|
Unified search across all fields | Single file editing | Bulk editing | Direct access via URL
|
||||||
|
</p>
|
||||||
|
<div className="app-kv-browser">
|
||||||
|
<KVTreeView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="app-content">
|
||||||
|
<div className="app-sidebar">
|
||||||
|
<FileList
|
||||||
|
results={searchResults}
|
||||||
|
selectedHashId={selectedHashId}
|
||||||
|
selectedHashIds={selectedHashIds}
|
||||||
|
bulkEditMode={bulkEditMode}
|
||||||
|
onSelectFile={handleSelectFile}
|
||||||
|
onToggleSelection={handleToggleSelection}
|
||||||
|
onSelectAll={handleSelectAll}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
onBulkEditMode={handleBulkEditMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="app-editor">
|
||||||
|
{bulkEditMode && selectedHashIds.size > 0 ? (
|
||||||
|
<BulkMetadataEditor
|
||||||
|
hashIds={Array.from(selectedHashIds)}
|
||||||
|
onSaved={handleBulkMetadataSaved}
|
||||||
|
onCancel={handleClearSelection}
|
||||||
|
/>
|
||||||
|
) : selectedHashId ? (
|
||||||
|
<MetadataEditor
|
||||||
|
key={selectedHashId}
|
||||||
|
hashId={selectedHashId}
|
||||||
|
onSaved={handleMetadataSaved}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="app-no-selection">
|
||||||
|
{bulkEditMode
|
||||||
|
? 'Select files from the list to bulk edit metadata'
|
||||||
|
: 'Select a file from the list to edit its metadata'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>
|
||||||
|
MetaMesh Metadata Editor | Part of{' '}
|
||||||
|
<a href="/" target="_blank">
|
||||||
|
MetaMesh
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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<SearchResponse> {
|
||||||
|
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<FileMetadata> {
|
||||||
|
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<UpdateResult> {
|
||||||
|
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<UpdateResult> {
|
||||||
|
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<string, string>;
|
||||||
|
}>
|
||||||
|
): Promise<BatchUpdateResponse> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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<PluginSchema[]>([]);
|
||||||
|
const [commonValues, setCommonValues] = useState<Record<string, any>>({});
|
||||||
|
const [editedFields, setEditedFields] = useState<Partial<FileMetadata>>({});
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(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<string, any> = {};
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div className="bulk-metadata-editor">
|
||||||
|
<div className="bulk-metadata-editor-loading">
|
||||||
|
Loading metadata for {hashIds.length} files...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build display metadata (edited values or common values)
|
||||||
|
const displayMetadata: Record<string, any> = { ...commonValues, ...editedFields };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bulk-metadata-editor">
|
||||||
|
<div className="bulk-metadata-editor-header">
|
||||||
|
<h2>Bulk Edit Metadata</h2>
|
||||||
|
<p className="bulk-info">
|
||||||
|
Editing {hashIds.length} file{hashIds.length !== 1 ? 's' : ''} - Only modified fields will
|
||||||
|
be updated. Fields showing "*" have different values across files.
|
||||||
|
</p>
|
||||||
|
<div className="bulk-metadata-editor-actions">
|
||||||
|
{onCancel && (
|
||||||
|
<button onClick={onCancel} disabled={isSaving}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={handleSave} disabled={isSaving || !hasChanges} className="primary">
|
||||||
|
{isSaving ? 'Saving...' : `Update ${hashIds.length} File${hashIds.length !== 1 ? 's' : ''}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="bulk-metadata-editor-message success">{successMessage}</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="bulk-metadata-editor-message error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="bulk-metadata-editor-form">
|
||||||
|
{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 (
|
||||||
|
<section
|
||||||
|
key={plugin.id}
|
||||||
|
className="bulk-metadata-section"
|
||||||
|
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
||||||
|
>
|
||||||
|
<div className="bulk-metadata-section-header">
|
||||||
|
<h3>{plugin.name}</h3>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="section-hint">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="section-hint-bulk">
|
||||||
|
Common values shown. "*" indicates different values. Edit to update all files.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-fields">
|
||||||
|
{editableFields.map(([key, field]) => (
|
||||||
|
<DynamicFieldRenderer
|
||||||
|
key={key}
|
||||||
|
fieldKey={key}
|
||||||
|
schema={field}
|
||||||
|
metadata={displayMetadata as FileMetadata}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
readonly={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Modified Fields Summary */}
|
||||||
|
{hasChanges && (
|
||||||
|
<section className="bulk-metadata-section modified-fields">
|
||||||
|
<h3>Fields to be Updated ({Object.keys(editedFields).length})</h3>
|
||||||
|
<div className="modified-fields-list">
|
||||||
|
{Object.entries(editedFields).map(([key, value]) => (
|
||||||
|
<div key={key} className="modified-field-item">
|
||||||
|
<span className="field-name">{key}:</span>
|
||||||
|
<span className="field-value">
|
||||||
|
{Array.isArray(value)
|
||||||
|
? value.join(', ')
|
||||||
|
: typeof value === 'object'
|
||||||
|
? JSON.stringify(value)
|
||||||
|
: String(value || '(empty)')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="remove-field-btn"
|
||||||
|
onClick={() => handleClearField(key)}
|
||||||
|
title="Remove this field"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
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 (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer" className="field-link">
|
||||||
|
{displayValue}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return displayValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render based on field type
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'boolean':
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(value)}
|
||||||
|
onChange={(e) => setValue(e.target.checked)}
|
||||||
|
disabled={isReadonly}
|
||||||
|
/>
|
||||||
|
<span>{schema.label}</span>
|
||||||
|
</label>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => setValue(e.target.value ? Number(e.target.value) : undefined)}
|
||||||
|
disabled={isReadonly}
|
||||||
|
min={schema.min}
|
||||||
|
max={schema.max}
|
||||||
|
placeholder={schema.placeholder}
|
||||||
|
/>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => setValue(e.target.value || undefined)}
|
||||||
|
disabled={isReadonly}
|
||||||
|
>
|
||||||
|
<option value="">Select...</option>
|
||||||
|
{schema.options?.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<textarea
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => setValue(e.target.value || undefined)}
|
||||||
|
disabled={isReadonly}
|
||||||
|
placeholder={schema.placeholder}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'array':
|
||||||
|
// Convert RecordSet to comma-separated string for display
|
||||||
|
const arrayValue = (() => {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return Object.values(value).join(', ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Convert comma-separated string back to RecordSet
|
||||||
|
const handleArrayChange = (str: string) => {
|
||||||
|
const items = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||||
|
if (items.length === 0) {
|
||||||
|
setValue(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const recordSet: Record<string, string> = {};
|
||||||
|
items.forEach((item, idx) => {
|
||||||
|
recordSet[idx.toString()] = item;
|
||||||
|
});
|
||||||
|
setValue(recordSet);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={arrayValue}
|
||||||
|
onChange={(e) => handleArrayChange(e.target.value)}
|
||||||
|
disabled={isReadonly}
|
||||||
|
placeholder={schema.placeholder || 'Comma-separated values'}
|
||||||
|
/>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'json':
|
||||||
|
// Aggregate matching keys based on pattern
|
||||||
|
const jsonData = (() => {
|
||||||
|
if (!schema.pattern) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern like "stream/video/{n}/*" - aggregate all matching keys
|
||||||
|
const patternPrefix = schema.pattern.replace(/\{n\}.*$/, '');
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Recursively find all matching keys
|
||||||
|
const findMatching = (obj: any, currentPath: string = '') => {
|
||||||
|
if (!obj || typeof obj !== 'object') return;
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||||
|
if (newPath.startsWith(patternPrefix.replace(/\/$/, ''))) {
|
||||||
|
// Store in result with relative path
|
||||||
|
const relativePath = newPath.substring(patternPrefix.length - 1);
|
||||||
|
if (relativePath) {
|
||||||
|
setNestedValue(result, relativePath, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
findMatching(val, newPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
findMatching(metadata);
|
||||||
|
return Object.keys(result).length > 0 ? result : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!jsonData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field json-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<pre className="json-viewer">
|
||||||
|
{JSON.stringify(jsonData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'cid':
|
||||||
|
// CID field type - displays CID with "Open in Editor" button
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cidValue = String(value);
|
||||||
|
const editorUrl = `/editor/file/${cidValue}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field cid-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<div className="cid-value-container">
|
||||||
|
<code
|
||||||
|
className="cid-value"
|
||||||
|
onClick={() => navigator.clipboard.writeText(cidValue)}
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
{cidValue}
|
||||||
|
</code>
|
||||||
|
<a
|
||||||
|
href={editorUrl}
|
||||||
|
className="cid-open-button"
|
||||||
|
title="Open in Editor"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
↗ Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'languageString':
|
||||||
|
// Language-keyed string object: { "eng": "English Title", "jpn": "日本語タイトル" }
|
||||||
|
const langEntries: Array<[string, string]> = value && typeof value === 'object'
|
||||||
|
? Object.entries(value).map(([k, v]) => [k, String(v)])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const handleLangCodeChange = (oldLang: string, newLang: string) => {
|
||||||
|
if (oldLang === newLang) return;
|
||||||
|
const newValue = { ...value };
|
||||||
|
const text = newValue[oldLang];
|
||||||
|
delete newValue[oldLang];
|
||||||
|
newValue[newLang] = text;
|
||||||
|
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLangTextChange = (lang: string, text: string) => {
|
||||||
|
const newValue = { ...value, [lang]: text };
|
||||||
|
setValue(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveLang = (lang: string) => {
|
||||||
|
const newValue = { ...value };
|
||||||
|
delete newValue[lang];
|
||||||
|
setValue(Object.keys(newValue).length > 0 ? newValue : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddLang = () => {
|
||||||
|
// Find a language code not already used, default to 'eng'
|
||||||
|
const usedLangs = new Set(langEntries.map(([k]) => k));
|
||||||
|
const commonDefaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa'];
|
||||||
|
const defaultLang = commonDefaults.find(l => !usedLangs.has(l)) || 'eng';
|
||||||
|
setValue({ ...value, [defaultLang]: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isReadonly && langEntries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field language-string-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
<div className="language-entries">
|
||||||
|
{langEntries.map(([lang, text]) => (
|
||||||
|
<div key={lang} className="language-entry">
|
||||||
|
{isReadonly ? (
|
||||||
|
<>
|
||||||
|
<span className="lang-code" title={formatLanguageOption(lang)}>{lang}</span>
|
||||||
|
<span className="lang-text">{text}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LanguageCombobox
|
||||||
|
value={lang}
|
||||||
|
onChange={(newLang) => handleLangCodeChange(lang, newLang)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="lang-input"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleLangTextChange(lang, e.target.value)}
|
||||||
|
placeholder={schema.placeholder || 'Text...'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lang-remove-btn"
|
||||||
|
onClick={() => handleRemoveLang(lang)}
|
||||||
|
title="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!isReadonly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lang-add-btn"
|
||||||
|
onClick={handleAddLang}
|
||||||
|
>
|
||||||
|
+ Add Language
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
// Show nothing if no value and readonly
|
||||||
|
if (isReadonly && !value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dynamic-field">
|
||||||
|
<label>{schema.label}</label>
|
||||||
|
{isReadonly ? (
|
||||||
|
<div className="readonly-value">
|
||||||
|
{renderValueWithLink(String(value ?? ''))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => setValue(e.target.value || undefined)}
|
||||||
|
placeholder={schema.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{schema.hint && <small className="field-hint">{schema.hint}</small>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to set nested value in object
|
||||||
|
function setNestedValue(obj: any, path: string, value: any) {
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
let current = obj;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (!current[parts[i]]) {
|
||||||
|
current[parts[i]] = {};
|
||||||
|
}
|
||||||
|
current = current[parts[i]];
|
||||||
|
}
|
||||||
|
if (parts.length > 0) {
|
||||||
|
current[parts[parts.length - 1]] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
.plugin-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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-section-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-section-header h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-section-header .plugin-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive grid for fields */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.plugin-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make text and json fields span full width */
|
||||||
|
.plugin-fields .dynamic-field:has(textarea),
|
||||||
|
.plugin-fields .json-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.plugin-section {
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-section-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-section-header .plugin-description {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { PluginSchema, FileMetadata, SchemaField } from '../types';
|
||||||
|
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
|
||||||
|
import './DynamicPluginSection.css';
|
||||||
|
|
||||||
|
interface DynamicPluginSectionProps {
|
||||||
|
plugin: PluginSchema;
|
||||||
|
metadata: FileMetadata;
|
||||||
|
onChange: (key: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a plugin section with all its fields based on schema
|
||||||
|
*/
|
||||||
|
export function DynamicPluginSection({
|
||||||
|
plugin,
|
||||||
|
metadata,
|
||||||
|
onChange,
|
||||||
|
}: DynamicPluginSectionProps) {
|
||||||
|
const schema = plugin.metadataSchema;
|
||||||
|
|
||||||
|
if (!schema || Object.keys(schema).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if plugin has any data in metadata
|
||||||
|
const hasData = checkPluginHasData(schema, metadata);
|
||||||
|
if (!hasData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort fields: non-readonly first, then by key
|
||||||
|
const sortedFields = Object.entries(schema).sort(([keyA, a], [keyB, b]) => {
|
||||||
|
// Non-readonly fields first
|
||||||
|
if (a.readonly !== b.readonly) {
|
||||||
|
return a.readonly ? 1 : -1;
|
||||||
|
}
|
||||||
|
// Then alphabetically
|
||||||
|
return keyA.localeCompare(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="plugin-section"
|
||||||
|
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
||||||
|
>
|
||||||
|
<div className="plugin-section-header">
|
||||||
|
<h3>{plugin.name}</h3>
|
||||||
|
{plugin.description && (
|
||||||
|
<p className="plugin-description">{plugin.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plugin-fields">
|
||||||
|
{sortedFields.map(([fieldKey, fieldSchema]) => (
|
||||||
|
<DynamicFieldRenderer
|
||||||
|
key={fieldKey}
|
||||||
|
fieldKey={fieldKey}
|
||||||
|
schema={fieldSchema}
|
||||||
|
metadata={metadata}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plugin has any data in the metadata
|
||||||
|
*/
|
||||||
|
function checkPluginHasData(
|
||||||
|
schema: Record<string, SchemaField>,
|
||||||
|
metadata: FileMetadata
|
||||||
|
): boolean {
|
||||||
|
for (const fieldKey of Object.keys(schema)) {
|
||||||
|
const fieldSchema = schema[fieldKey];
|
||||||
|
|
||||||
|
// For JSON type with pattern, check if any matching keys exist
|
||||||
|
if (fieldSchema.type === 'json' && fieldSchema.pattern) {
|
||||||
|
const patternPrefix = fieldSchema.pattern.replace(/\{n\}.*$/, '').replace(/\/$/, '');
|
||||||
|
if (hasKeyWithPrefix(metadata, patternPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular fields, check if value exists
|
||||||
|
if (getNestedValue(metadata, fieldKey) !== undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get nested value from object using path-style key
|
||||||
|
*/
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
const parts = path.split('/');
|
||||||
|
let value = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
value = value[part];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if object has any key starting with prefix
|
||||||
|
*/
|
||||||
|
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
||||||
|
if (!obj || typeof obj !== 'object') return false;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||||
|
if (newPath.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||||
|
if (hasKeyWithPrefix(obj[key], prefix, newPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
.file-list {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-count {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-toggle {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background-color: rgba(100, 108, 255, 0.1);
|
||||||
|
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||||
|
color: #646cff;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-toggle:hover {
|
||||||
|
background-color: rgba(100, 108, 255, 0.2);
|
||||||
|
border-color: rgba(100, 108, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-edit-toggle.active {
|
||||||
|
background-color: rgba(100, 108, 255, 0.3);
|
||||||
|
border-color: #646cff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-bulk-actions {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: rgba(100, 108, 255, 0.1);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-btn {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
font-size: 0.85em;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-all-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-count {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #646cff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-items {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-checkbox {
|
||||||
|
padding-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-checkbox input[type='checkbox'] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item.selected {
|
||||||
|
background-color: rgba(100, 108, 255, 0.2);
|
||||||
|
border-left: 3px solid #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-details {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-hash {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-empty {
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.file-list {
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-count {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item.selected {
|
||||||
|
background-color: rgba(100, 108, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-details {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-item-hash {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-empty {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { SearchResult } from '../types';
|
||||||
|
import { getDisplayName, truncateHash, formatBytes } from '../utils/formatting';
|
||||||
|
import './FileList.css';
|
||||||
|
|
||||||
|
interface FileListProps {
|
||||||
|
results: SearchResult[];
|
||||||
|
selectedHashId?: string;
|
||||||
|
selectedHashIds: Set<string>;
|
||||||
|
bulkEditMode: boolean;
|
||||||
|
onSelectFile: (hashId: string) => void;
|
||||||
|
onToggleSelection: (hashId: string) => void;
|
||||||
|
onSelectAll: () => void;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onBulkEditMode: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileList({
|
||||||
|
results,
|
||||||
|
selectedHashId,
|
||||||
|
selectedHashIds,
|
||||||
|
bulkEditMode,
|
||||||
|
onSelectFile,
|
||||||
|
onToggleSelection,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
onBulkEditMode,
|
||||||
|
}: FileListProps) {
|
||||||
|
if (results.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="file-list-empty">
|
||||||
|
<p>No files found. Try searching for a title, filename, or hash ID.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSelected = results.length > 0 && results.every(r => selectedHashIds.has(r.hashId));
|
||||||
|
|
||||||
|
// Sort results by file path
|
||||||
|
const sortedResults = [...results].sort((a, b) => {
|
||||||
|
const pathA = a.metadata.filePath || a.metadata.privateFilePath || '';
|
||||||
|
const pathB = b.metadata.filePath || b.metadata.privateFilePath || '';
|
||||||
|
return pathA.localeCompare(pathB);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="file-list">
|
||||||
|
<div className="file-list-header">
|
||||||
|
<span className="file-count">{results.length} files found</span>
|
||||||
|
<button
|
||||||
|
className={`bulk-edit-toggle ${bulkEditMode ? 'active' : ''}`}
|
||||||
|
onClick={() => onBulkEditMode(!bulkEditMode)}
|
||||||
|
title={bulkEditMode ? 'Exit bulk edit mode' : 'Enter bulk edit mode'}
|
||||||
|
>
|
||||||
|
{bulkEditMode ? '✓ Bulk Edit' : 'Bulk Edit'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bulkEditMode && (
|
||||||
|
<div className="file-list-bulk-actions">
|
||||||
|
<button onClick={allSelected ? onClearSelection : onSelectAll} className="select-all-btn">
|
||||||
|
{allSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</button>
|
||||||
|
{selectedHashIds.size > 0 && (
|
||||||
|
<span className="selection-count">
|
||||||
|
{selectedHashIds.size} selected
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="file-list-items">
|
||||||
|
{sortedResults.map((result) => {
|
||||||
|
const isSelected = bulkEditMode
|
||||||
|
? selectedHashIds.has(result.hashId)
|
||||||
|
: selectedHashId === result.hashId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={result.hashId}
|
||||||
|
className={`file-list-item ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (bulkEditMode) {
|
||||||
|
onToggleSelection(result.hashId);
|
||||||
|
} else {
|
||||||
|
onSelectFile(result.hashId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulkEditMode && (
|
||||||
|
<div className="file-list-item-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedHashIds.has(result.hashId)}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSelection(result.hashId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="file-list-item-content">
|
||||||
|
<div className="file-list-item-title">
|
||||||
|
{getDisplayName(result.metadata)}
|
||||||
|
</div>
|
||||||
|
<div className="file-list-item-details">
|
||||||
|
<span className="file-list-item-type">
|
||||||
|
{result.metadata.videoType || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span className="file-list-item-size">
|
||||||
|
{formatBytes(result.metadata.byteSize)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="file-list-item-hash">
|
||||||
|
Hash: {truncateHash(result.hashId)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
.kv-tree {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-refresh {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background-color: rgba(100, 108, 255, 0.2);
|
||||||
|
color: #646cff;
|
||||||
|
border: 1px solid rgba(100, 108, 255, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-refresh:hover:not(:disabled) {
|
||||||
|
background-color: rgba(100, 108, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-refresh:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-error {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: rgba(255, 0, 0, 0.1);
|
||||||
|
color: #ff6b6b;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-content {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-node {
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-node:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key.expanded {
|
||||||
|
background-color: rgba(100, 108, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-toggle {
|
||||||
|
width: 1rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key-name {
|
||||||
|
flex: 1;
|
||||||
|
color: #88c0d0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-type {
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-value {
|
||||||
|
padding: 0.5rem 1rem 0.5rem 2.5rem;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-fields {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-fields tr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-field-name {
|
||||||
|
padding: 0.25rem 0.5rem 0.25rem 0;
|
||||||
|
color: #a3be8c;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 1%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-field-value {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-field-value pre {
|
||||||
|
margin: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-load-more {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #646cff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-load-more:hover:not(:disabled) {
|
||||||
|
background-color: rgba(100, 108, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-load-more:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-empty {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.kv-tree {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-header h3 {
|
||||||
|
color: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-info {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-error {
|
||||||
|
background-color: rgba(255, 0, 0, 0.08);
|
||||||
|
color: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-node {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key.expanded {
|
||||||
|
background-color: rgba(100, 108, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-toggle {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-key-name {
|
||||||
|
color: #0077aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-type {
|
||||||
|
background-color: rgba(0, 0, 0, 0.08);
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-value {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-fields tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-field-name {
|
||||||
|
color: #228822;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-field-value {
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-load-more:hover:not(:disabled) {
|
||||||
|
background-color: rgba(100, 108, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-tree-empty {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import './KVTreeView.css';
|
||||||
|
|
||||||
|
interface KeyValue {
|
||||||
|
key: string;
|
||||||
|
type?: string;
|
||||||
|
value?: Record<string, any> | string;
|
||||||
|
expanded?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KVInfo {
|
||||||
|
prefix: string;
|
||||||
|
fileCount: number;
|
||||||
|
keyCount: number;
|
||||||
|
totalSize: number;
|
||||||
|
memoryUsage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = '/api/kv';
|
||||||
|
|
||||||
|
export function KVTreeView() {
|
||||||
|
const [keys, setKeys] = useState<KeyValue[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<string>('0');
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<KVInfo | null>(null);
|
||||||
|
|
||||||
|
// Load KV info on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/info`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setInfo(data))
|
||||||
|
.catch(err => console.error('Failed to load KV info:', err));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load initial keys
|
||||||
|
const loadKeys = useCallback(async (reset = false) => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
const currentCursor = reset ? '0' : cursor;
|
||||||
|
if (!reset && currentCursor === '0' && keys.length > 0) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/keys?cursor=${currentCursor}&count=50`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load keys');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const newKeys: KeyValue[] = data.keys.map((key: string) => ({
|
||||||
|
key,
|
||||||
|
expanded: false,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
setKeys(newKeys);
|
||||||
|
} else {
|
||||||
|
setKeys(prev => [...prev, ...newKeys]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCursor(data.cursor);
|
||||||
|
setHasMore(data.hasMore);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [cursor, keys.length, loading]);
|
||||||
|
|
||||||
|
// Load on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadKeys(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle key expansion and load value
|
||||||
|
const toggleKey = async (index: number) => {
|
||||||
|
const key = keys[index];
|
||||||
|
|
||||||
|
if (key.expanded) {
|
||||||
|
// Collapse
|
||||||
|
setKeys(prev => prev.map((k, i) =>
|
||||||
|
i === index ? { ...k, expanded: false } : k
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand and load value if not already loaded
|
||||||
|
if (key.value === undefined) {
|
||||||
|
setKeys(prev => prev.map((k, i) =>
|
||||||
|
i === index ? { ...k, loading: true } : k
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/key/${encodeURIComponent(key.key)}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load value');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
setKeys(prev => prev.map((k, i) =>
|
||||||
|
i === index ? {
|
||||||
|
...k,
|
||||||
|
type: data.type,
|
||||||
|
value: data.value,
|
||||||
|
expanded: true,
|
||||||
|
loading: false
|
||||||
|
} : k
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
setKeys(prev => prev.map((k, i) =>
|
||||||
|
i === index ? { ...k, loading: false, value: 'Error loading value' } : k
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setKeys(prev => prev.map((k, i) =>
|
||||||
|
i === index ? { ...k, expanded: true } : k
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatValue = (value: any): string => {
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (value === undefined) return 'undefined';
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kv-tree">
|
||||||
|
<div className="kv-tree-header">
|
||||||
|
<h3>KV Database Browser</h3>
|
||||||
|
{info && (
|
||||||
|
<div className="kv-tree-info">
|
||||||
|
<span>Keys: {info.keyCount}</span>
|
||||||
|
<span>Files: {formatSize(info.totalSize)}</span>
|
||||||
|
{info.memoryUsage && <span>Memory: {info.memoryUsage}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="kv-tree-refresh"
|
||||||
|
onClick={() => loadKeys(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading...' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="kv-tree-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="kv-tree-content">
|
||||||
|
{keys.map((item, index) => (
|
||||||
|
<div key={item.key} className="kv-tree-node">
|
||||||
|
<div
|
||||||
|
className={`kv-tree-key ${item.expanded ? 'expanded' : ''}`}
|
||||||
|
onClick={() => toggleKey(index)}
|
||||||
|
>
|
||||||
|
<span className="kv-tree-toggle">
|
||||||
|
{item.loading ? '...' : (item.expanded ? '▼' : '▶')}
|
||||||
|
</span>
|
||||||
|
<span className="kv-tree-key-name">{item.key}</span>
|
||||||
|
{item.type && <span className="kv-tree-type">{item.type}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.expanded && item.value && (
|
||||||
|
<div className="kv-tree-value">
|
||||||
|
{typeof item.value === 'object' ? (
|
||||||
|
<table className="kv-tree-fields">
|
||||||
|
<tbody>
|
||||||
|
{Object.entries(item.value).map(([field, val]) => (
|
||||||
|
<tr key={field}>
|
||||||
|
<td className="kv-field-name">{field}</td>
|
||||||
|
<td className="kv-field-value">
|
||||||
|
<pre>{formatValue(val)}</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<pre>{formatValue(item.value)}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
className="kv-tree-load-more"
|
||||||
|
onClick={() => loadKeys()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Loading...' : 'Load More'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && keys.length === 0 && (
|
||||||
|
<div className="kv-tree-empty">No keys found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
.language-combobox {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 2rem 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;
|
||||||
|
cursor: pointer;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #646cff;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox.disabled .language-combobox-input {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox.open .language-combobox-arrow {
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option:hover,
|
||||||
|
.language-combobox-option.highlighted {
|
||||||
|
background: rgba(100, 108, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option.selected {
|
||||||
|
background: rgba(100, 108, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option.custom {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
color: #a0d8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option-code {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
background: rgba(100, 108, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #a0d8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-empty {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.language-combobox-input {
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox.disabled .language-combobox-input {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-arrow {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-dropdown {
|
||||||
|
background: #fff;
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option:hover,
|
||||||
|
.language-combobox-option.highlighted {
|
||||||
|
background: rgba(100, 108, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-option.selected {
|
||||||
|
background: rgba(100, 108, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-option-code {
|
||||||
|
background: rgba(100, 108, 255, 0.1);
|
||||||
|
color: #4a50c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-combobox-empty {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { iso6393, Language } from 'iso-639-3';
|
||||||
|
import './LanguageCombobox.css';
|
||||||
|
|
||||||
|
// Build language lookup maps
|
||||||
|
const languagesByCode = new Map<string, Language>();
|
||||||
|
for (const lang of iso6393) {
|
||||||
|
languagesByCode.set(lang.iso6393, lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get display name for a language code
|
||||||
|
export function getLanguageName(code: string): string {
|
||||||
|
const lang = languagesByCode.get(code);
|
||||||
|
if (lang) {
|
||||||
|
return lang.name;
|
||||||
|
}
|
||||||
|
return code.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format for display: "English (eng)" or "eng" for unknown
|
||||||
|
export function formatLanguageOption(code: string): string {
|
||||||
|
const lang = languagesByCode.get(code);
|
||||||
|
if (lang) {
|
||||||
|
return `${lang.name} (${code})`;
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageComboboxProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (code: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LanguageCombobox({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
placeholder = 'Search language...',
|
||||||
|
}: LanguageComboboxProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter languages based on search
|
||||||
|
const filteredLanguages = useMemo(() => {
|
||||||
|
const searchLower = search.toLowerCase().trim();
|
||||||
|
if (!searchLower) {
|
||||||
|
// Show common languages first when no search
|
||||||
|
const common = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa', 'ita', 'por', 'rus'];
|
||||||
|
const commonLangs = common
|
||||||
|
.map(code => languagesByCode.get(code))
|
||||||
|
.filter((l): l is typeof iso6393[0] => l !== undefined);
|
||||||
|
return commonLangs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return iso6393.filter((lang: Language) => {
|
||||||
|
const nameMatch = lang.name.toLowerCase().includes(searchLower);
|
||||||
|
const codeMatch = lang.iso6393.toLowerCase().includes(searchLower);
|
||||||
|
const iso1Match = lang.iso6391 && lang.iso6391.toLowerCase().includes(searchLower);
|
||||||
|
return nameMatch || codeMatch || iso1Match;
|
||||||
|
}).slice(0, 50); // Limit results for performance
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Check if search is a valid custom 3-letter code
|
||||||
|
const isValidCustomCode = useMemo(() => {
|
||||||
|
const trimmed = search.trim().toLowerCase();
|
||||||
|
return trimmed.length === 3 && /^[a-z]{3}$/.test(trimmed) && !languagesByCode.has(trimmed);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// Reset highlight when filtered list changes
|
||||||
|
useEffect(() => {
|
||||||
|
setHighlightedIndex(0);
|
||||||
|
}, [filteredLanguages.length]);
|
||||||
|
|
||||||
|
// Scroll highlighted item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && listRef.current) {
|
||||||
|
const highlighted = listRef.current.querySelector('.highlighted');
|
||||||
|
if (highlighted) {
|
||||||
|
highlighted.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [highlightedIndex, isOpen]);
|
||||||
|
|
||||||
|
const handleSelect = (code: string) => {
|
||||||
|
onChange(code);
|
||||||
|
setSearch('');
|
||||||
|
setIsOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalItems = filteredLanguages.length + (isValidCustomCode ? 1 : 0);
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => (prev + 1) % totalItems);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => (prev - 1 + totalItems) % totalItems);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (isValidCustomCode && highlightedIndex === filteredLanguages.length) {
|
||||||
|
handleSelect(search.trim().toLowerCase());
|
||||||
|
} else if (filteredLanguages[highlightedIndex]) {
|
||||||
|
handleSelect(filteredLanguages[highlightedIndex].iso6393);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
inputRef.current?.blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = isOpen ? search : formatLanguageOption(value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`language-combobox ${isOpen ? 'open' : ''} ${disabled ? 'disabled' : ''}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="language-combobox-input"
|
||||||
|
value={displayValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
setSearch('');
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
// Delay to allow click on option
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
}, 200);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<span className="language-combobox-arrow">▼</span>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="language-combobox-dropdown" ref={listRef}>
|
||||||
|
{filteredLanguages.map((lang: Language, index: number) => (
|
||||||
|
<div
|
||||||
|
key={lang.iso6393}
|
||||||
|
className={`language-combobox-option ${index === highlightedIndex ? 'highlighted' : ''} ${lang.iso6393 === value ? 'selected' : ''}`}
|
||||||
|
onMouseDown={() => handleSelect(lang.iso6393)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
>
|
||||||
|
<span className="lang-option-name">{lang.name}</span>
|
||||||
|
<span className="lang-option-code">{lang.iso6393}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isValidCustomCode && (
|
||||||
|
<div
|
||||||
|
className={`language-combobox-option custom ${highlightedIndex === filteredLanguages.length ? 'highlighted' : ''}`}
|
||||||
|
onMouseDown={() => handleSelect(search.trim().toLowerCase())}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(filteredLanguages.length)}
|
||||||
|
>
|
||||||
|
<span className="lang-option-name">Use custom code</span>
|
||||||
|
<span className="lang-option-code">{search.trim().toLowerCase()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredLanguages.length === 0 && !isValidCustomCode && (
|
||||||
|
<div className="language-combobox-empty">
|
||||||
|
No languages found. Type a 3-letter code to add custom.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
.metadata-editor {
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions button.primary {
|
||||||
|
background-color: #646cff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions button.primary:hover:not(:disabled) {
|
||||||
|
background-color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions .download-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions .download-button:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-message {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-message.success {
|
||||||
|
background-color: rgba(0, 200, 0, 0.1);
|
||||||
|
border: 1px solid rgba(0, 200, 0, 0.3);
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-message.error {
|
||||||
|
background-color: rgba(255, 0, 0, 0.1);
|
||||||
|
border: 1px solid rgba(255, 0, 0, 0.3);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-hashes {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashes-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashes-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashes-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value-primary {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value-primary .hash-label {
|
||||||
|
color: #4ecdc4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value-primary .hash-code {
|
||||||
|
color: #4ecdc4;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-code {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
word-break: break-all;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-code:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin Chips */
|
||||||
|
.metadata-editor-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-chip {
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 2px solid var(--chip-color);
|
||||||
|
color: var(--chip-color);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-chip:hover {
|
||||||
|
background-color: var(--chip-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-chip.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin Accordion */
|
||||||
|
.plugin-accordion {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-header {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border: none;
|
||||||
|
border-left: 4px solid #646cff;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-header:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion.expanded .plugin-accordion-header {
|
||||||
|
background-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-title {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-content {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section-header {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section-header h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field input,
|
||||||
|
.metadata-field textarea,
|
||||||
|
.metadata-field select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-field {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-hash-info {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-loading,
|
||||||
|
.metadata-editor-error,
|
||||||
|
.metadata-editor-empty {
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-error button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.metadata-editor-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.metadata-editor {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section {
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-section-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-description {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-field label {
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-message.success {
|
||||||
|
background-color: rgba(0, 200, 0, 0.1);
|
||||||
|
border-color: rgba(0, 200, 0, 0.3);
|
||||||
|
color: #008800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-message.error {
|
||||||
|
background-color: rgba(255, 0, 0, 0.1);
|
||||||
|
border-color: rgba(255, 0, 0, 0.3);
|
||||||
|
color: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-loading,
|
||||||
|
.metadata-editor-error,
|
||||||
|
.metadata-editor-empty {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-hash-info {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-editor-hashes {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashes-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hashes-header h3 {
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value-primary {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-value-primary .hash-label,
|
||||||
|
.hash-value-primary .hash-code {
|
||||||
|
color: #0a9e94;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-label {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-code {
|
||||||
|
color: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hash-code:hover {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin Chips - Light theme */
|
||||||
|
.plugin-chip {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-chip:hover,
|
||||||
|
.plugin-chip.active {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Plugin Accordion - Light theme */
|
||||||
|
.plugin-accordion {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-header:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion.expanded .plugin-accordion-header {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plugin-accordion-content {
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FileMetadata, PluginSchema, PluginsResponse } from '../types';
|
||||||
|
import { MetadataAPI } from '../api/metadataApi';
|
||||||
|
import { DynamicPluginSection } from './DynamicPluginSection';
|
||||||
|
import './MetadataEditor.css';
|
||||||
|
|
||||||
|
interface PluginAccordionProps {
|
||||||
|
plugin: PluginSchema;
|
||||||
|
metadata: FileMetadata;
|
||||||
|
onChange: (field: string, value: any) => void;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginAccordion({ plugin, metadata, onChange, isExpanded, onToggle }: PluginAccordionProps) {
|
||||||
|
return (
|
||||||
|
<div className={`plugin-accordion ${isExpanded ? 'expanded' : ''}`}>
|
||||||
|
<button
|
||||||
|
className="plugin-accordion-header"
|
||||||
|
onClick={onToggle}
|
||||||
|
style={{ borderLeftColor: plugin.color || '#646cff' }}
|
||||||
|
>
|
||||||
|
<span className="plugin-accordion-title">{plugin.name}</span>
|
||||||
|
<span className="plugin-accordion-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="plugin-accordion-content">
|
||||||
|
<DynamicPluginSection
|
||||||
|
plugin={plugin}
|
||||||
|
metadata={metadata}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataEditorProps {
|
||||||
|
hashId: string;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetadataEditor({ hashId, onSaved }: MetadataEditorProps) {
|
||||||
|
const [metadata, setMetadata] = useState<FileMetadata | null>(null);
|
||||||
|
const [editedMetadata, setEditedMetadata] = useState<FileMetadata | null>(null);
|
||||||
|
const [plugins, setPlugins] = useState<PluginSchema[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [expandedPlugins, setExpandedPlugins] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Fetch plugins schema on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlugins();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch metadata when hashId changes
|
||||||
|
useEffect(() => {
|
||||||
|
loadMetadata();
|
||||||
|
}, [hashId]);
|
||||||
|
|
||||||
|
const fetchPlugins = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/plugins');
|
||||||
|
if (response.ok) {
|
||||||
|
const data: PluginsResponse = await response.json();
|
||||||
|
// Sort by priority (lower = higher priority)
|
||||||
|
const sorted = [...data.plugins].sort((a, b) =>
|
||||||
|
(a.priority ?? 100) - (b.priority ?? 100)
|
||||||
|
);
|
||||||
|
setPlugins(sorted);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch plugin schemas:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMetadata = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await MetadataAPI.getMetadata(hashId);
|
||||||
|
setMetadata(data);
|
||||||
|
setEditedMetadata(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFieldChange = (field: string, value: any) => {
|
||||||
|
if (!editedMetadata) return;
|
||||||
|
|
||||||
|
// Handle path-style keys like "stream/duration" or "nfo/title"
|
||||||
|
const parts = field.split('/');
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// Simple key
|
||||||
|
setEditedMetadata({
|
||||||
|
...editedMetadata,
|
||||||
|
[field]: value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Nested key - need to update nested structure
|
||||||
|
const newMetadata = { ...editedMetadata };
|
||||||
|
let current: any = newMetadata;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (!current[parts[i]]) {
|
||||||
|
current[parts[i]] = {};
|
||||||
|
} else {
|
||||||
|
current[parts[i]] = { ...current[parts[i]] };
|
||||||
|
}
|
||||||
|
current = current[parts[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
current[parts[parts.length - 1]] = value;
|
||||||
|
setEditedMetadata(newMetadata);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!editedMetadata) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
|
await MetadataAPI.updateMetadata(hashId, editedMetadata);
|
||||||
|
setMetadata(editedMetadata);
|
||||||
|
setSuccessMessage('Metadata saved successfully!');
|
||||||
|
|
||||||
|
if (onSaved) {
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear success message after 3 seconds
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setEditedMetadata(metadata);
|
||||||
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlugin = (pluginId: string) => {
|
||||||
|
setExpandedPlugins(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(pluginId)) {
|
||||||
|
next.delete(pluginId);
|
||||||
|
} else {
|
||||||
|
next.add(pluginId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollToPlugin = (pluginId: string) => {
|
||||||
|
// Expand the plugin if not already expanded
|
||||||
|
if (!expandedPlugins.has(pluginId)) {
|
||||||
|
setExpandedPlugins(prev => new Set(prev).add(pluginId));
|
||||||
|
}
|
||||||
|
// Scroll to the plugin after a short delay to allow expansion
|
||||||
|
setTimeout(() => {
|
||||||
|
const element = document.getElementById(`plugin-${pluginId}`);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="metadata-editor-loading">Loading metadata...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !metadata) {
|
||||||
|
return (
|
||||||
|
<div className="metadata-editor-error">
|
||||||
|
<p>Error loading metadata: {error}</p>
|
||||||
|
<button onClick={loadMetadata}>Retry</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!metadata || !editedMetadata) {
|
||||||
|
return <div className="metadata-editor-empty">No metadata available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanges = JSON.stringify(metadata) !== JSON.stringify(editedMetadata);
|
||||||
|
|
||||||
|
// Filter plugins that have data
|
||||||
|
const pluginsWithData = plugins.filter(plugin => {
|
||||||
|
if (!plugin.metadataSchema) return false;
|
||||||
|
return hasPluginData(plugin, editedMetadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get file path for download
|
||||||
|
const filePath = editedMetadata?.filePath as string | undefined;
|
||||||
|
const downloadUrl = filePath ? `/api/file/download?path=${encodeURIComponent(filePath)}` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="metadata-editor">
|
||||||
|
<div className="metadata-editor-header">
|
||||||
|
<h2>Edit Metadata</h2>
|
||||||
|
<div className="metadata-editor-actions">
|
||||||
|
{downloadUrl && (
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
className="download-button"
|
||||||
|
title={`Download: ${filePath}`}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
⬇ Download
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{hasChanges && (
|
||||||
|
<button onClick={handleReset} disabled={isSaving}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving || !hasChanges}
|
||||||
|
className="primary"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{successMessage && (
|
||||||
|
<div className="metadata-editor-message success">{successMessage}</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="metadata-editor-message error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="metadata-editor-hashes">
|
||||||
|
<div className="hashes-header">
|
||||||
|
<h3>Content Identifiers</h3>
|
||||||
|
</div>
|
||||||
|
<div className="hashes-grid">
|
||||||
|
<HashValue label="midhash256" value={hashId} primary />
|
||||||
|
{getHashValues(editedMetadata).map(({ key, label, value }) => (
|
||||||
|
<HashValue key={key} label={label} value={value} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pluginsWithData.length > 0 && (
|
||||||
|
<div className="metadata-editor-chips">
|
||||||
|
{pluginsWithData.map((plugin) => (
|
||||||
|
<button
|
||||||
|
key={plugin.id}
|
||||||
|
className={`plugin-chip ${expandedPlugins.has(plugin.id) ? 'active' : ''}`}
|
||||||
|
style={{
|
||||||
|
'--chip-color': plugin.color || '#646cff',
|
||||||
|
backgroundColor: expandedPlugins.has(plugin.id) ? plugin.color || '#646cff' : 'transparent'
|
||||||
|
} as React.CSSProperties}
|
||||||
|
onClick={() => scrollToPlugin(plugin.id)}
|
||||||
|
>
|
||||||
|
{plugin.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="metadata-editor-form">
|
||||||
|
{pluginsWithData.length === 0 ? (
|
||||||
|
<div className="metadata-editor-empty">
|
||||||
|
No metadata found for this file
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
pluginsWithData.map((plugin) => (
|
||||||
|
<div key={plugin.id} id={`plugin-${plugin.id}`}>
|
||||||
|
<PluginAccordion
|
||||||
|
plugin={plugin}
|
||||||
|
metadata={editedMetadata}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
isExpanded={expandedPlugins.has(plugin.id)}
|
||||||
|
onToggle={() => togglePlugin(plugin.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a plugin has any data in the metadata
|
||||||
|
*/
|
||||||
|
function hasPluginData(plugin: PluginSchema, metadata: FileMetadata): boolean {
|
||||||
|
const schema = plugin.metadataSchema;
|
||||||
|
if (!schema) return false;
|
||||||
|
|
||||||
|
for (const fieldKey of Object.keys(schema)) {
|
||||||
|
const fieldSchema = schema[fieldKey];
|
||||||
|
|
||||||
|
// For JSON type with pattern, check if any matching keys exist
|
||||||
|
if (fieldSchema.type === 'json' && fieldSchema.pattern) {
|
||||||
|
const patternPrefix = fieldSchema.pattern.replace(/\{n\}.*$/, '').replace(/\/$/, '');
|
||||||
|
if (hasKeyWithPrefix(metadata, patternPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For regular fields, check if value exists
|
||||||
|
if (getNestedValue(metadata, fieldKey) !== undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(obj: any, path: string): any {
|
||||||
|
const parts = path.split('/');
|
||||||
|
let value = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
value = value[part];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKeyWithPrefix(obj: any, prefix: string, currentPath: string = ''): boolean {
|
||||||
|
if (!obj || typeof obj !== 'object') return false;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const newPath = currentPath ? `${currentPath}/${key}` : key;
|
||||||
|
if (newPath.startsWith(prefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof obj[key] === 'object' && obj[key] !== null) {
|
||||||
|
if (hasKeyWithPrefix(obj[key], prefix, newPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all hash values (cid_*) from metadata
|
||||||
|
*/
|
||||||
|
function getHashValues(metadata: FileMetadata): { key: string; label: string; value: string }[] {
|
||||||
|
const hashOrder = [
|
||||||
|
{ key: 'cid_sha2-256', label: 'SHA-256' },
|
||||||
|
{ key: 'cid_sha3-256', label: 'SHA3-256' },
|
||||||
|
{ key: 'cid_sha3-384', label: 'SHA3-384' },
|
||||||
|
{ key: 'cid_sha1', label: 'SHA-1' },
|
||||||
|
{ key: 'cid_md5', label: 'MD5' },
|
||||||
|
{ key: 'cid_crc32', label: 'CRC32' },
|
||||||
|
{ key: 'cid_btih_v2', label: 'BitTorrent v2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return hashOrder
|
||||||
|
.filter(({ key }) => (metadata as any)[key])
|
||||||
|
.map(({ key, label }) => ({
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
value: (metadata as any)[key],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a single hash value with copy functionality
|
||||||
|
*/
|
||||||
|
function HashValue({ label, value, primary }: { label: string; value: string; primary?: boolean }) {
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`hash-value ${primary ? 'hash-value-primary' : ''}`}>
|
||||||
|
<span className="hash-label">{label}</span>
|
||||||
|
<code className="hash-code" onClick={handleCopy} title="Click to copy">
|
||||||
|
{value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.search-bar {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input.unified-search {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input,
|
||||||
|
.search-bar button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.search-hint {
|
||||||
|
color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import './SearchBar.css';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
onSearch: (query: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ onSearch, isLoading }: SearchBarProps) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (query.trim()) {
|
||||||
|
onSearch(query.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="search-bar" onSubmit={handleSubmit}>
|
||||||
|
<div className="search-controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by title, filename, filepath, or hash ID..."
|
||||||
|
className="search-input unified-search"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isLoading || !query.trim()}>
|
||||||
|
{isLoading ? 'Searching...' : 'Search'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="search-hint">
|
||||||
|
Search across all fields: title, filename, file path, and hash ID
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 0.5em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
color: #213547;
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.tsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// API Response Types
|
||||||
|
export interface FileMetadata {
|
||||||
|
// File info
|
||||||
|
fileName?: string;
|
||||||
|
extension?: string;
|
||||||
|
byteSize?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
fileType?: string;
|
||||||
|
|
||||||
|
// Filename parsing
|
||||||
|
title?: string;
|
||||||
|
titles?: string[];
|
||||||
|
originaltitle?: string;
|
||||||
|
showtitle?: string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
movieYear?: number;
|
||||||
|
videoType?: string;
|
||||||
|
|
||||||
|
// General media metadata
|
||||||
|
year?: number;
|
||||||
|
imdbid?: string;
|
||||||
|
tmdbid?: string;
|
||||||
|
anidbid?: string;
|
||||||
|
premiered?: string;
|
||||||
|
genre?: string[];
|
||||||
|
releasedate?: string;
|
||||||
|
plot?: string;
|
||||||
|
director?: string;
|
||||||
|
rating?: number;
|
||||||
|
runtime?: number;
|
||||||
|
studio?: string;
|
||||||
|
art?: any;
|
||||||
|
actor?: any[];
|
||||||
|
aired?: string;
|
||||||
|
customrating?: string;
|
||||||
|
dateadded?: string;
|
||||||
|
sorttitle?: string;
|
||||||
|
mpaa?: string;
|
||||||
|
aspectratio?: string;
|
||||||
|
collectionnumber?: number;
|
||||||
|
criticrating?: number;
|
||||||
|
|
||||||
|
// Hashes
|
||||||
|
cid_crc32?: string;
|
||||||
|
cid_md5?: string;
|
||||||
|
cid_sha1?: string;
|
||||||
|
'cid_sha2-256'?: string;
|
||||||
|
'cid_sha3-256'?: string;
|
||||||
|
cid_sha3_384?: string;
|
||||||
|
|
||||||
|
// Video info
|
||||||
|
video?: {
|
||||||
|
codec?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
bitrate?: number;
|
||||||
|
fps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audio info
|
||||||
|
audio?: Array<{
|
||||||
|
codec?: string;
|
||||||
|
channels?: number;
|
||||||
|
language?: string;
|
||||||
|
bitrate?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Subtitles
|
||||||
|
subtitles?: Array<{
|
||||||
|
language?: string;
|
||||||
|
codec?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// Other
|
||||||
|
extra?: any;
|
||||||
|
anime?: any;
|
||||||
|
fileinfo?: any;
|
||||||
|
trackers?: string[];
|
||||||
|
|
||||||
|
// Allow any additional properties
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
hashId: string;
|
||||||
|
metadata: FileMetadata;
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
results: SearchResult[];
|
||||||
|
count: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateResult {
|
||||||
|
status: string;
|
||||||
|
hashId: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchUpdateResponse {
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
success: number;
|
||||||
|
errors: number;
|
||||||
|
results: Array<{
|
||||||
|
hashId: string;
|
||||||
|
status: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin Schema Types
|
||||||
|
export type SchemaFieldType = 'string' | 'number' | 'boolean' | 'select' | 'text' | 'array' | 'json' | 'cid' | 'languageString';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaField {
|
||||||
|
label: string;
|
||||||
|
type?: SchemaFieldType;
|
||||||
|
readonly?: boolean;
|
||||||
|
indexed?: boolean;
|
||||||
|
options?: SelectOption[];
|
||||||
|
hint?: string;
|
||||||
|
link?: string;
|
||||||
|
pattern?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginSchema {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
priority?: number;
|
||||||
|
color?: string;
|
||||||
|
dependencies: string[];
|
||||||
|
active: boolean;
|
||||||
|
metadataSchema?: Record<string, SchemaField>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginsResponse {
|
||||||
|
plugins: PluginSchema[];
|
||||||
|
executionOrder: string[];
|
||||||
|
activeCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Format bytes to human readable format
|
||||||
|
*/
|
||||||
|
export function formatBytes(bytes: number | undefined): string {
|
||||||
|
if (!bytes) return 'N/A';
|
||||||
|
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return Math.round((bytes / Math.pow(1024, i)) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format duration in seconds to HH:MM:SS
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number | undefined): string {
|
||||||
|
if (!seconds) return 'N/A';
|
||||||
|
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate hash to first and last 6 characters
|
||||||
|
*/
|
||||||
|
export function truncateHash(hash: string | undefined): string {
|
||||||
|
if (!hash) return 'N/A';
|
||||||
|
if (hash.length <= 16) return hash;
|
||||||
|
return `${hash.slice(0, 6)}...${hash.slice(-6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for a file from metadata
|
||||||
|
*/
|
||||||
|
export function getDisplayName(metadata: any): string {
|
||||||
|
if (metadata.title) {
|
||||||
|
if (metadata.season && metadata.episode) {
|
||||||
|
return `${metadata.title} S${String(metadata.season).padStart(2, '0')}E${String(metadata.episode).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
if (metadata.movieYear) {
|
||||||
|
return `${metadata.title} (${metadata.movieYear})`;
|
||||||
|
}
|
||||||
|
return metadata.title;
|
||||||
|
}
|
||||||
|
return metadata.fileName || 'Unknown';
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/editor/',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ module github.com/metazla/meta-core
|
|||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/redis/go-redis/v9 v9.4.0
|
github.com/redis/go-redis/v9 v9.4.0
|
||||||
)
|
)
|
||||||
@@ -10,4 +12,5 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
|
golang.org/x/sys v0.4.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
github.com/redis/go-redis/v9 v9.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
||||||
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||||
|
golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18=
|
||||||
|
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KVInfoResponse is the response for GET /api/kv/info
|
||||||
|
type KVInfoResponse struct {
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
FileCount int `json:"fileCount"`
|
||||||
|
KeyCount int `json:"keyCount"`
|
||||||
|
TotalSize int64 `json:"totalSize"`
|
||||||
|
MemoryUsage string `json:"memoryUsage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KVKeysResponse is the response for GET /api/kv/keys
|
||||||
|
type KVKeysResponse struct {
|
||||||
|
Keys []string `json:"keys"`
|
||||||
|
Cursor string `json:"cursor"`
|
||||||
|
HasMore bool `json:"hasMore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// KVKeyValueResponse is the response for GET /api/kv/key/{key}
|
||||||
|
type KVKeyValueResponse struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value map[string]string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKVInfo handles GET /api/kv/info
|
||||||
|
func (s *Server) handleKVInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all hash IDs to count files
|
||||||
|
hashIDs, err := s.storage.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get memory usage
|
||||||
|
memoryUsage, err := s.storage.GetMemoryInfo()
|
||||||
|
if err != nil {
|
||||||
|
memoryUsage = "N/A"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total size from sizeByte metadata (if available)
|
||||||
|
var totalSize int64
|
||||||
|
for _, hashID := range hashIDs {
|
||||||
|
sizeStr, err := s.storage.GetProperty(hashID, "sizeByte")
|
||||||
|
if err == nil && sizeStr != "" {
|
||||||
|
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil {
|
||||||
|
totalSize += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := KVInfoResponse{
|
||||||
|
Prefix: "file:",
|
||||||
|
FileCount: len(hashIDs),
|
||||||
|
KeyCount: len(hashIDs),
|
||||||
|
TotalSize: totalSize,
|
||||||
|
MemoryUsage: memoryUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKVKeys handles GET /api/kv/keys
|
||||||
|
func (s *Server) handleKVKeys(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
cursorStr := r.URL.Query().Get("cursor")
|
||||||
|
countStr := r.URL.Query().Get("count")
|
||||||
|
|
||||||
|
cursor := 0
|
||||||
|
if cursorStr != "" {
|
||||||
|
if c, err := strconv.Atoi(cursorStr); err == nil {
|
||||||
|
cursor = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count := 50
|
||||||
|
if countStr != "" {
|
||||||
|
if c, err := strconv.Atoi(countStr); err == nil && c > 0 {
|
||||||
|
count = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all hash IDs
|
||||||
|
allHashIDs, err := s.storage.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple offset-based pagination
|
||||||
|
var keys []string
|
||||||
|
end := cursor + count
|
||||||
|
if end > len(allHashIDs) {
|
||||||
|
end = len(allHashIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cursor < len(allHashIDs) {
|
||||||
|
for _, id := range allHashIDs[cursor:end] {
|
||||||
|
keys = append(keys, "file:"+id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor := cursor + len(keys)
|
||||||
|
hasMore := nextCursor < len(allHashIDs)
|
||||||
|
|
||||||
|
response := KVKeysResponse{
|
||||||
|
Keys: keys,
|
||||||
|
Cursor: strconv.Itoa(nextCursor),
|
||||||
|
HasMore: hasMore,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasMore {
|
||||||
|
response.Cursor = "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKVGetKey handles GET /api/kv/key/{key}
|
||||||
|
func (s *Server) handleKVGetKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
key := vars["key"]
|
||||||
|
|
||||||
|
if key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "key is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL decode the key (in case it contains encoded characters)
|
||||||
|
decodedKey, err := url.PathUnescape(key)
|
||||||
|
if err != nil {
|
||||||
|
decodedKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract hash ID from key (format: file:{hashId})
|
||||||
|
if !strings.HasPrefix(decodedKey, "file:") {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid key format, expected: file:{hashId}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashID := strings.TrimPrefix(decodedKey, "file:")
|
||||||
|
|
||||||
|
// Get metadata for this hash ID
|
||||||
|
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "key not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := KVKeyValueResponse{
|
||||||
|
Key: decodedKey,
|
||||||
|
Type: "hash",
|
||||||
|
Value: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
@@ -0,0 +1,568 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashIDsResponse is the response for GET /api/metadata/hash-ids
|
||||||
|
type HashIDsResponse struct {
|
||||||
|
HashIds []string `json:"hashIds"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataListItem represents a file in the metadata list
|
||||||
|
type MetadataListItem struct {
|
||||||
|
HashID string `json:"hashId"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
FileName string `json:"fileName,omitempty"`
|
||||||
|
FilePath string `json:"filePath,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Year string `json:"year,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataListResponse is the response for GET /api/metadata/list
|
||||||
|
type MetadataListResponse struct {
|
||||||
|
Items []MetadataListItem `json:"items"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataSearchRequest is the request body for POST /api/metadata/search
|
||||||
|
type MetadataSearchRequest struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
|
HashID string `json:"hashId,omitempty"`
|
||||||
|
Property string `json:"property,omitempty"`
|
||||||
|
PropertyValue string `json:"propertyValue,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataSearchResult is a single search result
|
||||||
|
type MetadataSearchResult struct {
|
||||||
|
HashID string `json:"hashId"`
|
||||||
|
Metadata map[string]string `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataSearchResponse is the response for POST /api/metadata/search
|
||||||
|
type MetadataSearchResponse struct {
|
||||||
|
Results []MetadataSearchResult `json:"results"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataBatchUpdateRequest is the request body for POST /api/metadata/batch
|
||||||
|
type MetadataBatchUpdateRequest struct {
|
||||||
|
Updates []MetadataBatchItem `json:"updates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataBatchItem is a single item in a batch update
|
||||||
|
type MetadataBatchItem struct {
|
||||||
|
HashID string `json:"hashId"`
|
||||||
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
|
Properties map[string]string `json:"properties,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataBatchResult is a single result from a batch update
|
||||||
|
type MetadataBatchResult struct {
|
||||||
|
HashID string `json:"hashId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetadataBatchResponse is the response for POST /api/metadata/batch
|
||||||
|
type MetadataBatchResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Success int `json:"success"`
|
||||||
|
Errors int `json:"errors"`
|
||||||
|
Results []MetadataBatchResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PropertyUpdateRequest is the request body for PUT /api/metadata/{hashId}/property
|
||||||
|
type PropertyUpdateRequest struct {
|
||||||
|
Property string `json:"property"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetHashIds handles GET /api/metadata/hash-ids
|
||||||
|
func (s *Server) handleGetHashIds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hashIDs, err := s.storage.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := HashIDsResponse{
|
||||||
|
HashIds: hashIDs,
|
||||||
|
Count: len(hashIDs),
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMetadata handles GET /api/metadata/list
|
||||||
|
func (s *Server) handleListMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse pagination parameters
|
||||||
|
offsetStr := r.URL.Query().Get("offset")
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if offsetStr != "" {
|
||||||
|
if o, err := strconv.Atoi(offsetStr); err == nil {
|
||||||
|
offset = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all hash IDs
|
||||||
|
allHashIDs, err := s.storage.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(allHashIDs)
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
end := offset + limit
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []MetadataListItem
|
||||||
|
if offset < total {
|
||||||
|
for _, hashID := range allHashIDs[offset:end] {
|
||||||
|
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||||
|
if err != nil || metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := MetadataListItem{
|
||||||
|
HashID: hashID,
|
||||||
|
Title: metadata["title"],
|
||||||
|
FileName: metadata["fileName"],
|
||||||
|
FilePath: metadata["filePath"],
|
||||||
|
Type: metadata["type"],
|
||||||
|
Year: metadata["year"],
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := MetadataListResponse{
|
||||||
|
Items: items,
|
||||||
|
Count: len(items),
|
||||||
|
Total: total,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetMetadataByHashId handles GET /api/metadata/{hashId}
|
||||||
|
func (s *Server) handleGetMetadataByHashId(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
hashID := vars["hashId"]
|
||||||
|
|
||||||
|
if hashID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "hash ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "metadata not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := MetadataResponse{
|
||||||
|
HashID: hashID,
|
||||||
|
Metadata: metadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateMetadataByHashId handles PUT /api/metadata/{hashId}
|
||||||
|
func (s *Server) handleUpdateMetadataByHashId(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
hashID := vars["hashId"]
|
||||||
|
|
||||||
|
if hashID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "hash ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude internal processing status field
|
||||||
|
delete(metadata, "processingStatus")
|
||||||
|
|
||||||
|
if err := s.storage.SetMetadataFlat(hashID, metadata); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"hashId": hashID,
|
||||||
|
"message": "Metadata updated successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteMetadataByHashId handles DELETE /api/metadata/{hashId}
|
||||||
|
func (s *Server) handleDeleteMetadataByHashId(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
hashID := vars["hashId"]
|
||||||
|
|
||||||
|
if hashID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "hash ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedCount, err := s.storage.DeleteMetadata(hashID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"hashId": hashID,
|
||||||
|
"deletedKeys": deletedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetadataGetProperty handles GET /api/metadata/{hashId}/property?property=X
|
||||||
|
func (s *Server) handleMetadataGetProperty(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
hashID := vars["hashId"]
|
||||||
|
property := r.URL.Query().Get("property")
|
||||||
|
|
||||||
|
if hashID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "hash ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if property == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "property parameter is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := s.storage.GetProperty(hashID, property)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
writeError(w, http.StatusNotFound, "property not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"hashId": hashID,
|
||||||
|
"property": property,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMetadataUpdateProperty handles PUT /api/metadata/{hashId}/property
|
||||||
|
func (s *Server) handleMetadataUpdateProperty(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
hashID := vars["hashId"]
|
||||||
|
|
||||||
|
if hashID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "hash ID is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req PropertyUpdateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Property == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "property is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.storage.SetProperty(hashID, req.Property, req.Value); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"hashId": hashID,
|
||||||
|
"property": req.Property,
|
||||||
|
"value": req.Value,
|
||||||
|
"message": "Property updated successfully",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearchMetadata handles POST /api/metadata/search
|
||||||
|
func (s *Server) handleSearchMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MetadataSearchRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Limit == 0 {
|
||||||
|
req.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hashId is provided, do exact match
|
||||||
|
if req.HashID != "" {
|
||||||
|
metadata, err := s.storage.GetMetadataFlat(req.HashID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if metadata != nil {
|
||||||
|
writeJSON(w, http.StatusOK, MetadataSearchResponse{
|
||||||
|
Results: []MetadataSearchResult{{HashID: req.HashID, Metadata: metadata}},
|
||||||
|
Count: 1,
|
||||||
|
Total: 1,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, MetadataSearchResponse{
|
||||||
|
Results: []MetadataSearchResult{},
|
||||||
|
Count: 0,
|
||||||
|
Total: 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all hash IDs
|
||||||
|
allHashIDs, err := s.storage.GetAllHashIDs()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MetadataSearchResult
|
||||||
|
total := len(allHashIDs)
|
||||||
|
|
||||||
|
// Search fields for query
|
||||||
|
searchFields := []string{"title", "fileName", "originaltitle", "showtitle", "filePath"}
|
||||||
|
|
||||||
|
for _, hashID := range allHashIDs {
|
||||||
|
if len(results) >= req.Limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := s.storage.GetMetadataFlat(hashID)
|
||||||
|
if err != nil || metadata == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := false
|
||||||
|
|
||||||
|
// Property search
|
||||||
|
if req.Property != "" && req.PropertyValue != "" {
|
||||||
|
value, ok := metadata[req.Property]
|
||||||
|
if ok && strings.Contains(strings.ToLower(value), strings.ToLower(req.PropertyValue)) {
|
||||||
|
matches = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General query search
|
||||||
|
if req.Query != "" && req.Property == "" {
|
||||||
|
queryLower := strings.ToLower(req.Query)
|
||||||
|
|
||||||
|
// Check if query matches hashId
|
||||||
|
if strings.Contains(strings.ToLower(hashID), queryLower) {
|
||||||
|
matches = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check search fields
|
||||||
|
if !matches {
|
||||||
|
for _, field := range searchFields {
|
||||||
|
if value, ok := metadata[field]; ok {
|
||||||
|
if strings.Contains(strings.ToLower(value), queryLower) {
|
||||||
|
matches = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no query or property filter, return all
|
||||||
|
if req.Query == "" && req.Property == "" {
|
||||||
|
matches = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches {
|
||||||
|
results = append(results, MetadataSearchResult{
|
||||||
|
HashID: hashID,
|
||||||
|
Metadata: metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, MetadataSearchResponse{
|
||||||
|
Results: results,
|
||||||
|
Count: len(results),
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBatchUpdate handles POST /api/metadata/batch
|
||||||
|
func (s *Server) handleBatchUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req MetadataBatchUpdateRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Updates) == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "updates array is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MetadataBatchResult
|
||||||
|
successCount := 0
|
||||||
|
errorCount := 0
|
||||||
|
|
||||||
|
for _, update := range req.Updates {
|
||||||
|
if update.HashID == "" {
|
||||||
|
results = append(results, MetadataBatchResult{
|
||||||
|
HashID: "unknown",
|
||||||
|
Status: "error",
|
||||||
|
Error: "Missing hash ID",
|
||||||
|
})
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateErr error
|
||||||
|
|
||||||
|
// Update complete metadata
|
||||||
|
if update.Metadata != nil {
|
||||||
|
// Exclude internal processing status
|
||||||
|
delete(update.Metadata, "processingStatus")
|
||||||
|
updateErr = s.storage.SetMetadataFlat(update.HashID, update.Metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update individual properties
|
||||||
|
if updateErr == nil && update.Properties != nil {
|
||||||
|
for property, value := range update.Properties {
|
||||||
|
if err := s.storage.SetProperty(update.HashID, property, value); err != nil {
|
||||||
|
updateErr = err
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateErr != nil {
|
||||||
|
results = append(results, MetadataBatchResult{
|
||||||
|
HashID: update.HashID,
|
||||||
|
Status: "error",
|
||||||
|
Error: updateErr.Error(),
|
||||||
|
})
|
||||||
|
errorCount++
|
||||||
|
} else {
|
||||||
|
results = append(results, MetadataBatchResult{
|
||||||
|
HashID: update.HashID,
|
||||||
|
Status: "ok",
|
||||||
|
})
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, MetadataBatchResponse{
|
||||||
|
Status: "completed",
|
||||||
|
Total: len(results),
|
||||||
|
Success: successCount,
|
||||||
|
Errors: errorCount,
|
||||||
|
Results: results,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClearMetadata handles POST /api/metadata/clear
|
||||||
|
func (s *Server) handleClearMetadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.storage.IsConnected() {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "storage not connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedCount, err := s.storage.ClearAllMetadata()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Metadata cleared",
|
||||||
|
"deletedCount": deletedCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
+80
-6
@@ -11,17 +11,24 @@ import (
|
|||||||
"github.com/metazla/meta-core/internal/config"
|
"github.com/metazla/meta-core/internal/config"
|
||||||
"github.com/metazla/meta-core/internal/discovery"
|
"github.com/metazla/meta-core/internal/discovery"
|
||||||
"github.com/metazla/meta-core/internal/leader"
|
"github.com/metazla/meta-core/internal/leader"
|
||||||
|
"github.com/metazla/meta-core/internal/mounts"
|
||||||
"github.com/metazla/meta-core/internal/storage"
|
"github.com/metazla/meta-core/internal/storage"
|
||||||
|
"github.com/metazla/meta-core/internal/watcher"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server is the HTTP API server for meta-core
|
// Server is the HTTP API server for meta-core
|
||||||
type Server struct {
|
type Server struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
election *leader.Election
|
election *leader.Election
|
||||||
discovery *discovery.Service
|
discovery *discovery.Service
|
||||||
storage *storage.Client
|
storage *storage.Client
|
||||||
router *mux.Router
|
mountsManager *mounts.Manager
|
||||||
server *http.Server
|
mountsHandlers *mounts.Handlers
|
||||||
|
watcherDispatcher *watcher.Dispatcher
|
||||||
|
fileWatcher *watcher.Watcher
|
||||||
|
watcherHandlers *watcher.Handlers
|
||||||
|
router *mux.Router
|
||||||
|
server *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new API server
|
// NewServer creates a new API server
|
||||||
@@ -39,6 +46,27 @@ func NewServer(
|
|||||||
router: mux.NewRouter(),
|
router: mux.NewRouter(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize mounts manager
|
||||||
|
mountsManager, err := mounts.NewManager(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[API] Warning: failed to initialize mounts manager: %v", err)
|
||||||
|
} else {
|
||||||
|
s.mountsManager = mountsManager
|
||||||
|
s.mountsHandlers = mounts.NewHandlers(mountsManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize file watcher (if enabled)
|
||||||
|
if cfg.EnableFileWatcher && len(cfg.WatchFolderList) > 0 {
|
||||||
|
s.watcherDispatcher = watcher.NewDispatcher()
|
||||||
|
fileWatcher, err := watcher.NewWatcher(cfg, s.watcherDispatcher)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[API] Warning: failed to initialize file watcher: %v", err)
|
||||||
|
} else {
|
||||||
|
s.fileWatcher = fileWatcher
|
||||||
|
s.watcherHandlers = watcher.NewHandlers(fileWatcher, s.watcherDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.setupRoutes()
|
s.setupRoutes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -51,6 +79,23 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
|
s.router.HandleFunc("/leader", s.handleLeader).Methods("GET")
|
||||||
s.router.HandleFunc("/role", s.handleRole).Methods("GET")
|
s.router.HandleFunc("/role", s.handleRole).Methods("GET")
|
||||||
|
|
||||||
|
// Metadata Editor API routes (must be before /meta/{hash} routes)
|
||||||
|
s.router.HandleFunc("/api/metadata/hash-ids", s.handleGetHashIds).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/metadata/list", s.handleListMetadata).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/metadata/search", s.handleSearchMetadata).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/metadata/batch", s.handleBatchUpdate).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/metadata/clear", s.handleClearMetadata).Methods("POST")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataGetProperty).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}/property", s.handleMetadataUpdateProperty).Methods("PUT")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleGetMetadataByHashId).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
|
||||||
|
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
|
||||||
|
|
||||||
|
// KV Browser API routes
|
||||||
|
s.router.HandleFunc("/api/kv/info", s.handleKVInfo).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/kv/keys", s.handleKVKeys).Methods("GET")
|
||||||
|
s.router.HandleFunc("/api/kv/key/{key:.*}", s.handleKVGetKey).Methods("GET")
|
||||||
|
|
||||||
// Metadata operations - base endpoints
|
// Metadata operations - base endpoints
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
||||||
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
s.router.HandleFunc("/meta/{hash}", s.handlePutMeta).Methods("PUT")
|
||||||
@@ -78,6 +123,21 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.HandleFunc("/services", s.handleListServices).Methods("GET")
|
s.router.HandleFunc("/services", s.handleListServices).Methods("GET")
|
||||||
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
|
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
|
||||||
|
|
||||||
|
// Mount management routes (if manager initialized)
|
||||||
|
if s.mountsHandlers != nil {
|
||||||
|
s.mountsHandlers.RegisterRoutes(s.router)
|
||||||
|
log.Println("[API] Mount management routes registered at /api/mounts/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("[API] Metadata Editor routes registered at /api/metadata/*")
|
||||||
|
log.Println("[API] KV Browser routes registered at /api/kv/*")
|
||||||
|
|
||||||
|
// File watcher routes (if watcher initialized)
|
||||||
|
if s.watcherHandlers != nil {
|
||||||
|
s.watcherHandlers.RegisterRoutes(s.router)
|
||||||
|
log.Println("[API] File watcher routes registered at /api/events/*, /api/scan/*")
|
||||||
|
}
|
||||||
|
|
||||||
// Add middleware
|
// Add middleware
|
||||||
s.router.Use(loggingMiddleware)
|
s.router.Use(loggingMiddleware)
|
||||||
s.router.Use(corsMiddleware)
|
s.router.Use(corsMiddleware)
|
||||||
@@ -103,11 +163,25 @@ func (s *Server) Start() error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Start file watcher (if initialized)
|
||||||
|
if s.fileWatcher != nil {
|
||||||
|
if err := s.fileWatcher.Start(); err != nil {
|
||||||
|
log.Printf("[API] Warning: failed to start file watcher: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully stops the HTTP server
|
// Stop gracefully stops the HTTP server
|
||||||
func (s *Server) Stop() error {
|
func (s *Server) Stop() error {
|
||||||
|
// Stop file watcher
|
||||||
|
if s.fileWatcher != nil {
|
||||||
|
if err := s.fileWatcher.Stop(); err != nil {
|
||||||
|
log.Printf("[API] Warning: failed to stop file watcher: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if s.server == nil {
|
if s.server == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all configuration for meta-core
|
// Config holds all configuration for meta-core
|
||||||
@@ -28,11 +29,20 @@ type Config struct {
|
|||||||
HealthCheckIntervalMS int // Health check interval in ms (default: 5000)
|
HealthCheckIntervalMS int // Health check interval in ms (default: 5000)
|
||||||
HeartbeatIntervalMS int // Service heartbeat interval in ms (default: 30000)
|
HeartbeatIntervalMS int // Service heartbeat interval in ms (default: 30000)
|
||||||
StaleThresholdMS int // Stale service threshold in ms (default: 60000)
|
StaleThresholdMS int // Stale service threshold in ms (default: 60000)
|
||||||
|
|
||||||
|
// File watcher configuration
|
||||||
|
WatchFolderList []string // List of folders to watch for file changes
|
||||||
|
WatchIntervalMS int // Polling interval for network mounts (default: 1000)
|
||||||
|
DebounceMS int // File change debounce time (default: 30000)
|
||||||
|
EnableFileWatcher bool // Enable file watcher (default: true)
|
||||||
|
|
||||||
|
// Mount configuration
|
||||||
|
MountsDir string // Path to mounts configuration (default: /meta-core/mounts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load creates a Config from environment variables
|
// Load creates a Config from environment variables
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
cfg := &Config{
|
||||||
MetaCorePath: getEnv("META_CORE_PATH", "/meta-core"),
|
MetaCorePath: getEnv("META_CORE_PATH", "/meta-core"),
|
||||||
FilesPath: getEnv("FILES_PATH", "/files"),
|
FilesPath: getEnv("FILES_PATH", "/files"),
|
||||||
ServiceName: getEnv("SERVICE_NAME", "meta-core"),
|
ServiceName: getEnv("SERVICE_NAME", "meta-core"),
|
||||||
@@ -45,7 +55,19 @@ func Load() *Config {
|
|||||||
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
|
||||||
HeartbeatIntervalMS: getEnvInt("HEARTBEAT_INTERVAL_MS", 30000),
|
HeartbeatIntervalMS: getEnvInt("HEARTBEAT_INTERVAL_MS", 30000),
|
||||||
StaleThresholdMS: getEnvInt("STALE_THRESHOLD_MS", 60000),
|
StaleThresholdMS: getEnvInt("STALE_THRESHOLD_MS", 60000),
|
||||||
|
WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000),
|
||||||
|
DebounceMS: getEnvInt("DEBOUNCE_MS", 30000),
|
||||||
|
EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse watch folder list (comma-separated)
|
||||||
|
watchFolders := getEnv("WATCH_FOLDER_LIST", "/files/")
|
||||||
|
cfg.WatchFolderList = parseCommaSeparated(watchFolders)
|
||||||
|
|
||||||
|
// Set mounts directory
|
||||||
|
cfg.MountsDir = cfg.MetaCorePath + "/mounts"
|
||||||
|
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockFilePath returns the path to the leader lock file
|
// LockFilePath returns the path to the leader lock file
|
||||||
@@ -68,6 +90,16 @@ func (c *Config) ServicesDir() string {
|
|||||||
return c.MetaCorePath + "/services"
|
return c.MetaCorePath + "/services"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MountsFilePath returns the path to the mounts configuration file
|
||||||
|
func (c *Config) MountsFilePath() string {
|
||||||
|
return c.MountsDir + "/mounts.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountsErrorDir returns the path to mount error files
|
||||||
|
func (c *Config) MountsErrorDir() string {
|
||||||
|
return c.MountsDir + "/errors"
|
||||||
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
@@ -83,3 +115,26 @@ func getEnvInt(key string, defaultValue int) int {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvBool(key string, defaultValue bool) bool {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
return lower == "true" || lower == "1" || lower == "yes"
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCommaSeparated(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,7 +90,16 @@ func (s *Service) buildServiceInfo() *ServiceInfo {
|
|||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
ip := getLocalIP()
|
ip := getLocalIP()
|
||||||
|
|
||||||
apiBase := fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
|
// Use BASE_URL for external access (dashboard navigation), fall back to internal IP
|
||||||
|
var apiBase string
|
||||||
|
if s.config.BaseURL != "" {
|
||||||
|
apiBase = s.config.BaseURL
|
||||||
|
} else {
|
||||||
|
apiBase = fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal URLs always use container IP for container-to-container communication
|
||||||
|
internalBase := fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
|
||||||
metaCoreBase := fmt.Sprintf("http://%s:%d", ip, s.config.HTTPPort)
|
metaCoreBase := fmt.Sprintf("http://%s:%d", ip, s.config.HTTPPort)
|
||||||
|
|
||||||
return &ServiceInfo{
|
return &ServiceInfo{
|
||||||
@@ -104,15 +113,16 @@ func (s *Service) buildServiceInfo() *ServiceInfo {
|
|||||||
LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
|
LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
|
||||||
Capabilities: []string{"meta-core"},
|
Capabilities: []string{"meta-core"},
|
||||||
Endpoints: map[string]string{
|
Endpoints: map[string]string{
|
||||||
// meta-core sidecar endpoints (port 9000)
|
// meta-core sidecar endpoints (port 9000) - always internal
|
||||||
"health": metaCoreBase + "/health",
|
"health": metaCoreBase + "/health",
|
||||||
"meta": metaCoreBase + "/meta",
|
"meta": metaCoreBase + "/meta",
|
||||||
"leader": metaCoreBase + "/leader",
|
"leader": metaCoreBase + "/leader",
|
||||||
"services": metaCoreBase + "/services",
|
"services": metaCoreBase + "/services",
|
||||||
// main service endpoints (port 80)
|
// main service endpoints - external for dashboard
|
||||||
"api": apiBase + "/api",
|
"api": apiBase + "/api",
|
||||||
"webdav": apiBase + "/webdav",
|
"webdav": apiBase + "/webdav",
|
||||||
"callback": apiBase + "/api/plugins/callback",
|
// callback uses internal IP for container-to-container plugin communication
|
||||||
|
"callback": internalBase + "/api/plugins/callback",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package mounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers provides HTTP handlers for mount operations
|
||||||
|
type Handlers struct {
|
||||||
|
manager *Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers creates new mount handlers
|
||||||
|
func NewHandlers(manager *Manager) *Handlers {
|
||||||
|
return &Handlers{manager: manager}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all mount-related routes
|
||||||
|
func (h *Handlers) RegisterRoutes(r *mux.Router) {
|
||||||
|
// Mount management
|
||||||
|
r.HandleFunc("/api/mounts", h.handleListMounts).Methods("GET")
|
||||||
|
r.HandleFunc("/api/mounts", h.handleCreateMount).Methods("POST")
|
||||||
|
r.HandleFunc("/api/mounts/rclone/remotes", h.handleListRcloneRemotes).Methods("GET")
|
||||||
|
r.HandleFunc("/api/mounts/{id}", h.handleGetMount).Methods("GET")
|
||||||
|
r.HandleFunc("/api/mounts/{id}", h.handleDeleteMount).Methods("DELETE")
|
||||||
|
r.HandleFunc("/api/mounts/{id}/mount", h.handleRequestMount).Methods("POST")
|
||||||
|
r.HandleFunc("/api/mounts/{id}/unmount", h.handleRequestUnmount).Methods("POST")
|
||||||
|
r.HandleFunc("/api/mounts/{id}/safe-unmount", h.handleSafeUnmount).Methods("POST")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMounts handles GET /api/mounts
|
||||||
|
func (h *Handlers) handleListMounts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mounts, err := h.manager.ListMounts()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, MountsListResponse{Mounts: mounts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetMount handles GET /api/mounts/{id}
|
||||||
|
func (h *Handlers) handleGetMount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
mount, err := h.manager.GetMount(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mount == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mount not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, MountResponse{Mount: mount})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleCreateMount handles POST /api/mounts
|
||||||
|
func (h *Handlers) handleCreateMount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req CreateMountRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mount, err := h.manager.CreateMount(&req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, MountResponse{Mount: mount})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteMount handles DELETE /api/mounts/{id}
|
||||||
|
func (h *Handlers) handleDeleteMount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
if err := h.manager.DeleteMount(id); err != nil {
|
||||||
|
if err.Error() == "mount not found" {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, StatusResponse{Status: "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestMount handles POST /api/mounts/{id}/mount
|
||||||
|
func (h *Handlers) handleRequestMount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
if err := h.manager.RequestMount(id); err != nil {
|
||||||
|
if err.Error() == "mount not found" {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, StatusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Message: "Mount requested",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRequestUnmount handles POST /api/mounts/{id}/unmount
|
||||||
|
func (h *Handlers) handleRequestUnmount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
if err := h.manager.RequestUnmount(id); err != nil {
|
||||||
|
if err.Error() == "mount not found" {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, StatusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Message: "Unmount requested",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSafeUnmount handles POST /api/mounts/{id}/safe-unmount
|
||||||
|
func (h *Handlers) handleSafeUnmount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
|
||||||
|
// Get timeout from query param (default 60000ms)
|
||||||
|
timeoutStr := r.URL.Query().Get("timeout")
|
||||||
|
timeoutMS := 60000
|
||||||
|
if timeoutStr != "" {
|
||||||
|
if parsed, err := parseIntOrDefault(timeoutStr, 60000); err == nil {
|
||||||
|
timeoutMS = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mount info
|
||||||
|
mount, err := h.manager.GetMount(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mount == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "mount not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request unmount
|
||||||
|
if err := h.manager.RequestUnmount(id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for unmount
|
||||||
|
success := h.manager.WaitForUnmount(mount.MountPath, timeoutMS)
|
||||||
|
|
||||||
|
if !success {
|
||||||
|
writeJSON(w, http.StatusOK, StatusResponse{
|
||||||
|
Status: "warning",
|
||||||
|
Message: "Unmount requested but mount may still be attached",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, StatusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
Message: "Safe unmount completed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRcloneRemotes handles GET /api/mounts/rclone/remotes
|
||||||
|
func (h *Handlers) handleListRcloneRemotes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
remotes, err := h.manager.ListRcloneRemotes()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, RcloneRemotesResponse{Remotes: remotes})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{
|
||||||
|
"error": http.StatusText(status),
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIntOrDefault(s string, defaultVal int) (int, error) {
|
||||||
|
var result int
|
||||||
|
if s == "" {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
err := json.Unmarshal([]byte(s), &result)
|
||||||
|
if err != nil {
|
||||||
|
return defaultVal, nil
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
package mounts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/metazla/meta-core/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager handles mount configuration and status
|
||||||
|
type Manager struct {
|
||||||
|
config *config.Config
|
||||||
|
mu sync.RWMutex
|
||||||
|
filesPath string
|
||||||
|
mountsDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new mount manager
|
||||||
|
func NewManager(cfg *config.Config) (*Manager, error) {
|
||||||
|
m := &Manager{
|
||||||
|
config: cfg,
|
||||||
|
filesPath: cfg.FilesPath,
|
||||||
|
mountsDir: cfg.MountsDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
if err := m.ensureDirs(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create mount directories: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDirs creates required directories
|
||||||
|
func (m *Manager) ensureDirs() error {
|
||||||
|
dirs := []string{
|
||||||
|
m.mountsDir,
|
||||||
|
m.config.MountsErrorDir(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConfig reads the mounts configuration file
|
||||||
|
func (m *Manager) readConfig() (*MountsFile, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := os.ReadFile(m.config.MountsFilePath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &MountsFile{Version: 1, Mounts: []MountConfig{}}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mountsFile MountsFile
|
||||||
|
if err := json.Unmarshal(data, &mountsFile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &mountsFile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfig writes the mounts configuration file
|
||||||
|
func (m *Manager) writeConfig(mountsFile *MountsFile) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if err := m.ensureDirs(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(mountsFile, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(m.config.MountsFilePath(), data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMounted checks if a path is currently mounted
|
||||||
|
func (m *Manager) IsMounted(mountPath string) bool {
|
||||||
|
cmd := exec.Command("findmnt", "-n", mountPath)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)) != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadError reads the error file for a mount
|
||||||
|
func (m *Manager) ReadError(id string) (string, error) {
|
||||||
|
errorFile := filepath.Join(m.config.MountsErrorDir(), id+".error")
|
||||||
|
data, err := os.ReadFile(errorFile)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||||
|
if len(lines) > 1 {
|
||||||
|
// Skip timestamp line, return rest
|
||||||
|
return strings.Join(lines[1:], "\n"), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeName sanitizes a mount name for use as a directory name
|
||||||
|
func SanitizeName(name string) string {
|
||||||
|
// Convert to lowercase
|
||||||
|
result := strings.ToLower(name)
|
||||||
|
|
||||||
|
// Replace non-alphanumeric chars with hyphens
|
||||||
|
re := regexp.MustCompile(`[^a-z0-9-_]`)
|
||||||
|
result = re.ReplaceAllString(result, "-")
|
||||||
|
|
||||||
|
// Collapse multiple hyphens
|
||||||
|
re = regexp.MustCompile(`-+`)
|
||||||
|
result = re.ReplaceAllString(result, "-")
|
||||||
|
|
||||||
|
// Remove leading/trailing hyphens
|
||||||
|
result = strings.Trim(result, "-")
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if len(result) > 64 {
|
||||||
|
result = result[:64]
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObscurePassword obscures a password using rclone
|
||||||
|
func ObscurePassword(password string) (string, error) {
|
||||||
|
// Escape special characters for shell
|
||||||
|
escapedPass := strings.ReplaceAll(password, `"`, `\"`)
|
||||||
|
|
||||||
|
cmd := exec.Command("rclone", "obscure", escapedPass)
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to obscure password: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMounts returns all mounts with their current status
|
||||||
|
func (m *Manager) ListMounts() ([]MountStatus, error) {
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses := make([]MountStatus, len(mountsFile.Mounts))
|
||||||
|
for i, mount := range mountsFile.Mounts {
|
||||||
|
mounted := m.IsMounted(mount.MountPath)
|
||||||
|
errMsg, _ := m.ReadError(mount.ID)
|
||||||
|
|
||||||
|
statuses[i] = MountStatus{
|
||||||
|
MountConfig: mount,
|
||||||
|
Mounted: mounted,
|
||||||
|
Error: errMsg,
|
||||||
|
LastChecked: NowMS(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMount returns a single mount by ID
|
||||||
|
func (m *Manager) GetMount(id string) (*MountStatus, error) {
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mount := range mountsFile.Mounts {
|
||||||
|
if mount.ID == id {
|
||||||
|
mounted := m.IsMounted(mount.MountPath)
|
||||||
|
errMsg, _ := m.ReadError(mount.ID)
|
||||||
|
|
||||||
|
return &MountStatus{
|
||||||
|
MountConfig: mount,
|
||||||
|
Mounted: mounted,
|
||||||
|
Error: errMsg,
|
||||||
|
LastChecked: NowMS(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMount creates a new mount configuration
|
||||||
|
func (m *Manager) CreateMount(req *CreateMountRequest) (*MountStatus, error) {
|
||||||
|
if req.Name == "" {
|
||||||
|
return nil, fmt.Errorf("mount name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Type != MountTypeNFS && req.Type != MountTypeSMB && req.Type != MountTypeRclone {
|
||||||
|
return nil, fmt.Errorf("valid mount type (nfs, smb, rclone) is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate type-specific fields
|
||||||
|
switch req.Type {
|
||||||
|
case MountTypeNFS:
|
||||||
|
if req.NFSServer == "" || req.NFSPath == "" {
|
||||||
|
return nil, fmt.Errorf("NFS server and path are required")
|
||||||
|
}
|
||||||
|
case MountTypeSMB:
|
||||||
|
if req.SMBServer == "" || req.SMBShare == "" {
|
||||||
|
return nil, fmt.Errorf("SMB server and share are required")
|
||||||
|
}
|
||||||
|
case MountTypeRclone:
|
||||||
|
if req.RcloneRemote == "" {
|
||||||
|
return nil, fmt.Errorf("rclone remote is required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ID and mount path
|
||||||
|
id := uuid.New().String()
|
||||||
|
safeName := SanitizeName(req.Name)
|
||||||
|
mountPath := filepath.Join(m.filesPath, safeName)
|
||||||
|
|
||||||
|
// Check if path already exists
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mount := range mountsFile.Mounts {
|
||||||
|
if mount.MountPath == mountPath {
|
||||||
|
return nil, fmt.Errorf("mount path %s already configured", mountPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default enabled to true if not specified
|
||||||
|
enabled := true
|
||||||
|
if req.Enabled != nil {
|
||||||
|
enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
mount := MountConfig{
|
||||||
|
ID: id,
|
||||||
|
Name: req.Name,
|
||||||
|
Type: req.Type,
|
||||||
|
Enabled: enabled,
|
||||||
|
DesiredMounted: enabled, // Auto-mount if enabled
|
||||||
|
MountPath: mountPath,
|
||||||
|
Options: req.Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type-specific fields
|
||||||
|
switch req.Type {
|
||||||
|
case MountTypeNFS:
|
||||||
|
mount.NFSServer = req.NFSServer
|
||||||
|
mount.NFSPath = req.NFSPath
|
||||||
|
case MountTypeSMB:
|
||||||
|
mount.SMBServer = req.SMBServer
|
||||||
|
mount.SMBShare = req.SMBShare
|
||||||
|
mount.SMBUsername = req.SMBUsername
|
||||||
|
mount.SMBDomain = req.SMBDomain
|
||||||
|
if req.SMBPassword != "" {
|
||||||
|
obscured, err := ObscurePassword(req.SMBPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to secure password: %w", err)
|
||||||
|
}
|
||||||
|
mount.SMBPasswordObscured = obscured
|
||||||
|
}
|
||||||
|
case MountTypeRclone:
|
||||||
|
mount.RcloneRemote = req.RcloneRemote
|
||||||
|
mount.RclonePath = req.RclonePath
|
||||||
|
}
|
||||||
|
|
||||||
|
mountsFile.Mounts = append(mountsFile.Mounts, mount)
|
||||||
|
if err := m.writeConfig(mountsFile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Mounts] Created mount config: %s (%s) -> %s", mount.Name, mount.Type, mount.MountPath)
|
||||||
|
|
||||||
|
return &MountStatus{
|
||||||
|
MountConfig: mount,
|
||||||
|
Mounted: false,
|
||||||
|
LastChecked: NowMS(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestMount sets desiredMounted to true
|
||||||
|
func (m *Manager) RequestMount(id string) error {
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, mount := range mountsFile.Mounts {
|
||||||
|
if mount.ID == id {
|
||||||
|
mountsFile.Mounts[i].DesiredMounted = true
|
||||||
|
if err := m.writeConfig(mountsFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[Mounts] Mount requested: %s", mount.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("mount not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestUnmount sets desiredMounted to false
|
||||||
|
func (m *Manager) RequestUnmount(id string) error {
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, mount := range mountsFile.Mounts {
|
||||||
|
if mount.ID == id {
|
||||||
|
mountsFile.Mounts[i].DesiredMounted = false
|
||||||
|
if err := m.writeConfig(mountsFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[Mounts] Unmount requested: %s", mount.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("mount not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForUnmount waits for a mount to be unmounted
|
||||||
|
func (m *Manager) WaitForUnmount(mountPath string, timeoutMS int) bool {
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutMS) * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if !m.IsMounted(mountPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMount removes a mount configuration
|
||||||
|
func (m *Manager) DeleteMount(id string) error {
|
||||||
|
mountsFile, err := m.readConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var mount *MountConfig
|
||||||
|
index := -1
|
||||||
|
for i, mnt := range mountsFile.Mounts {
|
||||||
|
if mnt.ID == id {
|
||||||
|
mount = &mnt
|
||||||
|
index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if index == -1 {
|
||||||
|
return fmt.Errorf("mount not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request unmount first
|
||||||
|
mountsFile.Mounts[index].DesiredMounted = false
|
||||||
|
if err := m.writeConfig(mountsFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for unmount (15 seconds max)
|
||||||
|
m.WaitForUnmount(mount.MountPath, 15000)
|
||||||
|
|
||||||
|
// Remove from config
|
||||||
|
mountsFile.Mounts = append(mountsFile.Mounts[:index], mountsFile.Mounts[index+1:]...)
|
||||||
|
if err := m.writeConfig(mountsFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up error file
|
||||||
|
errorFile := filepath.Join(m.config.MountsErrorDir(), id+".error")
|
||||||
|
os.Remove(errorFile)
|
||||||
|
|
||||||
|
// Try to remove mount directory (will fail if not empty, which is fine)
|
||||||
|
os.Remove(mount.MountPath)
|
||||||
|
|
||||||
|
log.Printf("[Mounts] Deleted mount: %s", mount.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRcloneRemotes lists available rclone remotes
|
||||||
|
func (m *Manager) ListRcloneRemotes() ([]RcloneRemote, error) {
|
||||||
|
// Call rclone RC API
|
||||||
|
cmd := exec.Command("curl", "-s", "-X", "POST",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-u", "admin:admin",
|
||||||
|
"http://127.0.0.1:5572/config/listremotes")
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Mounts] Failed to list rclone remotes: %v", err)
|
||||||
|
return []RcloneRemote{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var response struct {
|
||||||
|
Remotes []string `json:"remotes"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(output, &response); err != nil {
|
||||||
|
return []RcloneRemote{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
remotes := make([]RcloneRemote, 0, len(response.Remotes))
|
||||||
|
for _, name := range response.Remotes {
|
||||||
|
// Strip trailing colon if present
|
||||||
|
cleanName := strings.TrimSuffix(name, ":")
|
||||||
|
|
||||||
|
// Get type for this remote
|
||||||
|
remoteType := m.getRcloneRemoteType(cleanName)
|
||||||
|
|
||||||
|
remotes = append(remotes, RcloneRemote{
|
||||||
|
Name: cleanName,
|
||||||
|
Type: remoteType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return remotes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRcloneRemoteType gets the type of an rclone remote
|
||||||
|
func (m *Manager) getRcloneRemoteType(name string) string {
|
||||||
|
// Build request body
|
||||||
|
body := fmt.Sprintf(`{"name":"%s"}`, name)
|
||||||
|
|
||||||
|
cmd := exec.Command("curl", "-s", "-X", "POST",
|
||||||
|
"-H", "Content-Type: application/json",
|
||||||
|
"-u", "admin:admin",
|
||||||
|
"-d", body,
|
||||||
|
"http://127.0.0.1:5572/config/get")
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
if err := json.Unmarshal(output, &response); err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
if typeStr, ok := response["type"].(string); ok {
|
||||||
|
return typeStr
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic auth helper for rclone API
|
||||||
|
func basicAuth(username, password string) string {
|
||||||
|
auth := username + ":" + password
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(auth))
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package mounts
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// MountType represents the type of mount
|
||||||
|
type MountType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MountTypeNFS MountType = "nfs"
|
||||||
|
MountTypeSMB MountType = "smb"
|
||||||
|
MountTypeRclone MountType = "rclone"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MountConfig represents a mount configuration
|
||||||
|
type MountConfig struct {
|
||||||
|
// Common fields
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type MountType `json:"type"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
DesiredMounted bool `json:"desiredMounted"`
|
||||||
|
MountPath string `json:"mountPath"`
|
||||||
|
Options string `json:"options,omitempty"`
|
||||||
|
|
||||||
|
// NFS-specific fields
|
||||||
|
NFSServer string `json:"nfsServer,omitempty"`
|
||||||
|
NFSPath string `json:"nfsPath,omitempty"`
|
||||||
|
|
||||||
|
// SMB-specific fields
|
||||||
|
SMBServer string `json:"smbServer,omitempty"`
|
||||||
|
SMBShare string `json:"smbShare,omitempty"`
|
||||||
|
SMBUsername string `json:"smbUsername,omitempty"`
|
||||||
|
SMBPasswordObscured string `json:"smbPasswordObscured,omitempty"`
|
||||||
|
SMBDomain string `json:"smbDomain,omitempty"`
|
||||||
|
|
||||||
|
// rclone-specific fields
|
||||||
|
RcloneRemote string `json:"rcloneRemote,omitempty"`
|
||||||
|
RclonePath string `json:"rclonePath,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountStatus represents the runtime status of a mount
|
||||||
|
type MountStatus struct {
|
||||||
|
MountConfig
|
||||||
|
Mounted bool `json:"mounted"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
LastChecked int64 `json:"lastChecked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountsFile represents the mounts.json file structure
|
||||||
|
type MountsFile struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Mounts []MountConfig `json:"mounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneRemote represents an rclone remote
|
||||||
|
type RcloneRemote struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMountRequest is the request body for creating a mount
|
||||||
|
type CreateMountRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type MountType `json:"type"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"` // Pointer to detect if set
|
||||||
|
Options string `json:"options,omitempty"`
|
||||||
|
|
||||||
|
// NFS
|
||||||
|
NFSServer string `json:"nfsServer,omitempty"`
|
||||||
|
NFSPath string `json:"nfsPath,omitempty"`
|
||||||
|
|
||||||
|
// SMB
|
||||||
|
SMBServer string `json:"smbServer,omitempty"`
|
||||||
|
SMBShare string `json:"smbShare,omitempty"`
|
||||||
|
SMBUsername string `json:"smbUsername,omitempty"`
|
||||||
|
SMBPassword string `json:"smbPassword,omitempty"` // Plain text, will be obscured
|
||||||
|
SMBDomain string `json:"smbDomain,omitempty"`
|
||||||
|
|
||||||
|
// rclone
|
||||||
|
RcloneRemote string `json:"rcloneRemote,omitempty"`
|
||||||
|
RclonePath string `json:"rclonePath,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountResponse is the response for mount operations
|
||||||
|
type MountResponse struct {
|
||||||
|
Mount *MountStatus `json:"mount,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MountsListResponse is the response for listing mounts
|
||||||
|
type MountsListResponse struct {
|
||||||
|
Mounts []MountStatus `json:"mounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RcloneRemotesResponse is the response for listing rclone remotes
|
||||||
|
type RcloneRemotesResponse struct {
|
||||||
|
Remotes []RcloneRemote `json:"remotes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusResponse is a generic status response
|
||||||
|
type StatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
GateStatus interface{} `json:"gateStatus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp helpers
|
||||||
|
func NowMS() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
@@ -490,3 +490,83 @@ func (c *Client) RemoveFromSet(hashID, property, value string) (bool, error) {
|
|||||||
newValue := strings.Join(newValues, "|")
|
newValue := strings.Join(newValues, "|")
|
||||||
return true, c.client.HSet(ctx, hashKey, property, newValue).Err()
|
return true, c.client.HSet(ctx, hashKey, property, newValue).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMemoryInfo returns Redis memory usage information
|
||||||
|
func (c *Client) GetMemoryInfo() (string, error) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return "", fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
info, err := c.client.Info(ctx, "memory").Result()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("info memory failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract used_memory_human from info string
|
||||||
|
for _, line := range strings.Split(info, "\r\n") {
|
||||||
|
if strings.HasPrefix(line, "used_memory_human:") {
|
||||||
|
return strings.TrimPrefix(line, "used_memory_human:"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "N/A", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAllMetadata deletes all file metadata and index
|
||||||
|
// Returns the number of files deleted
|
||||||
|
func (c *Client) ClearAllMetadata() (int64, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.client == nil {
|
||||||
|
return 0, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Get all hash IDs from index
|
||||||
|
indexKey := c.buildIndexKey()
|
||||||
|
hashIDs, err := c.client.SMembers(ctx, indexKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("smembers failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var deletedCount int64
|
||||||
|
|
||||||
|
// Delete each file's hash
|
||||||
|
for _, hashID := range hashIDs {
|
||||||
|
hashKey := c.buildHashKey(hashID)
|
||||||
|
if err := c.client.Del(ctx, hashKey).Err(); err != nil {
|
||||||
|
log.Printf("[Storage] Warning: failed to delete %s: %v", hashKey, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deletedCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the index set
|
||||||
|
if err := c.client.Del(ctx, indexKey).Err(); err != nil {
|
||||||
|
log.Printf("[Storage] Warning: failed to delete index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Storage] Cleared %d file metadata entries", deletedCount)
|
||||||
|
return deletedCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRedisClient returns the underlying redis client for advanced operations
|
||||||
|
func (c *Client) GetRedisClient() *redis.Client {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.client
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefix returns the key prefix used by this client
|
||||||
|
func (c *Client) GetPrefix() string {
|
||||||
|
return c.prefix
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Debouncer delays events to ensure file stability
|
||||||
|
type Debouncer struct {
|
||||||
|
delay time.Duration
|
||||||
|
pending map[string]*PendingEvent
|
||||||
|
mu sync.Mutex
|
||||||
|
callback func(FileEvent)
|
||||||
|
stopped bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDebouncer creates a new debouncer with the specified delay
|
||||||
|
func NewDebouncer(delay time.Duration) *Debouncer {
|
||||||
|
return &Debouncer{
|
||||||
|
delay: delay,
|
||||||
|
pending: make(map[string]*PendingEvent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCallback sets the callback function for debounced events
|
||||||
|
func (d *Debouncer) SetCallback(cb func(FileEvent)) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.callback = cb
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds or updates an event in the debounce queue
|
||||||
|
func (d *Debouncer) Add(event FileEvent) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if d.stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := event.Path
|
||||||
|
|
||||||
|
if existing, ok := d.pending[key]; ok {
|
||||||
|
// Update existing pending event
|
||||||
|
existing.LastSeen = time.Now()
|
||||||
|
existing.Event = event // Update with latest event info
|
||||||
|
|
||||||
|
// Reset timer
|
||||||
|
existing.Timer.Reset(d.delay)
|
||||||
|
} else {
|
||||||
|
// Create new pending event
|
||||||
|
now := time.Now()
|
||||||
|
pending := &PendingEvent{
|
||||||
|
Event: event,
|
||||||
|
FirstSeen: now,
|
||||||
|
LastSeen: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create timer
|
||||||
|
pending.Timer = time.AfterFunc(d.delay, func() {
|
||||||
|
d.flush(key)
|
||||||
|
})
|
||||||
|
|
||||||
|
d.pending[key] = pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush emits the event and removes it from pending
|
||||||
|
func (d *Debouncer) flush(key string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
|
||||||
|
pending, ok := d.pending[key]
|
||||||
|
if !ok {
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough time has passed since last activity
|
||||||
|
elapsed := time.Since(pending.LastSeen)
|
||||||
|
if elapsed < d.delay {
|
||||||
|
// Reset timer for remaining time
|
||||||
|
remaining := d.delay - elapsed
|
||||||
|
pending.Timer.Reset(remaining)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pending
|
||||||
|
delete(d.pending, key)
|
||||||
|
callback := d.callback
|
||||||
|
event := pending.Event
|
||||||
|
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
// Call callback outside of lock
|
||||||
|
if callback != nil {
|
||||||
|
callback(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the debouncer and cancels all pending events
|
||||||
|
func (d *Debouncer) Stop() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.stopped = true
|
||||||
|
|
||||||
|
// Cancel all timers
|
||||||
|
for _, pending := range d.pending {
|
||||||
|
pending.Timer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear pending
|
||||||
|
d.pending = make(map[string]*PendingEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingCount returns the number of pending events
|
||||||
|
func (d *Debouncer) PendingCount() int {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
return len(d.pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushAll immediately flushes all pending events
|
||||||
|
func (d *Debouncer) FlushAll() {
|
||||||
|
d.mu.Lock()
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(d.pending))
|
||||||
|
for key := range d.pending {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
d.flush(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// MaxRetries is the maximum number of delivery retries
|
||||||
|
MaxRetries = 3
|
||||||
|
// RetryDelay is the delay between retries
|
||||||
|
RetryDelay = 5 * time.Second
|
||||||
|
// WebhookTimeout is the timeout for webhook requests
|
||||||
|
WebhookTimeout = 10 * time.Second
|
||||||
|
// MaxFailCount is the max failures before removing subscriber
|
||||||
|
MaxFailCount = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatcher sends file events to subscribers
|
||||||
|
type Dispatcher struct {
|
||||||
|
subscribers map[string]*Subscriber
|
||||||
|
sseClients map[chan FileEvent]bool
|
||||||
|
mu sync.RWMutex
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDispatcher creates a new event dispatcher
|
||||||
|
func NewDispatcher() *Dispatcher {
|
||||||
|
return &Dispatcher{
|
||||||
|
subscribers: make(map[string]*Subscriber),
|
||||||
|
sseClients: make(map[chan FileEvent]bool),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: WebhookTimeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe registers a webhook subscriber
|
||||||
|
func (d *Dispatcher) Subscribe(url string, eventTypes []string) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.subscribers[url] = &Subscriber{
|
||||||
|
URL: url,
|
||||||
|
RegisteredAt: NowMS(),
|
||||||
|
EventTypes: eventTypes,
|
||||||
|
FailCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Dispatcher] Subscribed webhook: %s", url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsubscribe removes a webhook subscriber
|
||||||
|
func (d *Dispatcher) Unsubscribe(url string) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
delete(d.subscribers, url)
|
||||||
|
log.Printf("[Dispatcher] Unsubscribed webhook: %s", url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubscribers returns all subscribers
|
||||||
|
func (d *Dispatcher) ListSubscribers() []Subscriber {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]Subscriber, 0, len(d.subscribers))
|
||||||
|
for _, sub := range d.subscribers {
|
||||||
|
result = append(result, *sub)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSSEClient adds an SSE client channel
|
||||||
|
func (d *Dispatcher) AddSSEClient(ch chan FileEvent) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
d.sseClients[ch] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveSSEClient removes an SSE client channel
|
||||||
|
func (d *Dispatcher) RemoveSSEClient(ch chan FileEvent) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
delete(d.sseClients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch sends an event to all subscribers
|
||||||
|
func (d *Dispatcher) Dispatch(event FileEvent) {
|
||||||
|
// Dispatch to webhooks
|
||||||
|
go d.dispatchToWebhooks(event)
|
||||||
|
|
||||||
|
// Dispatch to SSE clients
|
||||||
|
d.dispatchToSSE(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchToWebhooks sends event to webhook subscribers
|
||||||
|
func (d *Dispatcher) dispatchToWebhooks(event FileEvent) {
|
||||||
|
d.mu.RLock()
|
||||||
|
subscribers := make([]*Subscriber, 0, len(d.subscribers))
|
||||||
|
for _, sub := range d.subscribers {
|
||||||
|
subscribers = append(subscribers, sub)
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, sub := range subscribers {
|
||||||
|
// Check event type filter
|
||||||
|
if len(sub.EventTypes) > 0 {
|
||||||
|
found := false
|
||||||
|
for _, et := range sub.EventTypes {
|
||||||
|
if et == string(event.Type) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go d.deliverToWebhook(sub.URL, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverToWebhook delivers an event to a webhook with retries
|
||||||
|
func (d *Dispatcher) deliverToWebhook(url string, event FileEvent) {
|
||||||
|
body, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Dispatcher] Failed to marshal event: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < MaxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(RetryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Event-Type", string(event.Type))
|
||||||
|
|
||||||
|
resp, err := d.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
// Success
|
||||||
|
d.mu.Lock()
|
||||||
|
if sub, ok := d.subscribers[url]; ok {
|
||||||
|
sub.LastDelivery = NowMS()
|
||||||
|
sub.FailCount = 0
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = &httpError{StatusCode: resp.StatusCode}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed
|
||||||
|
log.Printf("[Dispatcher] Failed to deliver to %s after %d retries: %v", url, MaxRetries, lastErr)
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
if sub, ok := d.subscribers[url]; ok {
|
||||||
|
sub.FailCount++
|
||||||
|
if sub.FailCount >= MaxFailCount {
|
||||||
|
delete(d.subscribers, url)
|
||||||
|
log.Printf("[Dispatcher] Removed webhook %s after %d failures", url, MaxFailCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchToSSE sends event to SSE clients
|
||||||
|
func (d *Dispatcher) dispatchToSSE(event FileEvent) {
|
||||||
|
d.mu.RLock()
|
||||||
|
clients := make([]chan FileEvent, 0, len(d.sseClients))
|
||||||
|
for ch := range d.sseClients {
|
||||||
|
clients = append(clients, ch)
|
||||||
|
}
|
||||||
|
d.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, ch := range clients {
|
||||||
|
select {
|
||||||
|
case ch <- event:
|
||||||
|
default:
|
||||||
|
// Channel full, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSEClientCount returns the number of connected SSE clients
|
||||||
|
func (d *Dispatcher) SSEClientCount() int {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
return len(d.sseClients)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpError represents an HTTP error
|
||||||
|
type httpError struct {
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *httpError) Error() string {
|
||||||
|
return http.StatusText(e.StatusCode)
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handlers provides HTTP handlers for watcher operations
|
||||||
|
type Handlers struct {
|
||||||
|
watcher *Watcher
|
||||||
|
dispatcher *Dispatcher
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandlers creates new watcher handlers
|
||||||
|
func NewHandlers(watcher *Watcher, dispatcher *Dispatcher) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
watcher: watcher,
|
||||||
|
dispatcher: dispatcher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers all watcher-related routes
|
||||||
|
func (h *Handlers) RegisterRoutes(r *mux.Router) {
|
||||||
|
// Event subscriptions
|
||||||
|
r.HandleFunc("/api/events/subscribe", h.handleSSESubscribe).Methods("GET")
|
||||||
|
r.HandleFunc("/api/events/poll", h.handlePoll).Methods("GET")
|
||||||
|
r.HandleFunc("/api/events/subscribers", h.handleListSubscribers).Methods("GET")
|
||||||
|
r.HandleFunc("/api/events/subscribers", h.handleAddSubscriber).Methods("POST")
|
||||||
|
r.HandleFunc("/api/events/subscribers/{url}", h.handleRemoveSubscriber).Methods("DELETE")
|
||||||
|
|
||||||
|
// Scan management
|
||||||
|
r.HandleFunc("/api/scan/trigger", h.handleTriggerScan).Methods("POST")
|
||||||
|
r.HandleFunc("/api/scan/status", h.handleScanStatus).Methods("GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSSESubscribe handles GET /api/events/subscribe (Server-Sent Events)
|
||||||
|
func (h *Handlers) handleSSESubscribe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Set SSE headers
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
|
||||||
|
// Create event channel
|
||||||
|
eventChan := make(chan FileEvent, 100)
|
||||||
|
h.dispatcher.AddSSEClient(eventChan)
|
||||||
|
|
||||||
|
// Cleanup on disconnect
|
||||||
|
defer h.dispatcher.RemoveSSEClient(eventChan)
|
||||||
|
|
||||||
|
// Get flusher
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "SSE not supported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send initial connected message
|
||||||
|
fmt.Fprintf(w, "event: connected\ndata: {\"status\":\"connected\"}\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
// Stream events
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case event := <-eventChan:
|
||||||
|
data, err := json.Marshal(event)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "event: file\ndata: %s\n\n", string(data))
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePoll handles GET /api/events/poll
|
||||||
|
func (h *Handlers) handlePoll(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get since parameter
|
||||||
|
sinceStr := r.URL.Query().Get("since")
|
||||||
|
sinceMS := int64(0)
|
||||||
|
if sinceStr != "" {
|
||||||
|
if parsed, err := strconv.ParseInt(sinceStr, 10, 64); err == nil {
|
||||||
|
sinceMS = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get limit parameter
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 100
|
||||||
|
if limitStr != "" {
|
||||||
|
if parsed, err := strconv.Atoi(limitStr); err == nil {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
events := h.watcher.GetRecentEvents(sinceMS, limit)
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, EventsListResponse{
|
||||||
|
Events: events,
|
||||||
|
Count: len(events),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSubscribers handles GET /api/events/subscribers
|
||||||
|
func (h *Handlers) handleListSubscribers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
subscribers := h.dispatcher.ListSubscribers()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, SubscribersListResponse{
|
||||||
|
Subscribers: subscribers,
|
||||||
|
Count: len(subscribers),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAddSubscriber handles POST /api/events/subscribers
|
||||||
|
func (h *Handlers) handleAddSubscriber(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SubscribeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.URL == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.dispatcher.Subscribe(req.URL, req.EventTypes); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Subscribed",
|
||||||
|
"url": req.URL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRemoveSubscriber handles DELETE /api/events/subscribers/{url}
|
||||||
|
func (h *Handlers) handleRemoveSubscriber(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
url := vars["url"]
|
||||||
|
|
||||||
|
if url == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "URL is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.dispatcher.Unsubscribe(url); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Unsubscribed",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTriggerScan handles POST /api/scan/trigger
|
||||||
|
func (h *Handlers) handleTriggerScan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
go h.watcher.RunScan()
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Scan triggered",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleScanStatus handles GET /api/scan/status
|
||||||
|
func (h *Handlers) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := h.watcher.GetStatus()
|
||||||
|
writeJSON(w, http.StatusOK, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{
|
||||||
|
"error": http.StatusText(status),
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// FileEventType represents the type of file event
|
||||||
|
type FileEventType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EventTypeAdd FileEventType = "add"
|
||||||
|
EventTypeChange FileEventType = "change"
|
||||||
|
EventTypeDelete FileEventType = "delete"
|
||||||
|
EventTypeRename FileEventType = "rename"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileEvent represents a file system event
|
||||||
|
type FileEvent struct {
|
||||||
|
Type FileEventType `json:"type"`
|
||||||
|
Path string `json:"path"` // Relative to FILES_PATH
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
PartialHash string `json:"partialHash,omitempty"` // Hash of first 64KB
|
||||||
|
OldPath string `json:"oldPath,omitempty"` // For rename events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriber represents a webhook subscriber
|
||||||
|
type Subscriber struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
RegisteredAt int64 `json:"registeredAt"`
|
||||||
|
EventTypes []string `json:"eventTypes,omitempty"` // Empty means all
|
||||||
|
LastDelivery int64 `json:"lastDelivery,omitempty"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PendingEvent tracks a file event that's being debounced
|
||||||
|
type PendingEvent struct {
|
||||||
|
Event FileEvent
|
||||||
|
FirstSeen time.Time
|
||||||
|
LastSeen time.Time
|
||||||
|
Timer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribeRequest is the request to register a webhook
|
||||||
|
type SubscribeRequest struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
EventTypes []string `json:"eventTypes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventsListResponse is the response for listing events
|
||||||
|
type EventsListResponse struct {
|
||||||
|
Events []FileEvent `json:"events"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscribersListResponse is the response for listing subscribers
|
||||||
|
type SubscribersListResponse struct {
|
||||||
|
Subscribers []Subscriber `json:"subscribers"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanStatusResponse is the response for scan status
|
||||||
|
type ScanStatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Scanning bool `json:"scanning"`
|
||||||
|
LastScan int64 `json:"lastScan,omitempty"`
|
||||||
|
FileCount int `json:"fileCount,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NowMS returns current time in milliseconds
|
||||||
|
func NowMS() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/metazla/meta-core/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PartialHashSize is the number of bytes to hash for file identification
|
||||||
|
PartialHashSize = 64 * 1024 // 64KB
|
||||||
|
)
|
||||||
|
|
||||||
|
// Watcher monitors directories for file changes
|
||||||
|
type Watcher struct {
|
||||||
|
config *config.Config
|
||||||
|
fsWatcher *fsnotify.Watcher
|
||||||
|
debouncer *Debouncer
|
||||||
|
dispatcher *Dispatcher
|
||||||
|
filesPath string
|
||||||
|
watchPaths []string
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
isRunning bool
|
||||||
|
isScanning bool
|
||||||
|
lastScan int64
|
||||||
|
fileCount int
|
||||||
|
stopChan chan struct{}
|
||||||
|
eventBuffer []FileEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWatcher creates a new file watcher
|
||||||
|
func NewWatcher(cfg *config.Config, dispatcher *Dispatcher) (*Watcher, error) {
|
||||||
|
fsWatcher, err := fsnotify.NewWatcher()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
debouncer := NewDebouncer(time.Duration(cfg.DebounceMS) * time.Millisecond)
|
||||||
|
|
||||||
|
w := &Watcher{
|
||||||
|
config: cfg,
|
||||||
|
fsWatcher: fsWatcher,
|
||||||
|
debouncer: debouncer,
|
||||||
|
dispatcher: dispatcher,
|
||||||
|
filesPath: cfg.FilesPath,
|
||||||
|
watchPaths: cfg.WatchFolderList,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
eventBuffer: make([]FileEvent, 0, 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set debouncer callback
|
||||||
|
debouncer.SetCallback(func(event FileEvent) {
|
||||||
|
w.handleDebouncedEvent(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins watching for file changes
|
||||||
|
func (w *Watcher) Start() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.isRunning {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.isRunning = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
// Add watch paths
|
||||||
|
for _, watchPath := range w.watchPaths {
|
||||||
|
if err := w.addWatchRecursive(watchPath); err != nil {
|
||||||
|
log.Printf("[Watcher] Warning: failed to watch %s: %v", watchPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start event processing goroutine
|
||||||
|
go w.processEvents()
|
||||||
|
|
||||||
|
// Start initial scan
|
||||||
|
go w.RunScan()
|
||||||
|
|
||||||
|
log.Printf("[Watcher] Started watching %d paths", len(w.watchPaths))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops the watcher
|
||||||
|
func (w *Watcher) Stop() error {
|
||||||
|
w.mu.Lock()
|
||||||
|
if !w.isRunning {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.isRunning = false
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
close(w.stopChan)
|
||||||
|
w.debouncer.Stop()
|
||||||
|
return w.fsWatcher.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// addWatchRecursive adds a directory and all subdirectories to the watch list
|
||||||
|
func (w *Watcher) addWatchRecursive(root string) error {
|
||||||
|
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip inaccessible paths
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if err := w.fsWatcher.Add(path); err != nil {
|
||||||
|
log.Printf("[Watcher] Warning: cannot watch %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// processEvents handles fsnotify events
|
||||||
|
func (w *Watcher) processEvents() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-w.stopChan:
|
||||||
|
return
|
||||||
|
|
||||||
|
case event, ok := <-w.fsWatcher.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.handleFsEvent(event)
|
||||||
|
|
||||||
|
case err, ok := <-w.fsWatcher.Errors:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[Watcher] Error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFsEvent converts an fsnotify event to a FileEvent
|
||||||
|
func (w *Watcher) handleFsEvent(event fsnotify.Event) {
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(w.filesPath, event.Name)
|
||||||
|
if err != nil {
|
||||||
|
relPath = event.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up path
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
// Determine event type
|
||||||
|
var eventType FileEventType
|
||||||
|
switch {
|
||||||
|
case event.Op&fsnotify.Create == fsnotify.Create:
|
||||||
|
eventType = EventTypeAdd
|
||||||
|
// If it's a new directory, add it to watch
|
||||||
|
if info, err := os.Stat(event.Name); err == nil && info.IsDir() {
|
||||||
|
w.fsWatcher.Add(event.Name)
|
||||||
|
}
|
||||||
|
case event.Op&fsnotify.Write == fsnotify.Write:
|
||||||
|
eventType = EventTypeChange
|
||||||
|
case event.Op&fsnotify.Remove == fsnotify.Remove:
|
||||||
|
eventType = EventTypeDelete
|
||||||
|
case event.Op&fsnotify.Rename == fsnotify.Rename:
|
||||||
|
eventType = EventTypeRename
|
||||||
|
default:
|
||||||
|
return // Ignore other events
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories for file events (except remove)
|
||||||
|
if eventType != EventTypeDelete {
|
||||||
|
info, err := os.Stat(event.Name)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file info if available
|
||||||
|
var size int64
|
||||||
|
if eventType != EventTypeDelete {
|
||||||
|
if info, err := os.Stat(event.Name); err == nil {
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileEvent := FileEvent{
|
||||||
|
Type: eventType,
|
||||||
|
Path: relPath,
|
||||||
|
Size: size,
|
||||||
|
Timestamp: NowMS(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to debouncer
|
||||||
|
w.debouncer.Add(fileEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDebouncedEvent processes a debounced event
|
||||||
|
func (w *Watcher) handleDebouncedEvent(event FileEvent) {
|
||||||
|
// Compute partial hash for add/change events
|
||||||
|
if event.Type == EventTypeAdd || event.Type == EventTypeChange {
|
||||||
|
fullPath := filepath.Join(w.filesPath, event.Path)
|
||||||
|
if hash, err := computePartialHash(fullPath); err == nil {
|
||||||
|
event.PartialHash = hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to buffer
|
||||||
|
w.mu.Lock()
|
||||||
|
w.eventBuffer = append(w.eventBuffer, event)
|
||||||
|
// Keep buffer size reasonable
|
||||||
|
if len(w.eventBuffer) > 10000 {
|
||||||
|
w.eventBuffer = w.eventBuffer[len(w.eventBuffer)-5000:]
|
||||||
|
}
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
// Dispatch to subscribers
|
||||||
|
w.dispatcher.Dispatch(event)
|
||||||
|
|
||||||
|
log.Printf("[Watcher] Event: %s %s", event.Type, event.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScan performs a full directory scan
|
||||||
|
func (w *Watcher) RunScan() {
|
||||||
|
w.mu.Lock()
|
||||||
|
if w.isScanning {
|
||||||
|
w.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.isScanning = true
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.isScanning = false
|
||||||
|
w.lastScan = NowMS()
|
||||||
|
w.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("[Watcher] Starting directory scan...")
|
||||||
|
|
||||||
|
fileCount := 0
|
||||||
|
for _, watchPath := range w.watchPaths {
|
||||||
|
count := w.scanDirectory(watchPath)
|
||||||
|
fileCount += count
|
||||||
|
}
|
||||||
|
|
||||||
|
w.mu.Lock()
|
||||||
|
w.fileCount = fileCount
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[Watcher] Scan complete: %d files found", fileCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanDirectory scans a directory and emits add events for files
|
||||||
|
func (w *Watcher) scanDirectory(root string) int {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files
|
||||||
|
if strings.HasPrefix(filepath.Base(path), ".") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(w.filesPath, path)
|
||||||
|
if err != nil {
|
||||||
|
relPath = path
|
||||||
|
}
|
||||||
|
relPath = filepath.ToSlash(relPath)
|
||||||
|
|
||||||
|
// Emit add event (through debouncer)
|
||||||
|
event := FileEvent{
|
||||||
|
Type: EventTypeAdd,
|
||||||
|
Path: relPath,
|
||||||
|
Size: info.Size(),
|
||||||
|
Timestamp: NowMS(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute partial hash
|
||||||
|
if hash, err := computePartialHash(path); err == nil {
|
||||||
|
event.PartialHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch directly (skip debouncer for scan)
|
||||||
|
w.dispatcher.Dispatch(event)
|
||||||
|
count++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentEvents returns events since a given timestamp
|
||||||
|
func (w *Watcher) GetRecentEvents(sinceMS int64, limit int) []FileEvent {
|
||||||
|
w.mu.RLock()
|
||||||
|
defer w.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]FileEvent, 0)
|
||||||
|
for _, event := range w.eventBuffer {
|
||||||
|
if event.Timestamp > sinceMS {
|
||||||
|
result = append(result, event)
|
||||||
|
if limit > 0 && len(result) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns watcher status
|
||||||
|
func (w *Watcher) GetStatus() ScanStatusResponse {
|
||||||
|
w.mu.RLock()
|
||||||
|
defer w.mu.RUnlock()
|
||||||
|
|
||||||
|
status := "running"
|
||||||
|
if !w.isRunning {
|
||||||
|
status = "stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScanStatusResponse{
|
||||||
|
Status: status,
|
||||||
|
Scanning: w.isScanning,
|
||||||
|
LastScan: w.lastScan,
|
||||||
|
FileCount: w.fileCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// computePartialHash computes SHA-256 hash of first 64KB of a file
|
||||||
|
func computePartialHash(path string) (string, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
buffer := make([]byte, PartialHashSize)
|
||||||
|
n, err := file.Read(buffer)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.Write(buffer[:n])
|
||||||
|
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user