improve editor and add type detection

This commit is contained in:
Pierre
2026-05-14 23:31:07 +02:00
parent 3d65e7e951
commit aa45f4f32e
37 changed files with 2309 additions and 4220 deletions
-224
View File
@@ -1,224 +0,0 @@
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
padding: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background-color: rgba(0, 0, 0, 0.2);
}
.app-header h1 {
margin: 0 0 0.5rem 0;
color: #646cff;
}
.app-description {
margin: 0;
color: rgba(255, 255, 255, 0.6);
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
.app-error {
padding: 1rem;
background-color: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
border-radius: 8px;
color: #ff6b6b;
margin-bottom: 1rem;
}
.app-loading {
text-align: center;
padding: 3rem 2rem;
color: rgba(255, 255, 255, 0.7);
font-size: 1.1rem;
}
.app-welcome {
text-align: center;
padding: 4rem 2rem;
color: rgba(255, 255, 255, 0.6);
}
.app-welcome h2 {
margin: 0 0 1rem 0;
color: rgba(255, 255, 255, 0.8);
}
.app-welcome-features {
margin-top: 1rem;
margin-bottom: 1.5rem;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
}
.app-kv-browser {
margin-top: 1.5rem;
text-align: left;
max-width: 900px;
margin-left: auto;
margin-right: auto;
}
.app-content {
display: grid;
grid-template-columns: 350px 1fr;
gap: 1.5rem;
margin-top: 1rem;
}
.app-sidebar {
min-height: 400px;
}
.app-editor {
min-height: 400px;
}
.app-no-selection {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 400px;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
color: rgba(255, 255, 255, 0.5);
text-align: center;
padding: 2rem;
}
.app-footer {
padding: 1.5rem 2rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
color: rgba(255, 255, 255, 0.5);
font-size: 0.9rem;
}
.app-footer a {
color: #646cff;
text-decoration: none;
}
.app-footer a:hover {
text-decoration: underline;
}
@media (max-width: 1024px) {
.app-content {
grid-template-columns: 1fr;
}
.app-sidebar {
order: 2;
}
.app-editor {
order: 1;
}
}
@media (max-width: 768px) {
.app-header,
.app-main,
.app-footer {
padding: 1rem;
}
.app-content {
gap: 1rem;
}
}
@media (prefers-color-scheme: light) {
.app-header {
border-bottom-color: rgba(0, 0, 0, 0.1);
background-color: rgba(0, 0, 0, 0.02);
}
.app-description {
color: rgba(0, 0, 0, 0.6);
}
.app-error {
background-color: rgba(255, 0, 0, 0.1);
border-color: rgba(255, 0, 0, 0.3);
color: #cc0000;
}
.app-loading {
color: rgba(0, 0, 0, 0.7);
}
.app-welcome {
color: rgba(0, 0, 0, 0.6);
}
.app-welcome h2 {
color: rgba(0, 0, 0, 0.8);
}
.app-welcome-features {
color: rgba(0, 0, 0, 0.5);
}
.app-no-selection {
background-color: rgba(0, 0, 0, 0.02);
color: rgba(0, 0, 0, 0.5);
}
.app-footer {
border-top-color: rgba(0, 0, 0, 0.1);
color: rgba(0, 0, 0, 0.5);
}
}
.app-nav {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
}
.app-nav button {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.2);
color: inherit;
padding: 0.4rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.95rem;
}
.app-nav button:hover {
background: rgba(255, 255, 255, 0.08);
}
.app-nav button.active {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.4);
font-weight: 600;
}
@media (prefers-color-scheme: light) {
.app-nav button {
border-color: rgba(0, 0, 0, 0.2);
}
.app-nav button:hover {
background: rgba(0, 0, 0, 0.05);
}
.app-nav button.active {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(0, 0, 0, 0.4);
}
}
-270
View File
@@ -1,270 +0,0 @@
import { useState, useEffect } from 'react';
import { SearchBar } from './components/SearchBar';
import { FileList } from './components/FileList';
import { MetadataEditor } from './components/MetadataEditor';
import { BulkMetadataEditor } from './components/BulkMetadataEditor';
import { KVTreeView } from './components/KVTreeView';
import { SnapshotPanel } from './components/SnapshotPanel';
import { MetadataAPI } from './api/metadataApi';
import { SearchResult } from './types';
import './App.css';
type View = 'edit' | 'snapshot';
function App() {
const [view, setView] = useState<View>('edit');
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>
<nav className="app-nav">
<button
className={view === 'edit' ? 'active' : ''}
onClick={() => setView('edit')}
>
Edit
</button>
<button
className={view === 'snapshot' ? 'active' : ''}
onClick={() => setView('snapshot')}
>
Snapshot
</button>
</nav>
</header>
{view === 'snapshot' ? (
<main className="app-main">
<SnapshotPanel />
</main>
) : (
<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;
-151
View File
@@ -1,151 +0,0 @@
import {
FileMetadata,
SearchResponse,
UpdateResult,
BatchUpdateResponse,
} from '../types';
const API_BASE = '/api/metadata';
export class MetadataAPI {
/**
* Search for files by query, hashId, or property
*/
static async search(params: {
query?: string;
hashId?: string;
property?: string;
propertyValue?: string;
limit?: number;
}): Promise<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();
}
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
export type SchemaPrimitive =
| 'string'
| 'int'
| 'float'
| 'bool'
| 'json-object'
| 'json-array'
| 'undefined'
| 'mixed';
export type SchemaHint = 'cid' | 'timestamp';
export type SchemaKeyHint = 'language-code';
export interface SchemaFieldInfo {
type: SchemaPrimitive;
hint?: SchemaHint;
key_hint?: SchemaKeyHint;
breakdown: Record<string, number>;
}
export interface SchemaResponse {
fields: Record<string, SchemaFieldInfo>;
generated_at: string;
source: 'live' | 'rescan';
}
let cached: Promise<SchemaResponse> | null = null;
export async function fetchSchema(): Promise<SchemaResponse> {
if (!cached) {
cached = fetch('/api/schema').then((r) => {
if (!r.ok) {
cached = null;
throw new Error(`schema fetch failed: ${r.status}`);
}
return r.json();
});
}
return cached;
}
/**
* Returns the live schema entry for a field, or undefined if not yet observed.
* Loads /api/schema lazily on first mount and caches the result process-wide.
*/
export function useSchema(): {
schema: SchemaResponse | null;
getFieldHint: (key: string) => SchemaFieldInfo | undefined;
} {
const [schema, setSchema] = useState<SchemaResponse | null>(null);
useEffect(() => {
let alive = true;
fetchSchema()
.then((s) => {
if (alive) setSchema(s);
})
.catch(() => {
// Schema endpoint missing or errored — editor falls back to plugin schemas.
});
return () => {
alive = false;
};
}, []);
const getFieldHint = (key: string): SchemaFieldInfo | undefined => {
if (!schema) return undefined;
return schema.fields[key];
};
return { schema, getFieldHint };
}
@@ -1,382 +0,0 @@
.bulk-metadata-editor {
background-color: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 1.5rem;
}
.bulk-metadata-editor-header {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bulk-metadata-editor-header h2 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
}
.bulk-info {
margin: 0 0 1rem 0;
color: rgba(255, 255, 255, 0.6);
font-size: 0.95rem;
}
.bulk-metadata-editor-actions {
display: flex;
gap: 0.5rem;
}
.bulk-metadata-editor-actions button.primary {
background-color: #646cff;
color: white;
}
.bulk-metadata-editor-actions button.primary:hover:not(:disabled) {
background-color: #535bf2;
}
.bulk-metadata-editor-message {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.bulk-metadata-editor-message.success {
background-color: rgba(0, 200, 0, 0.1);
border: 1px solid rgba(0, 200, 0, 0.3);
color: #00ff00;
}
.bulk-metadata-editor-message.error {
background-color: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
color: #ff6b6b;
}
.bulk-metadata-editor-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.bulk-metadata-section {
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 4px solid #646cff;
border-radius: 8px;
padding: 1.5rem;
background-color: rgba(0, 0, 0, 0.2);
}
.bulk-metadata-section-header {
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.bulk-metadata-section-header h3 {
margin: 0 0 0.25rem 0;
font-size: 1.1rem;
color: #646cff;
}
.section-hint {
margin: 0.25rem 0 0 0;
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.section-hint-bulk {
margin: 0.5rem 0 0 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
font-weight: 500;
}
/* Plugin-based bulk fields */
.bulk-fields-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.bulk-field-wrapper {
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 4px;
padding: 0.75rem;
background-color: rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.bulk-field-wrapper.has-value {
border-color: rgba(100, 108, 255, 0.3);
background-color: rgba(100, 108, 255, 0.05);
}
.bulk-field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.field-key {
font-size: 0.85rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.6);
text-transform: lowercase;
font-family: 'Courier New', monospace;
}
.bulk-field-wrapper.has-value .field-key {
color: #646cff;
}
.bulk-field-input {
/* Plugin component will render inside this */
}
/* Legacy bulk fields (for compatibility) */
.bulk-metadata-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.bulk-metadata-field:last-child {
margin-bottom: 0;
}
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.field-header label {
font-size: 0.9rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.clear-field-btn {
width: 20px;
height: 20px;
padding: 0;
border: 1px solid rgba(255, 0, 0, 0.3);
background-color: rgba(255, 0, 0, 0.1);
color: #ff6b6b;
border-radius: 50%;
cursor: pointer;
font-size: 16px;
line-height: 1;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.clear-field-btn:hover {
background-color: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.5);
}
.bulk-metadata-field input,
.bulk-metadata-field textarea,
.bulk-metadata-field select {
width: 100%;
}
.bulk-metadata-field-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.bulk-metadata-field-row:last-child {
margin-bottom: 0;
}
/* Modified fields summary */
.modified-fields {
background-color: rgba(100, 108, 255, 0.05);
border-color: rgba(100, 108, 255, 0.3);
}
.modified-fields h3 {
color: #646cff;
}
.modified-fields-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.modified-field-item {
display: flex;
gap: 0.75rem;
align-items: center;
padding: 0.75rem;
background-color: rgba(100, 108, 255, 0.05);
border: 1px solid rgba(100, 108, 255, 0.2);
border-radius: 4px;
}
.field-name {
font-weight: 600;
color: #646cff;
min-width: 150px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.field-value {
flex: 1;
word-break: break-word;
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
}
.remove-field-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
background-color: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
color: #ff6b6b;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.remove-field-btn:hover {
background-color: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.5);
}
@media (max-width: 768px) {
.bulk-metadata-editor-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.bulk-metadata-editor-actions {
width: 100%;
flex-direction: column;
}
.bulk-metadata-editor-actions button {
width: 100%;
}
.bulk-metadata-field-row {
grid-template-columns: 1fr;
}
.modified-field-item {
flex-direction: column;
align-items: flex-start;
}
.field-name {
min-width: auto;
}
.remove-field-btn {
align-self: flex-end;
}
}
@media (prefers-color-scheme: light) {
.bulk-metadata-editor {
background-color: rgba(0, 0, 0, 0.02);
}
.bulk-metadata-editor-header {
border-bottom-color: rgba(0, 0, 0, 0.1);
}
.bulk-info {
color: rgba(0, 0, 0, 0.6);
}
.bulk-metadata-section {
border-color: rgba(0, 0, 0, 0.1);
background-color: rgba(0, 0, 0, 0.02);
}
.bulk-metadata-section-header {
border-bottom-color: rgba(0, 0, 0, 0.1);
}
.section-hint {
color: rgba(0, 0, 0, 0.5);
}
.section-hint-bulk {
color: rgba(0, 0, 0, 0.4);
}
.bulk-field-wrapper {
border-color: rgba(0, 0, 0, 0.1);
background-color: rgba(0, 0, 0, 0.02);
}
.bulk-field-wrapper.has-value {
border-color: rgba(100, 108, 255, 0.3);
background-color: rgba(100, 108, 255, 0.05);
}
.field-key {
color: rgba(0, 0, 0, 0.6);
}
.bulk-field-wrapper.has-value .field-key {
color: #646cff;
}
.field-header label {
color: rgba(0, 0, 0, 0.7);
}
.field-value {
color: rgba(0, 0, 0, 0.8);
}
.bulk-metadata-editor-message.success {
background-color: rgba(0, 200, 0, 0.1);
border-color: rgba(0, 200, 0, 0.3);
color: #008800;
}
.bulk-metadata-editor-message.error {
background-color: rgba(255, 0, 0, 0.1);
border-color: rgba(255, 0, 0, 0.3);
color: #cc0000;
}
.modified-fields {
background-color: rgba(100, 108, 255, 0.05);
border-color: rgba(100, 108, 255, 0.3);
}
.modified-field-item {
background-color: rgba(100, 108, 255, 0.05);
border-color: rgba(100, 108, 255, 0.2);
}
.clear-field-btn {
border-color: rgba(255, 0, 0, 0.3);
background-color: rgba(255, 0, 0, 0.1);
color: #cc0000;
}
.clear-field-btn:hover {
background-color: rgba(255, 0, 0, 0.2);
border-color: rgba(255, 0, 0, 0.5);
}
}
@@ -1,280 +0,0 @@
import { useState, useEffect } from 'react';
import { FileMetadata, PluginSchema } from '../types';
import { MetadataAPI } from '../api/metadataApi';
import { DynamicFieldRenderer } from './DynamicFieldRenderer';
import './BulkMetadataEditor.css';
interface PluginsResponse {
plugins: PluginSchema[];
}
interface BulkMetadataEditorProps {
hashIds: string[];
onSaved?: () => void;
onCancel?: () => void;
}
export function BulkMetadataEditor({ hashIds, onSaved, onCancel }: BulkMetadataEditorProps) {
const [plugins, setPlugins] = useState<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>
);
}
@@ -1,297 +0,0 @@
.dynamic-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.dynamic-field:last-child {
margin-bottom: 0;
}
.dynamic-field label {
font-size: 0.9rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.7);
}
.dynamic-field input[type="text"],
.dynamic-field input[type="number"],
.dynamic-field select,
.dynamic-field textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 0.9rem;
background: rgba(0, 0, 0, 0.3);
color: #fff;
transition: border-color 0.2s;
}
.dynamic-field input:focus,
.dynamic-field select:focus,
.dynamic-field textarea:focus {
outline: none;
border-color: #646cff;
}
.dynamic-field input:disabled,
.dynamic-field select:disabled,
.dynamic-field textarea:disabled {
background: rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.5);
cursor: not-allowed;
}
.dynamic-field textarea {
resize: vertical;
min-height: 80px;
font-family: inherit;
}
.field-hint {
display: block;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.4);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: normal;
color: rgba(255, 255, 255, 0.7);
}
.checkbox-label input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
cursor: pointer;
accent-color: #646cff;
}
.checkbox-label input:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.readonly-value {
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(255, 255, 255, 0.7);
font-family: 'Courier New', monospace;
font-size: 0.9rem;
word-break: break-all;
}
.field-link {
color: #646cff;
text-decoration: none;
}
.field-link:hover {
text-decoration: underline;
}
/* CID field styling */
.cid-field .cid-value-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cid-field .cid-value {
flex: 1;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.85rem;
color: #a0d8ef;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cid-field .cid-value:hover {
background: rgba(0, 0, 0, 0.4);
border-color: #646cff;
}
.cid-field .cid-open-button {
display: inline-flex;
align-items: center;
padding: 0.5rem 0.75rem;
background: #646cff;
color: #fff;
border-radius: 4px;
text-decoration: none;
font-size: 0.85rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.2s;
}
.cid-field .cid-open-button:hover {
background: #535bf2;
}
/* JSON field styling */
.json-field .json-viewer {
background: rgba(0, 0, 0, 0.4);
color: #d4d4d4;
padding: 1rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.85rem;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Language string field styling */
.language-string-field .language-entries {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.language-string-field .language-entry {
display: flex;
align-items: center;
gap: 0.5rem;
}
.language-string-field .lang-input {
flex: 1;
min-width: 0;
}
.language-string-field .lang-code {
display: inline-block;
width: 50px;
padding: 0.4rem 0.5rem;
background: rgba(100, 108, 255, 0.2);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.8rem;
color: #a0d8ef;
text-align: center;
flex-shrink: 0;
}
.language-string-field .lang-text {
flex: 1;
padding: 0.4rem 0.75rem;
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
color: rgba(255, 255, 255, 0.8);
font-size: 0.9rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-string-field .lang-remove-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 100, 100, 0.2);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: 4px;
color: #ff6b6b;
font-size: 1.2rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.language-string-field .lang-remove-btn:hover {
background: rgba(255, 100, 100, 0.3);
border-color: rgba(255, 100, 100, 0.5);
}
.language-string-field .lang-add-btn {
align-self: flex-start;
padding: 0.4rem 0.75rem;
background: rgba(100, 108, 255, 0.2);
border: 1px solid rgba(100, 108, 255, 0.3);
border-radius: 4px;
color: #646cff;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.language-string-field .lang-add-btn:hover {
background: rgba(100, 108, 255, 0.3);
border-color: rgba(100, 108, 255, 0.5);
}
/* Light theme */
@media (prefers-color-scheme: light) {
.dynamic-field label {
color: rgba(0, 0, 0, 0.7);
}
.dynamic-field input[type="text"],
.dynamic-field input[type="number"],
.dynamic-field select,
.dynamic-field textarea {
border-color: rgba(0, 0, 0, 0.1);
background: #fff;
color: #333;
}
.dynamic-field input:disabled,
.dynamic-field select:disabled,
.dynamic-field textarea:disabled {
background: #f5f5f5;
color: rgba(0, 0, 0, 0.5);
}
.field-hint {
color: rgba(0, 0, 0, 0.5);
}
.checkbox-label {
color: rgba(0, 0, 0, 0.7);
}
.readonly-value {
background: #f8f9fa;
border-color: #e9ecef;
color: #495057;
}
.json-field .json-viewer {
background: #1e1e1e;
border-color: rgba(0, 0, 0, 0.2);
}
.language-string-field .lang-code {
background: rgba(100, 108, 255, 0.1);
color: #4a50c8;
}
.language-string-field .lang-text {
background: #f8f9fa;
border-color: #e9ecef;
color: #495057;
}
}
@@ -1,378 +0,0 @@
import { SchemaField, FileMetadata } from '../types';
import { LanguageCombobox, formatLanguageOption } from './LanguageCombobox';
import './DynamicFieldRenderer.css';
interface DynamicFieldRendererProps {
fieldKey: string;
schema: SchemaField;
metadata: FileMetadata;
onChange: (key: string, value: any) => void;
readonly?: boolean;
}
/**
* Renders a single metadata field based on its schema definition
*/
export function DynamicFieldRenderer({
fieldKey,
schema,
metadata,
onChange,
readonly = false,
}: DynamicFieldRendererProps) {
const isReadonly = readonly || schema.readonly;
const fieldType = schema.type || 'string';
// Get value from metadata using path-style key
const getValue = (): any => {
// Handle path-style keys like "stream/duration" or "nfo/title"
const parts = fieldKey.split('/');
let value: any = metadata;
for (const part of parts) {
if (value === undefined || value === null) return undefined;
value = value[part];
}
return value;
};
// Set value in metadata using path-style key
const setValue = (newValue: any) => {
onChange(fieldKey, newValue);
};
const value = getValue();
// Render link if configured
const renderValueWithLink = (displayValue: string) => {
if (schema.link && displayValue) {
const url = schema.link.replace('{value}', encodeURIComponent(displayValue));
return (
<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;
}
}
@@ -1,63 +0,0 @@
.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);
}
}
@@ -1,126 +0,0 @@
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;
}
-174
View File
@@ -1,174 +0,0 @@
.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);
}
}
-123
View File
@@ -1,123 +0,0 @@
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>
);
}
-247
View File
@@ -1,247 +0,0 @@
.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);
}
}
-216
View File
@@ -1,216 +0,0 @@
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>
);
}
@@ -8,15 +8,6 @@ for (const lang of iso6393) {
languagesByCode.set(lang.iso6393, lang); 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 // Format for display: "English (eng)" or "eng" for unknown
export function formatLanguageOption(code: string): string { export function formatLanguageOption(code: string): string {
const lang = languagesByCode.get(code); const lang = languagesByCode.get(code);
-438
View File
@@ -1,438 +0,0 @@
.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);
}
}
-392
View File
@@ -1,392 +0,0 @@
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>
);
}
-52
View File
@@ -1,52 +0,0 @@
.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);
}
}
-40
View File
@@ -1,40 +0,0 @@
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>
);
}
+321
View File
@@ -137,6 +137,26 @@
font-weight: 500; font-weight: 500;
} }
.kv-tree-branch .kv-tree-filename {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.85em;
color: #1e3a8a;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.kv-tree-hash-pill {
margin-left: 0.5rem;
padding: 0.05rem 0.4rem;
background: rgba(100, 108, 255, 0.12);
color: #4a50c8;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 0.7em;
border-radius: 999px;
flex-shrink: 0;
}
.kv-tree-leaf .kv-tree-label { .kv-tree-leaf .kv-tree-label {
color: #4a5170; color: #4a5170;
} }
@@ -251,6 +271,307 @@
gap: 8px; gap: 8px;
} }
/* ── CID leaf ─────────────────────────────────────────────────────────── */
.kv-leaf-cid {
display: flex;
flex-direction: column;
gap: 8px;
}
.kv-leaf-cid-controls {
display: flex;
gap: 6px;
align-items: center;
}
.kv-leaf-cid-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.kv-leaf-cid-meta {
font-size: 12px;
color: #6b7280;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.kv-leaf-cid-preview {
max-width: 100%;
max-height: 320px;
border-radius: 6px;
border: 1px solid #e2e6ee;
background: #f5f6fa;
object-fit: contain;
align-self: flex-start;
}
.kv-leaf-cid-preview-media {
width: 100%;
max-width: 560px;
}
/* ── File-detail view ─────────────────────────────────────────────────── */
.kv-file {
display: flex;
flex-direction: column;
gap: 14px;
}
.kv-file-header {
padding: 14px 16px;
background: #fff;
border: 1px solid #e2e6ee;
border-radius: 8px;
display: grid;
grid-template-columns: 1fr auto;
grid-template-areas:
"name actions"
"meta actions";
gap: 6px 16px;
align-items: center;
}
.kv-file-name {
grid-area: name;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-weight: 600;
font-size: 14px;
color: #1e3a8a;
word-break: break-all;
}
.kv-file-name-fallback {
color: #9ca3af;
font-style: italic;
}
.kv-file-meta {
grid-area: meta;
display: flex;
gap: 12px;
align-items: baseline;
font-size: 12px;
color: #6b7280;
min-width: 0;
}
.kv-file-hash {
background: rgba(100, 122, 250, 0.1);
color: #4a50c8;
padding: 1px 6px;
border-radius: 999px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
flex-shrink: 0;
}
.kv-file-path {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 11px;
}
.kv-file-actions {
grid-area: actions;
display: flex;
gap: 8px;
align-items: center;
}
.kv-file-rows {
display: flex;
flex-direction: column;
gap: 6px;
}
.kv-file-row {
display: grid;
grid-template-columns: minmax(140px, 220px) 1fr auto;
gap: 12px;
padding: 8px 12px;
background: #fff;
border: 1px solid #e2e6ee;
border-left: 3px solid #d6deed;
border-radius: 6px;
align-items: start;
}
.kv-file-row.pending-delete {
border-left-color: #dc2626;
background: #fef2f2;
}
.kv-file-row.pending-delete .kv-file-row-label {
text-decoration: line-through;
color: #9ca3af;
}
.kv-file-row-deleted-note {
color: #b91c1c;
font-style: italic;
font-size: 12px;
padding: 6px 0;
}
.kv-file-row-delete {
width: 24px;
height: 24px;
align-self: center;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: #dc2626;
font-size: 16px;
line-height: 1;
cursor: pointer;
opacity: 0.4;
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
}
.kv-file-row:hover .kv-file-row-delete,
.kv-file-row.pending-delete .kv-file-row-delete {
opacity: 1;
}
.kv-file-row-delete:hover {
background: rgba(220, 38, 38, 0.1);
border-color: rgba(220, 38, 38, 0.3);
}
.kv-file-add {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 12px;
background: rgba(100, 122, 250, 0.05);
border: 1px dashed rgba(100, 122, 250, 0.3);
border-radius: 6px;
}
.kv-file-add-prefix {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 11px;
color: #6b7280;
white-space: nowrap;
flex-shrink: 0;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.kv-file-add > input {
flex: 1;
}
.kv-file-add > input:first-of-type {
flex: 0 0 200px;
}
.kv-file-row.dirty {
border-left-color: #fbbf24;
background: #fffbeb;
}
.kv-file-row.pulsed {
animation: kv-row-pulse 1.4s ease-out;
}
@keyframes kv-row-pulse {
0% { background: rgba(100, 122, 250, 0.35); border-left-color: #4a50c8; }
100% { background: transparent; }
}
.kv-file-row-label,
.kv-file-row-label-static {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 12px;
color: #2a3050;
background: none;
border: none;
text-align: left;
cursor: pointer;
padding: 6px 0;
word-break: break-all;
}
.kv-file-row-label-static {
cursor: default;
}
.kv-file-row-label:hover {
color: #4a50c8;
text-decoration: underline;
}
.kv-file-row-value {
display: flex;
min-width: 0;
}
.kv-file-row-value > * {
flex: 1;
min-width: 0;
}
.kv-file-group {
display: flex;
flex-direction: column;
border: 1px solid #e2e6ee;
border-radius: 8px;
background: rgba(100, 122, 250, 0.03);
overflow: hidden;
}
.kv-file-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: none;
background: rgba(100, 122, 250, 0.1);
cursor: pointer;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Courier New', monospace;
font-size: 12px;
color: #2a3050;
}
.kv-file-group-header:hover {
background: rgba(100, 122, 250, 0.2);
}
.kv-file-group-chevron {
font-size: 10px;
color: #6b7280;
}
.kv-file-group-name {
font-weight: 600;
color: #1e3a8a;
}
.kv-file-group-count {
margin-left: auto;
padding: 1px 8px;
background: rgba(100, 122, 250, 0.2);
color: #4a50c8;
border-radius: 999px;
font-size: 11px;
}
.kv-file-group-body {
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.kv-file-row-lang .kv-file-lang-entries {
display: flex;
flex-direction: column;
gap: 6px;
}
.kv-file-lang-entry {
display: flex;
gap: 6px;
align-items: center;
}
.kv-file-lang-entry > .kv-leaf-input {
flex: 1;
}
.kv-file-lang-remove {
color: #dc2626;
}
.kv-file-lang-add {
align-self: flex-start;
background: rgba(100, 122, 250, 0.1);
}
/* ── DateTime leaf ────────────────────────────────────────────────────── */
.kv-leaf-datetime {
display: flex;
gap: 8px;
align-items: center;
}
.kv-leaf-datetime-shape {
font-size: 11px;
color: #6b7280;
background: rgba(100, 122, 250, 0.1);
padding: 2px 6px;
border-radius: 999px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: nowrap;
}
/* ── Branch ───────────────────────────────────────────────────────────── */ /* ── Branch ───────────────────────────────────────────────────────────── */
.kv-branch-section { .kv-branch-section {
border: 1px solid #e2e6ee; border: 1px solid #e2e6ee;
+41 -4
View File
@@ -4,6 +4,9 @@ import { ancestorBranches, KVTree } from './KVTree';
import { FilteredKVTree } from './FilteredKVTree'; import { FilteredKVTree } from './FilteredKVTree';
import { KVLeafEditor } from './KVLeafEditor'; import { KVLeafEditor } from './KVLeafEditor';
import { KVBranchEditor } from './KVBranchEditor'; import { KVBranchEditor } from './KVBranchEditor';
import { KVFileEditor } from './KVFileEditor';
const FILE_BRANCH_RE = /^file:[^/]+\/$/;
import { SearchFieldChips } from './SearchFieldChips'; import { SearchFieldChips } from './SearchFieldChips';
import { ToastStack, makeToast } from './Toast'; import { ToastStack, makeToast } from './Toast';
import { ToastEntry } from './types'; import { ToastEntry } from './types';
@@ -44,6 +47,28 @@ export default function KVApp() {
const [reloadToken, setReloadToken] = useState(0); const [reloadToken, setReloadToken] = useState(0);
const [toasts, setToasts] = useState<ToastEntry[]>([]); const [toasts, setToasts] = useState<ToastEntry[]>([]);
const searchTimerRef = useRef<number | null>(null); const searchTimerRef = useRef<number | null>(null);
// When the right pane is a file-detail view and the user clicks a leaf
// under the same file branch in the tree, we route the click as a
// scroll-anchor inside the file detail instead of a leaf-editor switch.
// The token bumps each click so identical anchors still trigger a scroll.
const [anchor, setAnchor] = useState<{ field: string; token: number } | null>(null);
const isFileBranch = (p: string | null) => !!p && /^file:[^/]+\/$/.test(p);
const handleLeafClick = (key: string) => {
if (isFileBranch(branchPath) && key.startsWith(branchPath!)) {
setAnchor({ field: key.slice(branchPath!.length), token: (anchor?.token ?? 0) + 1 });
return;
}
setSelectedKey(key);
setBranchPath(null);
setAnchor(null);
};
const handleBranchClick = (path: string) => {
setBranchPath(path);
setSelectedKey(null);
setAnchor(null);
};
const pushToast = (t: ToastEntry) => setToasts((cur) => [...cur, t]); const pushToast = (t: ToastEntry) => setToasts((cur) => [...cur, t]);
const dismissToast = (id: number) => setToasts((cur) => cur.filter((t) => t.id !== id)); const dismissToast = (id: number) => setToasts((cur) => cur.filter((t) => t.id !== id));
@@ -184,8 +209,8 @@ export default function KVApp() {
searching={searching} searching={searching}
selectedKey={selectedKey} selectedKey={selectedKey}
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); }} onSelectLeaf={handleLeafClick}
onSelectBranch={(p) => { setBranchPath(p); setSelectedKey(null); }} onSelectBranch={handleBranchClick}
onToggleBranch={(p, willOpen) => { onToggleBranch={(p, willOpen) => {
setExpandedPaths((cur) => { setExpandedPaths((cur) => {
const next = new Set(cur); const next = new Set(cur);
@@ -199,8 +224,8 @@ export default function KVApp() {
selectedKey={selectedKey} selectedKey={selectedKey}
expandedPaths={expandedPaths} expandedPaths={expandedPaths}
reloadToken={reloadToken} reloadToken={reloadToken}
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); }} onSelectLeaf={handleLeafClick}
onSelectBranch={(p) => { setBranchPath(p); setSelectedKey(null); }} onSelectBranch={handleBranchClick}
onToggleBranch={(p, willOpen) => { onToggleBranch={(p, willOpen) => {
setExpandedPaths((cur) => { setExpandedPaths((cur) => {
const next = new Set(cur); const next = new Set(cur);
@@ -227,6 +252,18 @@ export default function KVApp() {
}} }}
onError={(msg) => pushToast(makeToast('error', msg))} onError={(msg) => pushToast(makeToast('error', msg))}
/> />
) : branchPath && FILE_BRANCH_RE.test(branchPath) ? (
<KVFileEditor
key={branchPath}
branchPath={branchPath}
reloadToken={reloadToken}
scrollToField={anchor}
onChanged={(info, errors) => {
pushToast(makeToast(errors && errors.length > 0 ? 'error' : 'success', info, errors));
setReloadToken((n) => n + 1);
}}
onSelectLeaf={(k) => { setSelectedKey(k); setBranchPath(null); setAnchor(null); }}
/>
) : branchPath ? ( ) : branchPath ? (
<KVBranchEditor <KVBranchEditor
branchPath={branchPath} branchPath={branchPath}
+675
View File
@@ -0,0 +1,675 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { KVAPI } from '../api/kvApi';
import { SchemaFieldInfo, useSchema } from '../api/schemaApi';
import { LanguageCombobox, formatLanguageOption } from '../components/LanguageCombobox';
import { LeafValueEditor } from './leafRenderers';
import { schemaToFieldType } from './schemaLookup';
import { detectType } from './typeHeuristics';
import { FieldType } from './types';
interface Props {
/** Branch path of a file, e.g. "file:<hash>/". */
branchPath: string;
reloadToken: number;
onChanged: (info: string, errors?: string[]) => void;
/** Navigate to a single leaf for the manual deep editor. */
onSelectLeaf: (key: string) => void;
/**
* When set, scroll to and pulse the row matching this logical field path
* (e.g. "releasedate", "plot", "stream/0"). The `token` lets the same field
* fire a fresh scroll across consecutive clicks.
*/
scrollToField?: { field: string; token: number } | null;
}
const LANG_CODE_RE = /^[a-z]{2,3}$/;
interface LeafRow {
kind: 'leaf';
fieldKey: string;
value: string;
fieldType: FieldType;
info?: SchemaFieldInfo;
}
interface LanguageRow {
kind: 'language-group';
fieldKey: string;
/** Current language → text map. */
languageMap: Record<string, string>;
info?: SchemaFieldInfo;
}
type Row = LeafRow | LanguageRow;
interface RowGroup {
prefix: string;
isCluster: boolean;
rows: Row[];
}
export function KVFileEditor({
branchPath,
reloadToken,
onChanged,
onSelectLeaf,
scrollToField,
}: Props) {
const [metadata, setMetadata] = useState<Record<string, string> | null>(null);
const [edits, setEdits] = useState<Map<string, string | null>>(new Map());
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const [pulse, setPulse] = useState<string | null>(null);
const [newFieldKey, setNewFieldKey] = useState('');
const [newFieldValue, setNewFieldValue] = useState('');
const [addingField, setAddingField] = useState(false);
// Track whether we've seeded collapsedGroups for the current branch; resets
// when the branch changes so each file gets its defaults reapplied.
const defaultsAppliedRef = useRef<string | null>(null);
const { schema } = useSchema();
// When the parent passes a target field, expand its prefix group (if any)
// and scroll the row into view. We also briefly pulse the row to draw the
// eye — the pulse is reset after the animation duration.
useEffect(() => {
if (!scrollToField) return;
const { field } = scrollToField;
const prefix = field.split('/', 1)[0];
setCollapsedGroups((cur) => {
if (!cur.has(prefix)) return cur;
const next = new Set(cur);
next.delete(prefix);
return next;
});
requestAnimationFrame(() => {
const el = document.querySelector(
`[data-row-key="${cssEscape(field)}"]`,
) as HTMLElement | null;
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setPulse(field);
window.setTimeout(() => setPulse((p) => (p === field ? null : p)), 1400);
}
});
}, [scrollToField?.field, scrollToField?.token]);
const hashId = branchPath.slice('file:'.length, -1);
useEffect(() => {
let cancelled = false;
setLoading(true);
KVAPI.treeAll(branchPath, '/', 5000)
.then(async (resp) => {
// resp.leaves = full keys under branchPath. Recursive descent: also
// walk branch children (they're paths to sub-objects with their own
// leaves, e.g. file:<h>/plot/ → plot/eng).
const all: string[] = [...resp.leaves];
const queue = [...resp.branches];
while (queue.length > 0) {
const next = queue.shift()!;
try {
const sub = await KVAPI.treeAll(next, '/', 5000);
all.push(...sub.leaves);
queue.push(...sub.branches);
} catch {
// Ignore failures on a sub-branch; show what we can.
}
}
if (cancelled) return;
// MGET-equivalent: parallel fetches with a small cap to avoid burst.
const values = await Promise.all(
all.map((k) => KVAPI.get(k).then((r) => [k, r.exists ? r.value : null] as const).catch(() => [k, null] as const)),
);
if (cancelled) return;
const flat: Record<string, string> = {};
for (const [k, v] of values) {
if (v == null) continue;
// Strip the branch prefix so keys are the logical field path
// ("releasedate", "plot/eng", "stream/0").
const fieldKey = k.slice(branchPath.length);
if (fieldKey) flat[fieldKey] = v;
}
setMetadata(flat);
setEdits(new Map());
})
.catch(() => {
if (!cancelled) setMetadata({});
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [branchPath, reloadToken]);
const groups = useMemo(
() => groupRows(buildRows(metadata ?? {}, schema)),
[metadata, schema],
);
// Default state: every cluster group (e.g. stream/*, cid_*) starts collapsed.
// Reapplied when the user switches to a different file.
useEffect(() => {
if (groups.length === 0) return;
if (defaultsAppliedRef.current === branchPath) return;
defaultsAppliedRef.current = branchPath;
setCollapsedGroups(new Set(groups.filter((g) => g.isCluster).map((g) => g.prefix)));
}, [groups, branchPath]);
if (loading) {
return <div className="kv-pane-status">loading file…</div>;
}
if (!metadata) {
return <div className="kv-pane-status">no data for {branchPath}</div>;
}
const fileName = metadata['fileName'];
const filePath = metadata['filePath'];
const onLeafChange = (fieldKey: string, value: string) => {
setEdits((prev) => {
const next = new Map(prev);
const original = metadata[fieldKey] ?? null;
if (value === original) next.delete(fieldKey);
else next.set(fieldKey, value);
return next;
});
};
/**
* Toggle a field's pending-delete state. Re-clicking restores; if the field
* is brand-new (no original value) we just drop it from the edits map.
*/
const onLeafDeleteToggle = (fieldKey: string) => {
setEdits((prev) => {
const next = new Map(prev);
if (next.get(fieldKey) === null) {
// Currently pending delete → undo.
next.delete(fieldKey);
} else {
next.set(fieldKey, null);
}
return next;
});
};
const handleAddField = async () => {
const key = newFieldKey.trim();
if (!key) return;
const fullKey = branchPath + key;
setAddingField(true);
try {
await KVAPI.put(fullKey, newFieldValue);
setNewFieldKey('');
setNewFieldValue('');
onChanged(`Added ${fullKey}`);
// Refetch this file so the new field appears in the row list.
const r = await KVAPI.get(fullKey);
if (r.exists) {
setMetadata((cur) => ({ ...(cur ?? {}), [key]: r.value }));
}
} catch (e: any) {
onChanged(`Failed to add ${fullKey}`, [e.message]);
} finally {
setAddingField(false);
}
};
const onLanguageGroupChange = (
groupKey: string,
nextMap: Record<string, string>,
originalMap: Record<string, string>,
) => {
setEdits((prev) => {
const next = new Map(prev);
const allLangs = new Set([...Object.keys(originalMap), ...Object.keys(nextMap)]);
for (const lang of allLangs) {
const fieldKey = `${groupKey}/${lang}`;
const orig = originalMap[lang] ?? null;
const cur = nextMap[lang] ?? null;
if (cur === orig) next.delete(fieldKey);
else if (cur == null) next.set(fieldKey, null);
else next.set(fieldKey, cur);
}
return next;
});
};
const handleSave = async () => {
if (edits.size === 0) return;
setSaving(true);
const errors: string[] = [];
for (const [fieldKey, newValue] of edits) {
const fullKey = branchPath + fieldKey;
try {
if (newValue === null) await KVAPI.del(fullKey);
else await KVAPI.put(fullKey, newValue);
} catch (e: any) {
errors.push(`${fieldKey}: ${e.message}`);
}
}
setSaving(false);
if (errors.length === 0) {
onChanged(`Saved ${edits.size} field${edits.size === 1 ? '' : 's'} on ${fileName ?? hashId}`);
} else {
onChanged(
`Saved with ${errors.length} failure(s) on ${fileName ?? hashId}`,
errors,
);
}
};
const handleDiscard = () => setEdits(new Map());
const toggleGroup = (prefix: string) => {
setCollapsedGroups((cur) => {
const next = new Set(cur);
if (next.has(prefix)) next.delete(prefix);
else next.add(prefix);
return next;
});
};
return (
<div className="kv-file">
<div className="kv-file-header">
<div className="kv-file-name">
{fileName ?? <span className="kv-file-name-fallback">(no fileName)</span>}
</div>
<div className="kv-file-meta">
<code className="kv-file-hash" title={hashId}>
{hashId.slice(0, 8)}…{hashId.slice(-6)}
</code>
{filePath && <span className="kv-file-path" title={filePath}>{filePath}</span>}
</div>
<div className="kv-file-actions">
<button
className="kv-btn kv-btn-primary"
disabled={edits.size === 0 || saving}
onClick={handleSave}
>
{saving ? 'saving…' : `Save (${edits.size})`}
</button>
<button
className="kv-btn"
disabled={edits.size === 0 || saving}
onClick={handleDiscard}
>
Discard
</button>
</div>
</div>
<div className="kv-file-rows">
{groups.length === 0 && (
<div className="kv-pane-status">No data yet for this file.</div>
)}
{groups.map((g) => (
<FieldGroupView
key={g.prefix}
group={g}
edits={edits}
collapsed={collapsedGroups.has(g.prefix)}
pulse={pulse}
onToggle={() => toggleGroup(g.prefix)}
onLeafChange={onLeafChange}
onLeafDeleteToggle={onLeafDeleteToggle}
onLanguageChange={onLanguageGroupChange}
onJumpToLeaf={(fieldKey) => onSelectLeaf(branchPath + fieldKey)}
/>
))}
</div>
<div className="kv-file-add">
<span className="kv-file-add-prefix">{branchPath}</span>
<input
type="text"
className="kv-leaf-input kv-leaf-mono"
placeholder="field path (may contain /)"
value={newFieldKey}
onChange={(e) => setNewFieldKey(e.target.value)}
disabled={addingField}
/>
<input
type="text"
className="kv-leaf-input"
placeholder="value"
value={newFieldValue}
onChange={(e) => setNewFieldValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newFieldKey.trim()) handleAddField();
}}
disabled={addingField}
/>
<button
className="kv-btn kv-btn-primary"
disabled={!newFieldKey.trim() || addingField}
onClick={handleAddField}
>
{addingField ? 'adding…' : '+ Add field'}
</button>
</div>
</div>
);
}
function FieldGroupView({
group,
edits,
collapsed,
pulse,
onToggle,
onLeafChange,
onLeafDeleteToggle,
onLanguageChange,
onJumpToLeaf,
}: {
group: RowGroup;
edits: Map<string, string | null>;
collapsed: boolean;
pulse: string | null;
onToggle: () => void;
onLeafChange: (fieldKey: string, value: string) => void;
onLeafDeleteToggle: (fieldKey: string) => void;
onLanguageChange: (
groupKey: string,
nextMap: Record<string, string>,
originalMap: Record<string, string>,
) => void;
onJumpToLeaf: (fieldKey: string) => void;
}) {
if (!group.isCluster) {
return (
<>
{group.rows.map((row) => (
<RowView
key={row.fieldKey}
row={row}
edits={edits}
pulse={pulse}
onLeafChange={onLeafChange}
onLeafDeleteToggle={onLeafDeleteToggle}
onLanguageChange={onLanguageChange}
onJumpToLeaf={onJumpToLeaf}
/>
))}
</>
);
}
return (
<div className={`kv-file-group ${collapsed ? 'collapsed' : ''}`}>
<button
type="button"
className="kv-file-group-header"
onClick={onToggle}
title={collapsed ? 'expand' : 'collapse'}
>
<span className="kv-file-group-chevron">{collapsed ? '▸' : '▾'}</span>
<span className="kv-file-group-name">{group.prefix}</span>
<span className="kv-file-group-count">{group.rows.length}</span>
</button>
{!collapsed && (
<div className="kv-file-group-body">
{group.rows.map((row) => (
<RowView
key={row.fieldKey}
row={row}
edits={edits}
pulse={pulse}
onLeafChange={onLeafChange}
onLeafDeleteToggle={onLeafDeleteToggle}
onLanguageChange={onLanguageChange}
onJumpToLeaf={onJumpToLeaf}
/>
))}
</div>
)}
</div>
);
}
function RowView({
row,
edits,
pulse,
onLeafChange,
onLeafDeleteToggle,
onLanguageChange,
onJumpToLeaf,
}: {
row: Row;
edits: Map<string, string | null>;
pulse: string | null;
onLeafChange: (fieldKey: string, value: string) => void;
onLeafDeleteToggle: (fieldKey: string) => void;
onLanguageChange: (
groupKey: string,
nextMap: Record<string, string>,
originalMap: Record<string, string>,
) => void;
onJumpToLeaf: (fieldKey: string) => void;
}) {
const pulsed = pulse === row.fieldKey;
if (row.kind === 'leaf') {
const pendingDelete = edits.get(row.fieldKey) === null;
const draft = edits.has(row.fieldKey) ? (edits.get(row.fieldKey) ?? '') : row.value;
const dirty = edits.has(row.fieldKey);
return (
<div
className={`kv-file-row ${dirty ? 'dirty' : ''} ${pendingDelete ? 'pending-delete' : ''} ${pulsed ? 'pulsed' : ''}`}
data-row-key={row.fieldKey}
>
<button
type="button"
className="kv-file-row-label"
onClick={() => onJumpToLeaf(row.fieldKey)}
title="open in single-leaf editor"
>
{row.fieldKey}
</button>
<div className="kv-file-row-value">
{pendingDelete ? (
<div className="kv-file-row-deleted-note">marked for deletion</div>
) : (
<LeafValueEditor
type={row.fieldType}
value={draft}
onChange={(v) => onLeafChange(row.fieldKey, v)}
/>
)}
</div>
<button
type="button"
className="kv-file-row-delete"
onClick={() => onLeafDeleteToggle(row.fieldKey)}
title={pendingDelete ? 'undo deletion' : 'delete this field'}
aria-label={pendingDelete ? 'undo deletion' : 'delete this field'}
>
{pendingDelete ? '↺' : '×'}
</button>
</div>
);
}
return (
<LanguageGroupRow
row={row}
edits={edits}
pulsed={pulsed}
onLanguageChange={onLanguageChange}
/>
);
}
function LanguageGroupRow({
row,
edits,
pulsed,
onLanguageChange,
}: {
row: LanguageRow;
edits: Map<string, string | null>;
pulsed: boolean;
onLanguageChange: (
groupKey: string,
nextMap: Record<string, string>,
originalMap: Record<string, string>,
) => void;
}) {
// Apply pending edits on top of the original language map.
const effectiveMap: Record<string, string> = { ...row.languageMap };
for (const [k, v] of edits) {
if (!k.startsWith(`${row.fieldKey}/`)) continue;
const lang = k.slice(row.fieldKey.length + 1);
if (v === null) delete effectiveMap[lang];
else effectiveMap[lang] = v;
}
const entries = Object.entries(effectiveMap);
const dirty = [...edits.keys()].some((k) => k.startsWith(`${row.fieldKey}/`));
const updateLang = (lang: string, text: string) => {
onLanguageChange(row.fieldKey, { ...effectiveMap, [lang]: text }, row.languageMap);
};
const renameLang = (oldLang: string, newLang: string) => {
if (oldLang === newLang) return;
const next = { ...effectiveMap };
const text = next[oldLang];
delete next[oldLang];
next[newLang] = text;
onLanguageChange(row.fieldKey, next, row.languageMap);
};
const removeLang = (lang: string) => {
const next = { ...effectiveMap };
delete next[lang];
onLanguageChange(row.fieldKey, next, row.languageMap);
};
const addLang = () => {
const used = new Set(entries.map(([k]) => k));
const defaults = ['eng', 'jpn', 'zho', 'kor', 'fra', 'deu', 'spa', 'fre'];
const defaultLang = defaults.find((l) => !used.has(l)) || 'eng';
onLanguageChange(row.fieldKey, { ...effectiveMap, [defaultLang]: '' }, row.languageMap);
};
return (
<div
className={`kv-file-row kv-file-row-lang ${dirty ? 'dirty' : ''} ${pulsed ? 'pulsed' : ''}`}
data-row-key={row.fieldKey}
>
<div className="kv-file-row-label-static">{row.fieldKey}</div>
<div className="kv-file-row-value">
<div className="kv-file-lang-entries">
{entries.map(([lang, text]) => (
<div key={lang} className="kv-file-lang-entry">
<LanguageCombobox
value={lang}
onChange={(newLang) => renameLang(lang, newLang)}
/>
<input
type="text"
className="kv-leaf-input"
value={text}
title={formatLanguageOption(lang)}
onChange={(e) => updateLang(lang, e.target.value)}
/>
<button
type="button"
className="kv-btn-icon kv-file-lang-remove"
onClick={() => removeLang(lang)}
title="remove this language"
>
×
</button>
</div>
))}
<button
type="button"
className="kv-btn kv-file-lang-add"
onClick={addLang}
>
+ Add language
</button>
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────────────────
// Row model — kept local because it's only used here and is small.
// ──────────────────────────────────────────────────────────────────────────
function buildRows(
flat: Record<string, string>,
schema: ReturnType<typeof useSchema>['schema'],
): Row[] {
const languageGroupKeys = new Set<string>();
if (schema) {
for (const [k, info] of Object.entries(schema.fields)) {
if (info.key_hint === 'language-code') languageGroupKeys.add(k);
}
}
const langMaps: Record<string, Record<string, string>> = {};
const rows: Row[] = [];
for (const [key, value] of Object.entries(flat)) {
const slash = key.lastIndexOf('/');
if (slash > 0) {
const prefix = key.slice(0, slash);
const tail = key.slice(slash + 1);
if (languageGroupKeys.has(prefix) && LANG_CODE_RE.test(tail)) {
if (!langMaps[prefix]) langMaps[prefix] = {};
langMaps[prefix][tail] = value;
continue;
}
}
const info = schema?.fields[key];
rows.push({
kind: 'leaf',
fieldKey: key,
value,
// Schema decides when it can; otherwise heuristic on (path, value).
fieldType: schemaToFieldType(info) ?? detectType(key, value),
info,
});
}
for (const [groupKey, langMap] of Object.entries(langMaps)) {
rows.push({
kind: 'language-group',
fieldKey: groupKey,
languageMap: langMap,
info: schema?.fields[groupKey],
});
}
rows.sort((a, b) => a.fieldKey.localeCompare(b.fieldKey));
return rows;
}
/** Escape characters that would break a CSS attribute selector value. */
function cssEscape(s: string): string {
return s.replace(/["\\]/g, (c) => `\\${c}`);
}
function groupRows(rows: Row[]): RowGroup[] {
const out: RowGroup[] = [];
for (const row of rows) {
const prefix = row.fieldKey.split('/', 1)[0];
const last = out[out.length - 1];
if (last && last.prefix === prefix) {
last.rows.push(row);
} else {
out.push({ prefix, isCluster: false, rows: [row] });
}
}
for (const g of out) g.isCluster = g.rows.length > 1;
return out;
}
+11 -108
View File
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { KVAPI } from '../api/kvApi'; import { KVAPI } from '../api/kvApi';
import { LanguageCombobox } from '../components/LanguageCombobox'; import { useSchema } from '../api/schemaApi';
import { ALL_TYPES, describeType, detectType } from './typeHeuristics'; import { ALL_TYPES, describeType, detectType } from './typeHeuristics';
import { lookupSchemaForKey } from './schemaLookup';
import { LeafValueEditor } from './leafRenderers';
import { FieldType } from './types'; import { FieldType } from './types';
interface Props { interface Props {
@@ -22,9 +24,12 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Manual override for the inferred type. "auto" defers to detectType. // Manual override for the inferred type. "auto" defers to schema → heuristic.
const [typeOverride, setTypeOverride] = useState<FieldType>('auto'); const [typeOverride, setTypeOverride] = useState<FieldType>('auto');
const detected = detectType(selectedKey, original); const { schema } = useSchema();
const schemaLookup = lookupSchemaForKey(schema, selectedKey);
// Schema is authoritative when it has an opinion; heuristics fill the gap.
const detected = schemaLookup.fieldType ?? detectType(selectedKey, original);
const effectiveType = typeOverride === 'auto' ? detected : typeOverride; const effectiveType = typeOverride === 'auto' ? detected : typeOverride;
// Reset override when the selected key changes — the previous override // Reset override when the selected key changes — the previous override
@@ -113,7 +118,7 @@ export function KVLeafEditor({ selectedKey, reloadToken, onSaved, onDeleted, onE
{!exists && <span className="kv-leaf-hint">new key — save to create</span>} {!exists && <span className="kv-leaf-hint">new key — save to create</span>}
</div> </div>
<ValueEditor type={effectiveType} value={draft} onChange={setDraft} /> <LeafValueEditor type={effectiveType} value={draft} onChange={setDraft} />
<div className="kv-leaf-actions"> <div className="kv-leaf-actions">
<button <button
@@ -151,107 +156,5 @@ function KeyHeader({ keyPath }: { keyPath: string }) {
); );
} }
interface ValueProps { // Leaf value rendering moved to leafRenderers.tsx (LeafValueEditor)
type: FieldType; // so the file-detail view can reuse it per row.
value: string;
onChange: (v: string) => void;
}
function ValueEditor({ type, value, onChange }: ValueProps) {
switch (type) {
case 'bool':
return (
<select
className="kv-leaf-input"
value={value === 'true' ? 'true' : value === 'false' ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">(unset)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
case 'number':
return (
<input
type="number"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'datetime':
return (
<input
type="datetime-local"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'lang':
return (
<LanguageCombobox value={value} onChange={onChange} />
);
case 'url':
return (
<div className="kv-leaf-url">
<input
type="url"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && (
<a className="kv-leaf-url-open" href={value} target="_blank" rel="noreferrer">open ↗</a>
)}
</div>
);
case 'json':
return (
<textarea
className="kv-leaf-textarea kv-leaf-mono"
rows={12}
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
);
case 'text':
return (
<textarea
className="kv-leaf-textarea"
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'hash':
return (
<div className="kv-leaf-url">
<input
type="text"
className="kv-leaf-input kv-leaf-mono"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<button
className="kv-btn-icon"
onClick={() => navigator.clipboard.writeText(value)}
title="copy"
>⧉</button>
</div>
);
case 'string':
default:
return (
<input
type="text"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
+90 -18
View File
@@ -1,6 +1,57 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { KVAPI } from '../api/kvApi'; import { KVAPI } from '../api/kvApi';
const FILE_BRANCH_RE = /^file:[^/]+\/$/;
const fileNameCache = new Map<string, string | null>();
const fileNamePromises = new Map<string, Promise<void>>();
const fileNameSubscribers = new Set<() => void>();
function notifyFileNameSubscribers() {
for (const cb of fileNameSubscribers) cb();
}
function ensureFileName(branchPath: string): void {
if (fileNameCache.has(branchPath) || fileNamePromises.has(branchPath)) return;
const p = KVAPI.get(branchPath + 'fileName')
.then((r) => {
fileNameCache.set(branchPath, r.exists && r.value ? r.value : null);
})
.catch(() => {
fileNameCache.set(branchPath, null);
})
.finally(() => {
fileNamePromises.delete(branchPath);
notifyFileNameSubscribers();
});
fileNamePromises.set(branchPath, p);
}
function useFileName(branchPath: string | null): string | null {
const [, force] = useState(0);
useEffect(() => {
if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return;
ensureFileName(branchPath);
const cb = () => force((n) => n + 1);
fileNameSubscribers.add(cb);
return () => {
fileNameSubscribers.delete(cb);
};
}, [branchPath]);
if (!branchPath || !FILE_BRANCH_RE.test(branchPath)) return null;
return fileNameCache.get(branchPath) ?? null;
}
function isFileBranch(path: string): boolean {
return FILE_BRANCH_RE.test(path);
}
function shortHashFromFileBranch(path: string): string {
// path = "file:<hash>/" → last 6 chars of hash
const hash = path.slice('file:'.length, -1);
if (hash.length <= 12) return hash;
return `…${hash.slice(-6)}`;
}
// A node in the lazy tree. branch-typed nodes have an associated path that // A node in the lazy tree. branch-typed nodes have an associated path that
// ends with the delimiter and lazy-load their children on first expand. // ends with the delimiter and lazy-load their children on first expand.
type NodeKind = 'branch' | 'leaf'; type NodeKind = 'branch' | 'leaf';
@@ -174,17 +225,15 @@ function Branch({
const childState = branches[node.path]; const childState = branches[node.path];
return ( return (
<li key={node.path}> <li key={node.path}>
<div <BranchRow
className={`kv-tree-row kv-tree-branch${open ? ' open' : ''}`} node={node}
style={{ paddingLeft: depth * 14 + 4 }} open={open}
onClick={() => { depth={depth}
onToggle={() => {
onToggleBranch(node.path, !open); onToggleBranch(node.path, !open);
onSelectBranch(node.path); onSelectBranch(node.path);
}} }}
> />
<span className="kv-tree-toggle">{open ? '▾' : '▸'}</span>
<span className="kv-tree-label">{node.label}</span>
</div>
{open && ( {open && (
<Branch <Branch
path={node.path} path={node.path}
@@ -219,6 +268,38 @@ function Branch({
); );
} }
interface BranchRowProps {
node: TreeNode;
open: boolean;
depth: number;
onToggle: () => void;
}
function BranchRow({ node, open, depth, onToggle }: BranchRowProps) {
// Surface the actual filename next to file:<hash>/ branches so the tree
// reads like a file list at that level instead of a wall of hashes.
const fileName = useFileName(isFileBranch(node.path) ? node.path : null);
return (
<div
className={`kv-tree-row kv-tree-branch${open ? ' open' : ''}`}
style={{ paddingLeft: depth * 14 + 4 }}
onClick={onToggle}
title={isFileBranch(node.path) ? node.path : undefined}
>
<span className="kv-tree-toggle">{open ? '▾' : '▸'}</span>
{fileName ? (
<>
<span className="kv-tree-label kv-tree-filename">{fileName}</span>
<span className="kv-tree-hash-pill">{shortHashFromFileBranch(node.path)}</span>
</>
) : (
<span className="kv-tree-label">{node.label}</span>
)}
</div>
);
}
// Helper: given a key path with both ":" and "/" separators, return the list // Helper: given a key path with both ":" and "/" separators, return the list
// of ancestor branch paths that need to be expanded to reveal it. // of ancestor branch paths that need to be expanded to reveal it.
export function ancestorBranches(key: string): string[] { export function ancestorBranches(key: string): string[] {
@@ -242,14 +323,5 @@ function nextSep(s: string, from: number): number {
return best; return best;
} }
// Memoize the ancestor calculation across renders (used by the search-hit
// auto-expand).
export function useAncestorBranches(key: string | null): Set<string> {
return useMemo(() => {
if (!key) return new Set();
return new Set(ancestorBranches(key));
}, [key]);
}
// Re-export for convenience in the shell // Re-export for convenience in the shell
export type { TreeNode }; export type { TreeNode };
+295
View File
@@ -0,0 +1,295 @@
import { useEffect, useState } from 'react';
import { LanguageCombobox } from '../components/LanguageCombobox';
import { FieldType } from './types';
interface ValueRendererProps {
value: string;
onChange: (v: string) => void;
}
interface LeafValueEditorProps extends ValueRendererProps {
type: FieldType;
}
/**
* Type-dispatched leaf value editor. Used by both the single-leaf editor
* (KVLeafEditor) and the per-row editor in the file-detail view.
*/
export function LeafValueEditor({ type, value, onChange }: LeafValueEditorProps) {
switch (type) {
case 'bool':
return (
<select
className="kv-leaf-input"
value={value === 'true' ? 'true' : value === 'false' ? 'false' : ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">(unset)</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
);
case 'number':
return (
<input
type="number"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'datetime':
return <DateTimeLeaf value={value} onChange={onChange} />;
case 'lang':
return <LanguageCombobox value={value} onChange={onChange} />;
case 'url':
return (
<div className="kv-leaf-url">
<input
type="url"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && (
<a className="kv-leaf-url-open" href={value} target="_blank" rel="noreferrer">
open ↗
</a>
)}
</div>
);
case 'json':
return (
<textarea
className="kv-leaf-textarea kv-leaf-mono"
rows={12}
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
);
case 'text':
return (
<textarea
className="kv-leaf-textarea"
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'hash':
return <CidLeaf value={value} onChange={onChange} />;
case 'auto':
case 'string':
default:
return (
<input
type="text"
className="kv-leaf-input"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
interface CidFileInfo {
exists: boolean;
contentType?: string;
size?: number;
filePath?: string;
}
/**
* CID leaf editor. Shows the value as an editable monospace input plus a
* content-typed preview when meta-core can resolve the CID to a file on disk
* (`/api/file/{cid}/info` → image/video/audio rendering, else download-only).
*/
function CidLeaf({ value, onChange }: ValueRendererProps) {
const [info, setInfo] = useState<CidFileInfo | null>(null);
const cid = value.trim();
const fileUrl = cid ? `/file/${cid}` : '';
const downloadName = info?.filePath?.split('/').pop() || cid;
useEffect(() => {
if (!cid) {
setInfo(null);
return;
}
let alive = true;
fetch(`/api/file/${cid}/info`)
.then((r) => (r.ok ? r.json() : { exists: false }))
.then((data: CidFileInfo) => {
if (alive) setInfo(data);
})
.catch(() => {
if (alive) setInfo({ exists: false });
});
return () => {
alive = false;
};
}, [cid]);
const renderPreview = () => {
if (!info?.exists || !info.contentType || !cid) return null;
if (info.contentType.startsWith('image/')) {
return <img src={fileUrl} alt="" className="kv-leaf-cid-preview" loading="lazy" />;
}
if (info.contentType.startsWith('video/')) {
return (
<video
src={fileUrl}
className="kv-leaf-cid-preview kv-leaf-cid-preview-media"
controls
preload="metadata"
/>
);
}
if (info.contentType.startsWith('audio/')) {
return (
<audio
src={fileUrl}
className="kv-leaf-cid-preview-media"
controls
preload="metadata"
/>
);
}
return null;
};
return (
<div className="kv-leaf-cid">
{renderPreview()}
<div className="kv-leaf-cid-controls">
<input
type="text"
className="kv-leaf-input kv-leaf-mono"
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<button
type="button"
className="kv-btn-icon"
onClick={() => navigator.clipboard.writeText(value)}
title="copy CID"
>
⧉
</button>
</div>
{info?.exists && cid && (
<div className="kv-leaf-cid-actions">
<a
href={fileUrl}
download={downloadName}
className="kv-btn kv-btn-primary"
title={`Download ${downloadName}${info.size ? ` (${formatBytes(info.size)})` : ''}`}
>
⬇ Download
</a>
<a href={fileUrl} className="kv-btn" target="_blank" rel="noreferrer">
open ↗
</a>
<span className="kv-leaf-cid-meta">
{info.contentType}
{info.size != null ? ` · ${formatBytes(info.size)}` : ''}
</span>
</div>
)}
</div>
);
}
/** Timestamp shapes we round-trip through the picker. */
type TimestampShape = 'iso-date' | 'iso-datetime' | 'unix-s' | 'unix-ms';
function detectShape(raw: string): TimestampShape | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) return 'iso-date';
if (/^\d+$/.test(trimmed)) {
const n = Number.parseInt(trimmed, 10);
if (n >= 1_000_000_000 && n <= 4_102_444_800) return 'unix-s';
if (n >= 1_000_000_000_000 && n <= 4_102_444_800_000) return 'unix-ms';
return null;
}
if (!Number.isNaN(Date.parse(trimmed))) return 'iso-datetime';
return null;
}
function rawToDate(raw: string, shape: TimestampShape): Date | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (shape === 'unix-s') return new Date(Number.parseInt(trimmed, 10) * 1000);
if (shape === 'unix-ms') return new Date(Number.parseInt(trimmed, 10));
const d = new Date(trimmed);
return Number.isNaN(d.getTime()) ? null : d;
}
function dateToRaw(d: Date, shape: TimestampShape): string {
switch (shape) {
case 'unix-s':
return String(Math.floor(d.getTime() / 1000));
case 'unix-ms':
return String(d.getTime());
case 'iso-date':
return d.toISOString().slice(0, 10);
case 'iso-datetime':
return d.toISOString();
}
}
/**
* Datetime leaf editor. Preserves the original storage shape — ISO date stays
* ISO date, unix seconds stay unix seconds, etc. Unknown shapes fall back to
* iso-datetime.
*/
function DateTimeLeaf({ value, onChange }: ValueRendererProps) {
const shape: TimestampShape = detectShape(value) ?? 'iso-datetime';
const date = rawToDate(value, shape);
const inputType = shape === 'iso-date' ? 'date' : 'datetime-local';
const inputValue = (() => {
if (!date || Number.isNaN(date.getTime())) return '';
if (inputType === 'date') return date.toISOString().slice(0, 10);
// datetime-local wants YYYY-MM-DDTHH:mm in the user's local timezone.
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return local.toISOString().slice(0, 16);
})();
return (
<div className="kv-leaf-datetime">
<input
type={inputType}
className="kv-leaf-input"
value={inputValue}
onChange={(e) => {
const v = e.target.value;
if (!v) {
onChange('');
return;
}
const parsed = new Date(v);
if (Number.isNaN(parsed.getTime())) return;
onChange(dateToRaw(parsed, shape));
}}
/>
<span className="kv-leaf-datetime-shape" title="Storage format preserved on save">
{shape}
</span>
</div>
);
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
}
+90
View File
@@ -0,0 +1,90 @@
import { SchemaFieldInfo, SchemaResponse } from '../api/schemaApi';
import { FieldType } from './types';
export interface SchemaLookup {
/** The matched schema entry, if any. */
info?: SchemaFieldInfo;
/** Suggested FieldType for the leaf editor, or null when schema can't decide. */
fieldType: FieldType | null;
/**
* True when the leaf is one variant under a language-keyed group
* (e.g. plot/eng). The schema entry returned is the group's, not the leaf's.
*/
isLanguageVariant: boolean;
}
/**
* Resolve the logical schema entry for a full Redis key.
*
* - "file:<hash>/releasedate" → schema.fields["releasedate"]
* - "file:<hash>/plot/eng" → schema.fields["plot"] (language-keyed group)
* - non-file keys → undefined (schema is per-file today)
*/
export function lookupSchemaForKey(
schema: SchemaResponse | null,
key: string,
): SchemaLookup {
if (!schema) {
return { fieldType: null, isLanguageVariant: false };
}
const propertyPath = stripFilePrefix(key);
if (!propertyPath) {
return { fieldType: null, isLanguageVariant: false };
}
const direct = schema.fields[propertyPath];
if (direct) {
return {
info: direct,
fieldType: schemaToFieldType(direct),
isLanguageVariant: false,
};
}
// Language-keyed collapse: tail is a 2/3-letter lang code under a known group.
const lastSlash = propertyPath.lastIndexOf('/');
if (lastSlash > 0) {
const tail = propertyPath.slice(lastSlash + 1);
if (/^[a-z]{2,3}$/.test(tail)) {
const group = schema.fields[propertyPath.slice(0, lastSlash)];
if (group?.key_hint === 'language-code') {
return {
info: group,
fieldType: schemaToFieldType(group),
isLanguageVariant: true,
};
}
}
}
return { fieldType: null, isLanguageVariant: false };
}
export function schemaToFieldType(info: SchemaFieldInfo | undefined): FieldType | null {
if (!info) return null;
if (info.hint === 'cid') return 'hash';
if (info.hint === 'timestamp') return 'datetime';
switch (info.type) {
case 'bool':
return 'bool';
case 'int':
case 'float':
return 'number';
case 'json-object':
case 'json-array':
return 'json';
case 'string':
case 'mixed':
case 'undefined':
default:
return null;
}
}
function stripFilePrefix(key: string): string {
if (!key.startsWith('file:')) return '';
const slash = key.indexOf('/');
if (slash < 0) return '';
return key.slice(slash + 1);
}
+2 -2
View File
@@ -12,14 +12,14 @@ const PATH_PATTERNS: Array<[RegExp, FieldType]> = [
[/(^|\/)(description|overview|notes?|comment|summary|nfo)$/i, 'text'], [/(^|\/)(description|overview|notes?|comment|summary|nfo)$/i, 'text'],
]; ];
export function detectFromPath(path: string): FieldType | null { function detectFromPath(path: string): FieldType | null {
for (const [re, t] of PATH_PATTERNS) { for (const [re, t] of PATH_PATTERNS) {
if (re.test(path)) return t; if (re.test(path)) return t;
} }
return null; return null;
} }
export function detectFromValue(v: string): FieldType { function detectFromValue(v: string): FieldType {
if (v === '') return 'string'; if (v === '') return 'string';
if (v === 'true' || v === 'false') return 'bool'; if (v === 'true' || v === 'false') return 'bool';
if (/^-?\d+$/.test(v)) return 'number'; if (/^-?\d+$/.test(v)) return 'number';
-155
View File
@@ -1,155 +0,0 @@
// 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;
}
-53
View File
@@ -1,53 +0,0 @@
/**
* 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';
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/kvApi.ts","./src/api/metadataApi.ts","./src/api/snapshotApi.ts","./src/components/BulkMetadataEditor.tsx","./src/components/DynamicFieldRenderer.tsx","./src/components/DynamicPluginSection.tsx","./src/components/FileList.tsx","./src/components/KVTreeView.tsx","./src/components/LanguageCombobox.tsx","./src/components/MetadataEditor.tsx","./src/components/SearchBar.tsx","./src/components/SnapshotPanel.tsx","./src/kv/FilteredKVTree.tsx","./src/kv/KVApp.tsx","./src/kv/KVBranchEditor.tsx","./src/kv/KVLeafEditor.tsx","./src/kv/KVTree.tsx","./src/kv/SearchFieldChips.tsx","./src/kv/Toast.tsx","./src/kv/typeHeuristics.ts","./src/kv/types.ts","./src/types/index.ts","./src/utils/formatting.ts"],"version":"5.9.3"} {"root":["./src/main.tsx","./src/vite-env.d.ts","./src/api/kvApi.ts","./src/api/schemaApi.ts","./src/api/snapshotApi.ts","./src/components/LanguageCombobox.tsx","./src/components/SnapshotPanel.tsx","./src/kv/FilteredKVTree.tsx","./src/kv/KVApp.tsx","./src/kv/KVBranchEditor.tsx","./src/kv/KVFileEditor.tsx","./src/kv/KVLeafEditor.tsx","./src/kv/KVTree.tsx","./src/kv/SearchFieldChips.tsx","./src/kv/Toast.tsx","./src/kv/leafRenderers.tsx","./src/kv/schemaLookup.ts","./src/kv/typeHeuristics.ts","./src/kv/types.ts"],"version":"5.9.3"}
+57
View File
@@ -340,6 +340,63 @@ func (s *Server) handleHeadData(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
// handleGetFileByCIDInfo handles GET /api/file/{cid}/info.
// Returns lightweight metadata for the file (existence, content type, size,
// relative path) without streaming any bytes. Used by the editor to decide
// whether to render a preview (image/video) or a download-only button.
func (s *Server) handleGetFileByCIDInfo(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
cid := vars["cid"]
type infoResp struct {
Exists bool `json:"exists"`
ContentType string `json:"contentType,omitempty"`
Size int64 `json:"size,omitempty"`
FilePath string `json:"filePath,omitempty"`
}
w.Header().Set("Content-Type", "application/json")
if cid == "" {
_ = json.NewEncoder(w).Encode(infoResp{Exists: false})
return
}
if !s.storage.IsConnected() {
writeError(w, http.StatusServiceUnavailable, "storage not connected")
return
}
relPath, err := s.storage.LookupPathByCID(cid)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if relPath == "" {
_ = json.NewEncoder(w).Encode(infoResp{Exists: false})
return
}
fullPath := filepath.Join(s.config.FilesPath, relPath)
fi, err := os.Stat(fullPath)
if err != nil {
_ = json.NewEncoder(w).Encode(infoResp{Exists: false, FilePath: relPath})
return
}
ext := strings.ToLower(filepath.Ext(fullPath))
contentType, ok := contentTypeByExt[ext]
if !ok {
contentType = "application/octet-stream"
}
_ = json.NewEncoder(w).Encode(infoResp{
Exists: true,
ContentType: contentType,
Size: fi.Size(),
FilePath: relPath,
})
}
// handleGetFileByCID handles GET /file/{cid} // handleGetFileByCID handles GET /file/{cid}
// Serves a file by looking up its CID in poster/backdrop metadata // Serves a file by looking up its CID in poster/backdrop metadata
func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetFileByCID(w http.ResponseWriter, r *http.Request) {
+45
View File
@@ -0,0 +1,45 @@
package api
import (
"encoding/json"
"net/http"
"time"
)
// handleSchemaGet returns the live per-field schema derived from meta:events.
func (s *Server) handleSchemaGet(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
return
}
snap := s.schemaIndexer.Snapshot()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(snap)
}
// handleSchemaRescan re-emits a set event for every existing property via the
// meta publisher, waits for the indexer to drain, and returns the refreshed
// schema. Equivalent to a full SCAN-based rebuild without locking Redis.
func (s *Server) handleSchemaRescan(w http.ResponseWriter, r *http.Request) {
if s.schemaIndexer == nil {
http.Error(w, "schema indexer not running", http.StatusServiceUnavailable)
return
}
count, err := s.RepublishMetadata()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.schemaIndexer.WaitForDrain(10 * time.Second)
snap := s.schemaIndexer.Snapshot()
snap.Source = "rescan"
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"published_events": count,
"schema": snap,
})
}
+23
View File
@@ -13,6 +13,7 @@ import (
"github.com/metazla/meta-core/internal/events" "github.com/metazla/meta-core/internal/events"
"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/mounts"
"github.com/metazla/meta-core/internal/schema"
"github.com/metazla/meta-core/internal/storage" "github.com/metazla/meta-core/internal/storage"
"github.com/metazla/meta-core/internal/watcher" "github.com/metazla/meta-core/internal/watcher"
"github.com/metazla/meta-core/internal/watchers" "github.com/metazla/meta-core/internal/watchers"
@@ -37,6 +38,7 @@ type Server struct {
watchersPoller *watchers.Poller watchersPoller *watchers.Poller
watchersHandlers *watchers.Handlers watchersHandlers *watchers.Handlers
metaPublisher *events.MetaPublisher metaPublisher *events.MetaPublisher
schemaIndexer *schema.Indexer
webdavHandler *webdav.Handler webdavHandler *webdav.Handler
router *mux.Router router *mux.Router
server *http.Server server *http.Server
@@ -163,6 +165,10 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT") s.router.HandleFunc("/api/metadata/{hashId}", s.handleUpdateMetadataByHashId).Methods("PUT")
s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE") s.router.HandleFunc("/api/metadata/{hashId}", s.handleDeleteMetadataByHashId).Methods("DELETE")
// Schema inference
s.router.HandleFunc("/api/schema", s.handleSchemaGet).Methods("GET")
s.router.HandleFunc("/api/schema/rescan", s.handleSchemaRescan).Methods("POST")
// KV Browser API routes // KV Browser API routes
s.router.HandleFunc("/api/kv/info", s.handleKVInfo).Methods("GET") 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/keys", s.handleKVKeys).Methods("GET")
@@ -194,6 +200,7 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/data/{hash}", s.handleHeadData).Methods("HEAD") s.router.HandleFunc("/data/{hash}", s.handleHeadData).Methods("HEAD")
// File operations (by CID) // File operations (by CID)
s.router.HandleFunc("/api/file/{cid}/info", s.handleGetFileByCIDInfo).Methods("GET")
s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET") s.router.HandleFunc("/file/{cid}", s.handleGetFileByCID).Methods("GET")
s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST") s.router.HandleFunc("/file/cid", s.handleComputeFileCID).Methods("POST")
@@ -285,6 +292,15 @@ func (s *Server) Start() error {
if err := s.metaPublisher.Start(); err != nil { if err := s.metaPublisher.Start(); err != nil {
log.Printf("[API] Warning: failed to start meta publisher: %v", err) log.Printf("[API] Warning: failed to start meta publisher: %v", err)
} else { } else {
// Schema indexer consumes the same stream to derive a live
// per-field schema (type/hint/breakdown). Start it before the
// republish flood so it captures the bootstrap events.
s.schemaIndexer = schema.NewIndexer(s.storage.GetRedisClient(), s.storage.GetPrefix())
if err := s.schemaIndexer.Start(); err != nil {
log.Printf("[API] Warning: failed to start schema indexer: %v", err)
s.schemaIndexer = nil
}
// Republish events for all existing metadata // Republish events for all existing metadata
go func() { go func() {
count, err := s.RepublishMetadata() count, err := s.RepublishMetadata()
@@ -321,6 +337,13 @@ func (s *Server) Stop() error {
s.mountStatsPoller.Stop() s.mountStatsPoller.Stop()
} }
// Stop schema indexer before the publisher so the consumer drains cleanly
if s.schemaIndexer != nil {
if err := s.schemaIndexer.Stop(); err != nil {
log.Printf("[API] Warning: failed to stop schema indexer: %v", err)
}
}
// Stop meta publisher // Stop meta publisher
if s.metaPublisher != nil { if s.metaPublisher != nil {
if err := s.metaPublisher.Stop(); err != nil { if err := s.metaPublisher.Stop(); err != nil {
+103
View File
@@ -0,0 +1,103 @@
package schema
import (
"encoding/json"
"regexp"
"strconv"
"strings"
"time"
)
// cidRegex matches the CID shapes produced by @metazla/meta-hash:
// - CIDv1 multibase-base32: every algorithm (crc32, md5, sha1, sha2-256,
// sha3-256, sha3-384, midhash256, btih-v2, …) is serialized via
// CID.createV1(code, digest).toString(), which always yields a lowercase
// base32 string starting with "ba" (the "b" is the multibase tag, the
// "a" is the CIDv1 version byte under base32-encoding-of-0x01).
// - CIDv0 base58btc: "Qm" + 44 chars, kept for compatibility with other
// services that may still emit v0.
//
// Minimum CIDv1 length 16 chars matches the shortest real output we observed
// (crc32: "bafk3eaqerjhiwhy").
var cidRegex = regexp.MustCompile(`^(ba[a-z2-7]{14,}|Qm[1-9A-HJ-NP-Za-km-z]{44})$`)
// ClassifyValue returns the (primitive, hint) for a Redis value.
// exists=false (key missing) and JSON null literal both map to PrimUndefined.
// Empty string is a valid string.
func ClassifyValue(value string, exists bool) (Primitive, Hint) {
if !exists {
return PrimUndefined, ""
}
trimmed := strings.TrimSpace(value)
if trimmed == "null" {
return PrimUndefined, ""
}
if trimmed == "true" || trimmed == "false" {
return PrimBool, ""
}
if _, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
if isTimestampInt(trimmed) {
return PrimInt, HintTimestamp
}
return PrimInt, ""
}
if _, err := strconv.ParseFloat(trimmed, 64); err == nil {
return PrimFloat, ""
}
if len(trimmed) > 0 {
switch trimmed[0] {
case '{':
var v map[string]any
if json.Unmarshal([]byte(trimmed), &v) == nil {
return PrimJSONObj, ""
}
case '[':
var v []any
if json.Unmarshal([]byte(trimmed), &v) == nil {
return PrimJSONArr, ""
}
}
}
if cidRegex.MatchString(value) {
return PrimString, HintCID
}
if isTimestampString(value) {
return PrimString, HintTimestamp
}
return PrimString, ""
}
// isTimestampInt accepts unix-seconds in [2001-09, 2100] or unix-millis in the
// same range. Anything else is treated as a plain int.
func isTimestampInt(s string) bool {
n, err := strconv.ParseInt(s, 10, 64)
if err != nil || n <= 0 {
return false
}
if n >= 1_000_000_000 && n <= 4_102_444_800 {
return true
}
if n >= 1_000_000_000_000 && n <= 4_102_444_800_000 {
return true
}
return false
}
func isTimestampString(s string) bool {
if _, err := time.Parse(time.RFC3339, s); err == nil {
return true
}
if _, err := time.Parse("2006-01-02", s); err == nil {
return true
}
return false
}
// breakdownLabel returns the most specific label for a classified value.
// Hints take precedence over primitives.
func breakdownLabel(prim Primitive, hint Hint) string {
if hint != "" {
return string(hint)
}
return string(prim)
}
+354
View File
@@ -0,0 +1,354 @@
package schema
import (
"context"
"log"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/metazla/meta-core/internal/events"
"github.com/redis/go-redis/v9"
)
var langCodeRegex = regexp.MustCompile(`^[a-z]{2,3}$`)
// fieldState is the per-field accumulator. perKey lets us undo a previous
// contribution when the same Redis key is re-SET or DELed.
type fieldState struct {
counts map[string]int
primitives map[Primitive]int
hintCounts map[Hint]int
typedCount int
undefinedCount int
languageKeyed bool
perKey map[string]labelPair
}
type labelPair struct {
prim Primitive
hint Hint
}
func newFieldState() *fieldState {
return &fieldState{
counts: make(map[string]int),
primitives: make(map[Primitive]int),
hintCounts: make(map[Hint]int),
perKey: make(map[string]labelPair),
}
}
// Indexer maintains a live field-schema by consuming meta:events.
type Indexer struct {
client *redis.Client
keyPrefix string
mu sync.RWMutex
fields map[string]*fieldState
processed uint64 // total events processed since start
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
runMu sync.Mutex
running bool
}
// NewIndexer creates an Indexer. keyPrefix should match storage.Client.GetPrefix()
// so the indexer can build absolute Redis keys for GET calls.
func NewIndexer(client *redis.Client, keyPrefix string) *Indexer {
ctx, cancel := context.WithCancel(context.Background())
return &Indexer{
client: client,
keyPrefix: keyPrefix,
fields: make(map[string]*fieldState),
ctx: ctx,
cancel: cancel,
}
}
// Start begins consuming the meta:events stream from the earliest available ID.
func (i *Indexer) Start() error {
i.runMu.Lock()
if i.running {
i.runMu.Unlock()
return nil
}
i.running = true
i.runMu.Unlock()
i.wg.Add(1)
go i.consumeLoop()
log.Println("[SchemaIndexer] Started - consuming meta:events stream")
return nil
}
// Stop terminates the consumer goroutine.
func (i *Indexer) Stop() error {
i.runMu.Lock()
if !i.running {
i.runMu.Unlock()
return nil
}
i.running = false
i.runMu.Unlock()
i.cancel()
i.wg.Wait()
log.Println("[SchemaIndexer] Stopped")
return nil
}
// ProcessedCount returns the total number of stream events processed.
// Used by WaitForDrain to detect when a rescan flood has been absorbed.
func (i *Indexer) ProcessedCount() uint64 {
return atomic.LoadUint64(&i.processed)
}
// WaitForDrain blocks until ProcessedCount has been stable for `stableFor` or
// the timeout elapses. Suitable for use right after RepublishMetadata.
func (i *Indexer) WaitForDrain(timeout time.Duration) {
deadline := time.Now().Add(timeout)
last := i.ProcessedCount()
stableSince := time.Now()
for time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
now := i.ProcessedCount()
if now != last {
last = now
stableSince = time.Now()
continue
}
if time.Since(stableSince) >= 300*time.Millisecond {
return
}
}
}
func (i *Indexer) consumeLoop() {
defer i.wg.Done()
lastID := "0"
for {
select {
case <-i.ctx.Done():
return
default:
}
res, err := i.client.XRead(i.ctx, &redis.XReadArgs{
Streams: []string{events.MetaEventsStream, lastID},
Block: 2 * time.Second,
Count: 500,
}).Result()
if err != nil {
if err == redis.Nil || i.ctx.Err() != nil {
continue
}
log.Printf("[SchemaIndexer] XRead error: %v", err)
time.Sleep(1 * time.Second)
continue
}
for _, stream := range res {
for _, msg := range stream.Messages {
lastID = msg.ID
i.processEvent(msg.Values)
atomic.AddUint64(&i.processed, 1)
}
}
}
}
func (i *Indexer) processEvent(values map[string]any) {
op, _ := values["type"].(string)
key, _ := values["key"].(string)
if key == "" {
return
}
fieldPath, keyHint, ok := parseFieldPath(key)
if !ok {
return
}
switch op {
case "set":
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
val, err := i.client.Get(ctx, i.keyPrefix+key).Result()
cancel()
exists := err != redis.Nil
if err != nil && err != redis.Nil {
// Treat transient errors as "value disappeared" — nothing to record.
return
}
prim, hint := ClassifyValue(val, exists)
i.upsert(fieldPath, key, keyHint, prim, hint)
case "del", "expire":
i.remove(fieldPath, key)
}
}
// parseFieldPath turns "file:<hashID>/<propertyPath>" into the logical field
// path used as the map key. hashID may itself contain ':' (e.g.
// "midhash256:abc123"); the split is on the first '/'.
//
// When the last segment of propertyPath matches ^[a-z]{2,3}$ it is treated as
// a language code: the field name collapses to the prefix and key_hint is set.
func parseFieldPath(key string) (string, KeyHint, bool) {
if !strings.HasPrefix(key, "file:") {
return "", "", false
}
rest := strings.TrimPrefix(key, "file:")
slash := strings.Index(rest, "/")
if slash < 0 {
return "", "", false
}
property := rest[slash+1:]
if property == "" {
return "", "", false
}
if last := strings.LastIndex(property, "/"); last >= 0 {
tail := property[last+1:]
if langCodeRegex.MatchString(tail) {
return property[:last], KeyHintLanguageCode, true
}
}
return property, "", true
}
func (i *Indexer) upsert(fieldPath, redisKey string, keyHint KeyHint, prim Primitive, hint Hint) {
i.mu.Lock()
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
fs = newFieldState()
i.fields[fieldPath] = fs
}
if keyHint != "" {
fs.languageKeyed = true
}
if old, ok := fs.perKey[redisKey]; ok {
decContribution(fs, old)
}
fs.perKey[redisKey] = labelPair{prim: prim, hint: hint}
incContribution(fs, labelPair{prim: prim, hint: hint})
}
func (i *Indexer) remove(fieldPath, redisKey string) {
i.mu.Lock()
defer i.mu.Unlock()
fs, ok := i.fields[fieldPath]
if !ok {
return
}
old, ok := fs.perKey[redisKey]
if !ok {
return
}
delete(fs.perKey, redisKey)
decContribution(fs, old)
if len(fs.perKey) == 0 {
delete(i.fields, fieldPath)
}
}
func incContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]++
if lp.prim == PrimUndefined {
fs.undefinedCount++
return
}
fs.typedCount++
fs.primitives[lp.prim]++
if lp.hint != "" {
fs.hintCounts[lp.hint]++
}
}
func decContribution(fs *fieldState, lp labelPair) {
label := breakdownLabel(lp.prim, lp.hint)
fs.counts[label]--
if fs.counts[label] <= 0 {
delete(fs.counts, label)
}
if lp.prim == PrimUndefined {
fs.undefinedCount--
return
}
fs.typedCount--
fs.primitives[lp.prim]--
if fs.primitives[lp.prim] <= 0 {
delete(fs.primitives, lp.prim)
}
if lp.hint != "" {
fs.hintCounts[lp.hint]--
if fs.hintCounts[lp.hint] <= 0 {
delete(fs.hintCounts, lp.hint)
}
}
}
// Snapshot returns the current schema. Safe to call concurrently with consumption.
func (i *Indexer) Snapshot() *SchemaResponse {
i.mu.RLock()
defer i.mu.RUnlock()
out := &SchemaResponse{
Fields: make(map[string]*FieldSchema, len(i.fields)),
GeneratedAt: time.Now().UTC(),
Source: "live",
}
for fp, fs := range i.fields {
out.Fields[fp] = computeFieldSchema(fs)
}
return out
}
func computeFieldSchema(fs *fieldState) *FieldSchema {
out := &FieldSchema{
Breakdown: make(map[string]int, len(fs.counts)),
}
for k, v := range fs.counts {
out.Breakdown[k] = v
}
nPrim := len(fs.primitives)
hasUndef := fs.undefinedCount > 0
switch {
case nPrim == 0 && hasUndef:
out.Type = PrimUndefined
case nPrim == 1 && !hasUndef:
for p := range fs.primitives {
out.Type = p
}
default:
out.Type = PrimMixed
}
// Strict hint promotion: exactly one hint observed, and it covers every
// typed (non-undefined) value. Hints are never set on mixed fields.
if out.Type != PrimMixed && len(fs.hintCounts) == 1 && fs.typedCount > 0 {
for h, c := range fs.hintCounts {
if c == fs.typedCount {
out.Hint = h
}
}
}
if fs.languageKeyed {
out.KeyHint = KeyHintLanguageCode
}
return out
}
+48
View File
@@ -0,0 +1,48 @@
package schema
import "time"
// Primitive is the coarse value type used by the schema indexer.
// PrimMixed appears only as an aggregate label on a field, never per value.
type Primitive string
const (
PrimString Primitive = "string"
PrimInt Primitive = "int"
PrimFloat Primitive = "float"
PrimBool Primitive = "bool"
PrimJSONObj Primitive = "json-object"
PrimJSONArr Primitive = "json-array"
PrimUndefined Primitive = "undefined"
PrimMixed Primitive = "mixed"
)
// Hint is a semantic refinement on top of a primitive (e.g. cid, timestamp).
// A hint is promoted onto a field only when every non-undefined value matches it.
type Hint string
const (
HintCID Hint = "cid"
HintTimestamp Hint = "timestamp"
)
// KeyHint describes the shape of the subkey dimension when a field is keyed
// by a structured discriminator (e.g. title/eng, title/fre → language-code).
type KeyHint string
const KeyHintLanguageCode KeyHint = "language-code"
// FieldSchema is the per-field entry returned by GET /api/schema.
type FieldSchema struct {
Type Primitive `json:"type"`
Hint Hint `json:"hint,omitempty"`
KeyHint KeyHint `json:"key_hint,omitempty"`
Breakdown map[string]int `json:"breakdown"`
}
// SchemaResponse is the top-level payload for GET /api/schema.
type SchemaResponse struct {
Fields map[string]*FieldSchema `json:"fields"`
GeneratedAt time.Time `json:"generated_at"`
Source string `json:"source"`
}
+79 -17
View File
@@ -321,8 +321,15 @@ func (c *Client) CountFiles() (int, error) {
return len(hashIDs), nil return len(hashIDs), nil
} }
// LookupPathByCID searches all file metadata for a matching CID // LookupPathByCID resolves a CID to its file path on disk.
// Returns the file path if found in poster/posterPath or backdrop/backdropPath //
// Resolution covers three cases:
// - midhash256 hashID itself: the CID identifies a file's content, return its filePath.
// - poster/backdrop fields: the CID is for the image asset, return posterPath/backdropPath.
// - any cid_* field: the CID is another digest of the same file, return its filePath.
//
// Walks every indexed hash; acceptable for admin/UI use, but would benefit from
// a reverse index (cid → hashID/field) if call volume grows.
func (c *Client) LookupPathByCID(cid string) (string, error) { func (c *Client) LookupPathByCID(cid string) (string, error) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -338,31 +345,45 @@ func (c *Client) LookupPathByCID(cid string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// Get all hash IDs first
hashIDs, err := c.getAllHashIDsInternal(ctx) hashIDs, err := c.getAllHashIDsInternal(ctx)
if err != nil { if err != nil {
return "", err return "", err
} }
// For each hash, check poster and backdrop CIDs using flat keys
for _, hashID := range hashIDs { for _, hashID := range hashIDs {
prefix := c.buildKeyPrefix(hashID) meta, err := c.getMetadataFlatInternal(ctx, hashID)
if err != nil || len(meta) == 0 {
continue
}
// Check poster // midhash256 == hashID: the file itself.
posterCID, err := c.client.Get(ctx, prefix+"poster").Result() if hashID == cid {
if err == nil && posterCID == cid { if fp := meta["filePath"]; fp != "" {
posterPath, err := c.client.Get(ctx, prefix+"posterPath").Result() return normalizeFilesRelativePath(fp), nil
if err == nil && posterPath != "" {
return posterPath, nil
} }
} }
// Check backdrop // Poster / backdrop refer to a sidecar image asset.
backdropCID, err := c.client.Get(ctx, prefix+"backdrop").Result() if meta["poster"] == cid {
if err == nil && backdropCID == cid { if p := meta["posterPath"]; p != "" {
backdropPath, err := c.client.Get(ctx, prefix+"backdropPath").Result() return normalizeFilesRelativePath(p), nil
if err == nil && backdropPath != "" { }
return backdropPath, nil }
if meta["backdrop"] == cid {
if p := meta["backdropPath"]; p != "" {
return normalizeFilesRelativePath(p), nil
}
}
// Any other cid_* field is an additional digest of the file itself.
for field, value := range meta {
if value != cid {
continue
}
if strings.HasPrefix(field, "cid_") {
if fp := meta["filePath"]; fp != "" {
return normalizeFilesRelativePath(fp), nil
}
} }
} }
} }
@@ -370,6 +391,47 @@ func (c *Client) LookupPathByCID(cid string) (string, error) {
return "", nil return "", nil
} }
// normalizeFilesRelativePath strips a leading "/files/" prefix so handlers can
// safely filepath.Join(FilesPath, …) without double-prefixing. Different
// plugins store paths inconsistently — poster/backdrop use FilesPath-relative
// ("/plugin/…") while filePath is FilesPath-absolute ("/files/watch/…").
func normalizeFilesRelativePath(p string) string {
return strings.TrimPrefix(p, "/files")
}
// getMetadataFlatInternal mirrors GetMetadataFlat without re-acquiring the lock.
// Callers must already hold c.mu.
func (c *Client) getMetadataFlatInternal(ctx context.Context, hashID string) (map[string]string, error) {
prefix := c.buildKeyPrefix(hashID)
result := make(map[string]string)
var cursor uint64
for {
keys, nextCursor, err := c.client.Scan(ctx, cursor, prefix+"*", 1000).Result()
if err != nil {
return nil, fmt.Errorf("scan failed: %w", err)
}
if len(keys) > 0 {
values, err := c.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("mget failed: %w", err)
}
for i, key := range keys {
if values[i] == nil {
continue
}
field := strings.TrimPrefix(key, prefix)
result[field] = values[i].(string)
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
return result, nil
}
// getAllHashIDsInternal is an internal version that doesn't acquire locks // getAllHashIDsInternal is an internal version that doesn't acquire locks
// (caller must hold the lock) // (caller must hold the lock)
func (c *Client) getAllHashIDsInternal(ctx context.Context) ([]string, error) { func (c *Client) getAllHashIDsInternal(ctx context.Context) ([]string, error) {