mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-18 07:51:46 +00:00
Add catalog source reorder in folders + enhanced import/export
- Add up/down reorder buttons for catalog sources within collection folders - Export: save as JSON file via share sheet + system file save dialog - Import: 3 methods - paste JSON, pick .json file, fetch from URL - Add deep JSON validation with preview before importing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
784c59fcdd
commit
360f7c29ee
3 changed files with 474 additions and 20 deletions
|
|
@ -255,6 +255,20 @@ const CollectionEditorScreen = () => {
|
|||
setShowEmojiPicker(false);
|
||||
}, [editingFolder]);
|
||||
|
||||
const handleMoveCatalogSourceUp = useCallback((index: number) => {
|
||||
if (!editingFolder || index <= 0) return;
|
||||
const sources = [...editingFolder.catalogSources];
|
||||
[sources[index - 1], sources[index]] = [sources[index], sources[index - 1]];
|
||||
setEditingFolder({ ...editingFolder, catalogSources: sources });
|
||||
}, [editingFolder]);
|
||||
|
||||
const handleMoveCatalogSourceDown = useCallback((index: number) => {
|
||||
if (!editingFolder || index >= editingFolder.catalogSources.length - 1) return;
|
||||
const sources = [...editingFolder.catalogSources];
|
||||
[sources[index], sources[index + 1]] = [sources[index + 1], sources[index]];
|
||||
setEditingFolder({ ...editingFolder, catalogSources: sources });
|
||||
}, [editingFolder]);
|
||||
|
||||
const handleToggleCatalogSource = useCallback((source: CollectionCatalogSource) => {
|
||||
if (!editingFolder) return;
|
||||
const existing = editingFolder.catalogSources.find(
|
||||
|
|
@ -429,12 +443,20 @@ const CollectionEditorScreen = () => {
|
|||
{isMissing ? `Addon not installed: ${source.addonId}` : `${source.addonId} · ${source.type}`}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleCatalogSource(source)}
|
||||
style={styles.removeCatalogButton}
|
||||
>
|
||||
<MaterialIcons name="close" size={20} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.catalogSourceActions}>
|
||||
<TouchableOpacity onPress={() => handleMoveCatalogSourceUp(idx)} disabled={idx === 0} style={styles.iconBtn}>
|
||||
<MaterialIcons name="arrow-upward" size={18} color={idx === 0 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => handleMoveCatalogSourceDown(idx)} disabled={idx === editingFolder.catalogSources.length - 1} style={styles.iconBtn}>
|
||||
<MaterialIcons name="arrow-downward" size={18} color={idx === editingFolder.catalogSources.length - 1 ? colors.disabled : colors.text} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleToggleCatalogSource(source)}
|
||||
style={styles.removeCatalogButton}
|
||||
>
|
||||
<MaterialIcons name="close" size={20} color={colors.error} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
|
@ -757,6 +779,11 @@ const styles = StyleSheet.create({
|
|||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
catalogSourceActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
removeCatalogButton: {
|
||||
padding: 6,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,8 +9,14 @@ import {
|
|||
Switch,
|
||||
Platform,
|
||||
Alert,
|
||||
Modal,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { cacheDirectory, writeAsStringAsync, readAsStringAsync, EncodingType } from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import { useNavigation, useFocusEffect, NavigationProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
|
@ -38,6 +44,14 @@ const CollectionManagementScreen = () => {
|
|||
const [alertMessage, setAlertMessage] = useState('');
|
||||
const [alertActions, setAlertActions] = useState<any[]>([]);
|
||||
const [enabledMap, setEnabledMap] = useState<Record<string, boolean>>({});
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importTab, setImportTab] = useState<'paste' | 'file' | 'url'>('paste');
|
||||
const [importText, setImportText] = useState('');
|
||||
const [importUrl, setImportUrl] = useState('');
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importPreview, setImportPreview] = useState<{ collectionCount: number; folderCount: number } | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [pendingJson, setPendingJson] = useState<string | null>(null);
|
||||
|
||||
const loadEnabledState = useCallback(async () => {
|
||||
const settings = await collectionsService.getCollectionSettings();
|
||||
|
|
@ -92,26 +106,125 @@ const CollectionManagementScreen = () => {
|
|||
const handleExport = useCallback(async () => {
|
||||
try {
|
||||
const json = await collectionsService.exportToJson();
|
||||
await Clipboard.setStringAsync(json);
|
||||
showInfo('Copied to clipboard');
|
||||
const fileUri = cacheDirectory + 'nuvio-collections.json';
|
||||
await writeAsStringAsync(fileUri, json, { encoding: EncodingType.UTF8 });
|
||||
await Sharing.shareAsync(fileUri, {
|
||||
mimeType: 'application/json',
|
||||
dialogTitle: 'Export Collections',
|
||||
UTI: 'public.json',
|
||||
});
|
||||
} catch {
|
||||
showError('Export failed');
|
||||
}
|
||||
}, [showInfo, showError]);
|
||||
}, [showError]);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
const resetImportState = useCallback(() => {
|
||||
setImportText('');
|
||||
setImportUrl('');
|
||||
setImportError(null);
|
||||
setImportPreview(null);
|
||||
setPendingJson(null);
|
||||
setImportLoading(false);
|
||||
setImportTab('paste');
|
||||
}, []);
|
||||
|
||||
const handleOpenImport = useCallback(() => {
|
||||
resetImportState();
|
||||
setShowImportModal(true);
|
||||
}, [resetImportState]);
|
||||
|
||||
const handleCloseImport = useCallback(() => {
|
||||
setShowImportModal(false);
|
||||
resetImportState();
|
||||
}, [resetImportState]);
|
||||
|
||||
const validateAndPreview = useCallback((json: string) => {
|
||||
const result = collectionsService.validateCollectionsJson(json);
|
||||
if (result.valid) {
|
||||
setImportError(null);
|
||||
setImportPreview({ collectionCount: result.collectionCount, folderCount: result.folderCount });
|
||||
setPendingJson(json);
|
||||
} else {
|
||||
setImportError(result.error || 'Invalid data');
|
||||
setImportPreview(null);
|
||||
setPendingJson(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePasteValidate = useCallback(async () => {
|
||||
const text = await Clipboard.getStringAsync();
|
||||
if (!text?.trim()) {
|
||||
setImportError('Clipboard is empty');
|
||||
return;
|
||||
}
|
||||
setImportText(text);
|
||||
validateAndPreview(text);
|
||||
}, [validateAndPreview]);
|
||||
|
||||
const handleTextValidate = useCallback(() => {
|
||||
if (!importText.trim()) {
|
||||
setImportError('Please paste or type JSON');
|
||||
return;
|
||||
}
|
||||
validateAndPreview(importText);
|
||||
}, [importText, validateAndPreview]);
|
||||
|
||||
const handleFilePick = useCallback(async () => {
|
||||
try {
|
||||
const text = await Clipboard.getStringAsync();
|
||||
if (!text?.trim()) {
|
||||
showError('Clipboard is empty');
|
||||
setImportLoading(true);
|
||||
setImportError(null);
|
||||
const result = await DocumentPicker.getDocumentAsync({
|
||||
type: 'application/json',
|
||||
copyToCacheDirectory: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
setImportLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await collectionsService.importFromJson(text);
|
||||
showInfo(`Imported: ${result.added} added, ${result.updated} updated`);
|
||||
const fileUri = result.assets[0].uri;
|
||||
const content = await readAsStringAsync(fileUri, { encoding: EncodingType.UTF8 });
|
||||
setImportText(content);
|
||||
validateAndPreview(content);
|
||||
} catch {
|
||||
showError('Invalid collection data');
|
||||
setImportError('Failed to read file');
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}, [showInfo, showError]);
|
||||
}, [validateAndPreview]);
|
||||
|
||||
const handleUrlFetch = useCallback(async () => {
|
||||
if (!importUrl.trim()) {
|
||||
setImportError('Please enter a URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setImportLoading(true);
|
||||
setImportError(null);
|
||||
const response = await fetch(importUrl.trim());
|
||||
if (!response.ok) {
|
||||
setImportError(`Failed to fetch: HTTP ${response.status}`);
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
setImportText(text);
|
||||
validateAndPreview(text);
|
||||
} catch {
|
||||
setImportError('Failed to fetch URL');
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}, [importUrl, validateAndPreview]);
|
||||
|
||||
const handleConfirmImport = useCallback(async () => {
|
||||
if (!pendingJson) return;
|
||||
try {
|
||||
const result = await collectionsService.importFromJson(pendingJson);
|
||||
showInfo(`Imported: ${result.added} added, ${result.updated} updated`);
|
||||
handleCloseImport();
|
||||
} catch {
|
||||
showError('Import failed');
|
||||
}
|
||||
}, [pendingJson, showInfo, showError, handleCloseImport]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||
|
|
@ -144,7 +257,7 @@ const CollectionManagementScreen = () => {
|
|||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={handleImport}
|
||||
onPress={handleOpenImport}
|
||||
>
|
||||
<MaterialIcons name="file-download" size={18} color={colors.text} />
|
||||
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
|
||||
|
|
@ -226,15 +339,164 @@ const CollectionManagementScreen = () => {
|
|||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: colors.elevation3 }]}
|
||||
activeOpacity={0.7}
|
||||
onPress={handleImport}
|
||||
onPress={handleOpenImport}
|
||||
>
|
||||
<MaterialIcons name="file-download" size={18} color={colors.text} />
|
||||
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import from Clipboard</Text>
|
||||
<Text style={[styles.actionButtonText, { color: colors.text }]}>Import</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Import Modal */}
|
||||
<Modal visible={showImportModal} animationType="slide" transparent>
|
||||
<View style={[styles.modalOverlay, { backgroundColor: 'rgba(0,0,0,0.7)' }]}>
|
||||
<View style={[styles.importModal, { backgroundColor: colors.darkBackground }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.importModalHeader}>
|
||||
<Text style={[styles.importModalTitle, { color: colors.text }]}>Import Collections</Text>
|
||||
<TouchableOpacity onPress={handleCloseImport}>
|
||||
<MaterialIcons name="close" size={24} color={colors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab Selector */}
|
||||
<View style={styles.importTabRow}>
|
||||
{([
|
||||
{ key: 'paste' as const, label: 'Paste JSON', icon: 'content-paste' as const },
|
||||
{ key: 'file' as const, label: 'Pick File', icon: 'folder-open' as const },
|
||||
{ key: 'url' as const, label: 'From URL', icon: 'link' as const },
|
||||
]).map(tab => (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={[
|
||||
styles.importTab,
|
||||
{
|
||||
backgroundColor: importTab === tab.key ? colors.primary : colors.elevation1,
|
||||
borderColor: importTab === tab.key ? colors.primary : colors.border,
|
||||
},
|
||||
]}
|
||||
onPress={() => { setImportTab(tab.key); setImportError(null); setImportPreview(null); setPendingJson(null); }}
|
||||
>
|
||||
<MaterialIcons name={tab.icon} size={16} color={importTab === tab.key ? '#fff' : colors.textMuted} />
|
||||
<Text style={[styles.importTabText, { color: importTab === tab.key ? '#fff' : colors.text }]}>{tab.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.importModalBody} contentContainerStyle={{ paddingBottom: 20 }}>
|
||||
{/* Paste Tab */}
|
||||
{importTab === 'paste' && (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
|
||||
onPress={handlePasteValidate}
|
||||
>
|
||||
<MaterialIcons name="content-paste" size={18} color={colors.primary} />
|
||||
<Text style={[styles.importActionBtnText, { color: colors.text }]}>Paste from Clipboard</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.importOrText, { color: colors.textMuted }]}>or type/paste below:</Text>
|
||||
<TextInput
|
||||
style={[styles.importTextInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: importError ? colors.error : colors.border }]}
|
||||
value={importText}
|
||||
onChangeText={(text) => { setImportText(text); setImportError(null); setImportPreview(null); setPendingJson(null); }}
|
||||
placeholder="Paste collections JSON here..."
|
||||
placeholderTextColor={colors.disabled}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{!importPreview && !importError && importText.trim().length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.validateBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
|
||||
onPress={handleTextValidate}
|
||||
>
|
||||
<Text style={[styles.validateBtnText, { color: colors.primary }]}>Validate</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* File Tab */}
|
||||
{importTab === 'file' && (
|
||||
<TouchableOpacity
|
||||
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border }]}
|
||||
onPress={handleFilePick}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<MaterialIcons name="folder-open" size={18} color={colors.primary} />
|
||||
)}
|
||||
<Text style={[styles.importActionBtnText, { color: colors.text }]}>
|
||||
{importLoading ? 'Reading file...' : 'Choose .json File'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* URL Tab */}
|
||||
{importTab === 'url' && (
|
||||
<View>
|
||||
<TextInput
|
||||
style={[styles.importUrlInput, { backgroundColor: colors.elevation1, color: colors.text, borderColor: importError ? colors.error : colors.border }]}
|
||||
value={importUrl}
|
||||
onChangeText={(text) => { setImportUrl(text); setImportError(null); setImportPreview(null); setPendingJson(null); }}
|
||||
placeholder="https://example.com/collections.json"
|
||||
placeholderTextColor={colors.disabled}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.importActionBtn, { backgroundColor: colors.elevation1, borderColor: colors.border, marginTop: 10 }]}
|
||||
onPress={handleUrlFetch}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading ? (
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
) : (
|
||||
<MaterialIcons name="download" size={18} color={colors.primary} />
|
||||
)}
|
||||
<Text style={[styles.importActionBtnText, { color: colors.text }]}>
|
||||
{importLoading ? 'Fetching...' : 'Fetch & Validate'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{importError && (
|
||||
<View style={[styles.importResultBox, { backgroundColor: colors.error + '15', borderColor: colors.error }]}>
|
||||
<MaterialIcons name="error-outline" size={18} color={colors.error} />
|
||||
<Text style={[styles.importResultText, { color: colors.error }]}>{importError}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{importPreview && (
|
||||
<View style={[styles.importResultBox, { backgroundColor: colors.primary + '15', borderColor: colors.primary }]}>
|
||||
<MaterialIcons name="check-circle" size={18} color={colors.primary} />
|
||||
<Text style={[styles.importResultText, { color: colors.text }]}>
|
||||
Valid: {importPreview.collectionCount} collection{importPreview.collectionCount !== 1 ? 's' : ''}, {importPreview.folderCount} folder{importPreview.folderCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Import Button */}
|
||||
{importPreview && pendingJson && (
|
||||
<TouchableOpacity
|
||||
style={[styles.confirmImportBtn, { backgroundColor: colors.primary }]}
|
||||
onPress={handleConfirmImport}
|
||||
>
|
||||
<MaterialIcons name="file-download" size={20} color="#fff" />
|
||||
<Text style={styles.confirmImportBtnText}>Import {importPreview.collectionCount} Collection{importPreview.collectionCount !== 1 ? 's' : ''}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
|
|
@ -341,6 +603,120 @@ const styles = StyleSheet.create({
|
|||
disabledButton: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
importModal: {
|
||||
width: '92%',
|
||||
maxHeight: '85%',
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
},
|
||||
importModalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
importModalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
importTabRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
importTab: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
importTabText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
importModalBody: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
importActionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
},
|
||||
importActionBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
importOrText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 12,
|
||||
marginVertical: 10,
|
||||
},
|
||||
importTextInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
fontSize: 13,
|
||||
minHeight: 150,
|
||||
maxHeight: 250,
|
||||
},
|
||||
importUrlInput: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
validateBtn: {
|
||||
alignSelf: 'flex-start',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginTop: 10,
|
||||
},
|
||||
validateBtnText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
importResultBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
marginTop: 12,
|
||||
},
|
||||
importResultText: {
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
},
|
||||
confirmImportBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
marginTop: 12,
|
||||
},
|
||||
confirmImportBtnText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default CollectionManagementScreen;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,57 @@ class CollectionsService {
|
|||
}
|
||||
}
|
||||
|
||||
validateCollectionsJson(json: string): { valid: boolean; error?: string; collectionCount: number; folderCount: number } {
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return { valid: false, error: 'Invalid format: expected an array of collections', collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return { valid: false, error: 'Empty array: no collections found', collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
let folderCount = 0;
|
||||
const validTileShapes = ['poster', 'wide', 'square', 'POSTER', 'LANDSCAPE', 'SQUARE'];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
if (!item || typeof item.id !== 'string' || !item.id.trim()) {
|
||||
return { valid: false, error: `Collection ${i + 1}: missing or invalid "id"`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (typeof item.title !== 'string') {
|
||||
return { valid: false, error: `Collection "${item.id}": missing or invalid "title"`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (!Array.isArray(item.folders)) {
|
||||
return { valid: false, error: `Collection "${item.title || item.id}": "folders" must be an array`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
for (let j = 0; j < item.folders.length; j++) {
|
||||
const folder = item.folders[j];
|
||||
if (!folder || typeof folder.id !== 'string' || !folder.id.trim()) {
|
||||
return { valid: false, error: `Collection "${item.title}", folder ${j + 1}: missing or invalid "id"`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (typeof folder.title !== 'string') {
|
||||
return { valid: false, error: `Collection "${item.title}", folder "${folder.id}": missing or invalid "title"`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (!Array.isArray(folder.catalogSources)) {
|
||||
return { valid: false, error: `Collection "${item.title}", folder "${folder.title || folder.id}": "catalogSources" must be an array`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
if (folder.tileShape && !validTileShapes.includes(folder.tileShape)) {
|
||||
return { valid: false, error: `Collection "${item.title}", folder "${folder.title}": invalid tileShape "${folder.tileShape}"`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
for (let k = 0; k < folder.catalogSources.length; k++) {
|
||||
const source = folder.catalogSources[k];
|
||||
if (!source || typeof source.addonId !== 'string' || typeof source.type !== 'string' || typeof source.catalogId !== 'string') {
|
||||
return { valid: false, error: `Collection "${item.title}", folder "${folder.title}", source ${k + 1}: missing required fields (addonId, type, catalogId)`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
}
|
||||
folderCount++;
|
||||
}
|
||||
}
|
||||
return { valid: true, collectionCount: parsed.length, folderCount };
|
||||
} catch (e: any) {
|
||||
return { valid: false, error: `JSON parse error: ${e.message}`, collectionCount: 0, folderCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue