diff --git a/plugins/manager.go b/plugins/manager.go index 573906ca1..55d699113 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -14,6 +14,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "slices" "strings" "sync" @@ -116,6 +117,13 @@ func GetManager() *Manager { }) } +// IsConfigured returns true if the manager has been configured with a DataStore. +// This is useful for API handlers to know if they should use the manager or fall back +// to direct DB operations (e.g., in test environments). +func (m *Manager) IsConfigured() bool { + return m.ds != nil +} + // adminContext returns a context with admin privileges for DB operations. func adminContext(ctx context.Context) context.Context { return request.WithUser(ctx, model.User{IsAdmin: true}) @@ -941,6 +949,7 @@ func (m *Manager) UnloadPlugin(name string) error { } } + runtime.GC() log.Info(m.ctx, "Unloaded plugin", "plugin", name) return nil } diff --git a/server/nativeapi/plugin.go b/server/nativeapi/plugin.go index 1636ce44c..243e692b2 100644 --- a/server/nativeapi/plugin.go +++ b/server/nativeapi/plugin.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins" "github.com/navidrome/navidrome/server" ) @@ -49,9 +50,11 @@ type PluginUpdateRequest struct { func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") - repo := api.ds.Plugin(r.Context()) + ctx := r.Context() + repo := api.ds.Plugin(ctx) + manager := plugins.GetManager() - // Get existing plugin + // Get existing plugin to verify it exists plugin, err := repo.Get(id) if err != nil { if errors.Is(err, rest.ErrPermissionDenied) { @@ -62,7 +65,7 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { http.Error(w, "Plugin not found", http.StatusNotFound) return } - log.Error(r.Context(), "Error getting plugin", "id", id, err) + log.Error(ctx, "Error getting plugin", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -70,38 +73,82 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) { // Parse update request var req PluginUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - log.Error(r.Context(), "Error decoding request", err) + log.Error(ctx, "Error decoding request", err) http.Error(w, "Invalid request body", http.StatusBadRequest) return } - // Apply updates - if req.Enabled != nil { - plugin.Enabled = *req.Enabled - } - if req.Config != nil { - // Validate JSON if not empty - if *req.Config != "" && !isValidJSON(*req.Config) { - http.Error(w, "Invalid JSON in config field", http.StatusBadRequest) + // If manager is configured, use it to properly load/unload plugins + // Otherwise, fall back to direct DB operations (e.g., in tests) + if manager.IsConfigured() { + // Handle config update first (if provided) + if req.Config != nil { + // Validate JSON if not empty + if *req.Config != "" && !isValidJSON(*req.Config) { + http.Error(w, "Invalid JSON in config field", http.StatusBadRequest) + return + } + if err := manager.UpdatePluginConfig(ctx, id, *req.Config); err != nil { + log.Error(ctx, "Error updating plugin config", "id", id, err) + http.Error(w, "Error updating plugin configuration: "+err.Error(), http.StatusInternalServerError) + return + } + } + + // Handle enable/disable + if req.Enabled != nil { + if *req.Enabled { + if err := manager.EnablePlugin(ctx, id); err != nil { + log.Error(ctx, "Error enabling plugin", "id", id, err) + // Refresh plugin from DB to get the error + plugin, _ = repo.Get(id) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(plugin) + return + } + } else { + if err := manager.DisablePlugin(ctx, id); err != nil { + log.Error(ctx, "Error disabling plugin", "id", id, err) + http.Error(w, "Error disabling plugin: "+err.Error(), http.StatusInternalServerError) + return + } + } + } + } else { + // Fallback: direct DB operations (for tests or when manager is not started) + if req.Config != nil { + if *req.Config != "" && !isValidJSON(*req.Config) { + http.Error(w, "Invalid JSON in config field", http.StatusBadRequest) + return + } + plugin.Config = *req.Config + } + if req.Enabled != nil { + plugin.Enabled = *req.Enabled + } + if err := repo.Put(plugin); err != nil { + if errors.Is(err, rest.ErrPermissionDenied) { + http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) + return + } + log.Error(ctx, "Error updating plugin", "id", id, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) return } - plugin.Config = *req.Config } - // Save - if err := repo.Put(plugin); err != nil { - if errors.Is(err, rest.ErrPermissionDenied) { - http.Error(w, "Access denied: admin privileges required", http.StatusForbidden) - return - } - log.Error(r.Context(), "Error updating plugin", "id", id, err) + // Refresh and return updated plugin + plugin, err = repo.Get(id) + if err != nil { + log.Error(ctx, "Error getting updated plugin", "id", id, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(plugin); err != nil { - log.Error(r.Context(), "Error encoding plugin response", err) + log.Error(ctx, "Error encoding plugin response", err) } } diff --git a/server/serve_index.go b/server/serve_index.go index 38e646982..d70bf1d84 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -74,6 +74,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, "separator": string(os.PathSeparator), "enableInspect": conf.Server.Inspect.Enabled, + "pluginsEnabled": conf.Server.Plugins.Enabled, } if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index dc4fe9b53..2dbe72421 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -16,6 +16,7 @@ import playlist from './playlist' import radio from './radio' import share from './share' import library from './library' +import plugin from './plugin' import { Player } from './audioplayer' import customRoutes from './routes' import { @@ -139,6 +140,13 @@ const Admin = (props) => { options={{ subMenu: 'settings' }} /> ) : null, + permissions === 'admin' && config.pluginsEnabled ? ( + + ) : null, , , diff --git a/ui/src/config.js b/ui/src/config.js index a53a97de7..9582e95ee 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -38,6 +38,7 @@ const defaultConfig = { publicBaseUrl: '/share', separator: '/', enableInspect: true, + pluginsEnabled: true, } let config diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 9ef65d668..1f6086a20 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -330,6 +330,47 @@ "scanInProgress": "Scan in progress...", "noLibrariesAssigned": "No libraries assigned to this user" } + }, + "plugin": { + "name": "Plugin |||| Plugins", + "fields": { + "name": "Name", + "description": "Description", + "version": "Version", + "enabled": "Enabled", + "status": "Status", + "path": "Path", + "lastError": "Error", + "hasError": "Error", + "updatedAt": "Updated", + "createdAt": "Installed" + }, + "sections": { + "status": "Status", + "info": "Plugin Information", + "configuration": "Configuration", + "manifest": "Manifest" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "actions": { + "enable": "Enable", + "disable": "Disable" + }, + "notifications": { + "enabled": "Plugin enabled", + "disabled": "Plugin disabled", + "updated": "Plugin updated", + "error": "Error updating plugin" + }, + "validation": { + "invalidJson": "Configuration must be valid JSON" + }, + "messages": { + "configHelp": "Enter plugin configuration as a JSON object. Leave empty if the plugin requires no configuration." + } } }, "ra": { diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx new file mode 100644 index 000000000..9afafd53a --- /dev/null +++ b/ui/src/plugin/PluginList.jsx @@ -0,0 +1,223 @@ +import React, { useMemo, useCallback } from 'react' +import { + Datagrid, + TextField, + useUpdate, + useNotify, + useRefresh, + useRecordContext, + useTranslate, + FunctionField, +} from 'react-admin' +import Switch from '@material-ui/core/Switch' +import { makeStyles } from '@material-ui/core/styles' +import { useMediaQuery, Tooltip, Chip } from '@material-ui/core' +import { MdError } from 'react-icons/md' +import { List, DateField, SimpleList } from '../common' + +const useStyles = makeStyles((theme) => ({ + errorIcon: { + color: theme.palette.error.main, + marginRight: theme.spacing(0.5), + verticalAlign: 'middle', + }, + errorChip: { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.contrastText, + }, + enabledSwitch: { + '& .MuiSwitch-colorSecondary.Mui-checked': { + color: theme.palette.success?.main || theme.palette.primary.main, + }, + '& .MuiSwitch-colorSecondary.Mui-checked + .MuiSwitch-track': { + backgroundColor: + theme.palette.success?.main || theme.palette.primary.main, + }, + }, +})) + +const ToggleEnabledInput = ({ resource }) => { + const record = useRecordContext() + const notify = useNotify() + const refresh = useRefresh() + const translate = useTranslate() + const classes = useStyles() + + const [toggleEnabled, { loading }] = useUpdate( + resource, + record.id, + { enabled: !record.enabled }, + record, + { + undoable: false, + onSuccess: () => { + refresh() + notify( + record.enabled + ? 'resources.plugin.notifications.disabled' + : 'resources.plugin.notifications.enabled', + 'info', + ) + }, + onFailure: (error) => { + notify( + error?.message || 'resources.plugin.notifications.error', + 'warning', + ) + }, + }, + ) + + const handleClick = useCallback( + (e) => { + e.stopPropagation() + toggleEnabled() + }, + [toggleEnabled], + ) + + return ( + + + + + + ) +} + +const ErrorIndicator = () => { + const record = useRecordContext() + const translate = useTranslate() + const classes = useStyles() + + if (!record.lastError) { + return null + } + + return ( + + } + label={translate('resources.plugin.fields.hasError')} + className={classes.errorChip} + /> + + ) +} + +const VersionField = () => { + const record = useRecordContext() + + if (!record.manifest) { + return null + } + + try { + const manifest = JSON.parse(record.manifest) + return {manifest.version || '-'} + } catch { + return - + } +} + +const DescriptionField = () => { + const record = useRecordContext() + + if (!record.manifest) { + return null + } + + try { + const manifest = JSON.parse(record.manifest) + return {manifest.description || '-'} + } catch { + return - + } +} + +const PluginList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const translate = useTranslate() + + const toggleableFields = useMemo( + () => ({ + description: !isXsmall && ( + } + /> + ), + version: ( + } + /> + ), + enabled: ( + } + /> + ), + error: ( + } + /> + ), + }), + [isXsmall], + ) + + return ( + + {isXsmall ? ( + record.id} + secondaryText={(record) => { + try { + const manifest = JSON.parse(record.manifest) + return manifest.description || '' + } catch { + return '' + } + }} + tertiaryText={(record) => + record.enabled + ? translate('resources.plugin.status.enabled') + : translate('resources.plugin.status.disabled') + } + linkType="show" + /> + ) : ( + + + {toggleableFields['description']} + {toggleableFields['version']} + {toggleableFields['enabled']} + {toggleableFields['error']} + + + )} + + ) +} + +export default PluginList diff --git a/ui/src/plugin/PluginList.test.jsx b/ui/src/plugin/PluginList.test.jsx new file mode 100644 index 000000000..be61d4609 --- /dev/null +++ b/ui/src/plugin/PluginList.test.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock react-admin hooks +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useUpdate: vi.fn(() => [vi.fn(), { loading: false }]), + useNotify: vi.fn(() => vi.fn()), + useRefresh: vi.fn(() => vi.fn()), + useTranslate: vi.fn(() => (key) => key), + useRecordContext: vi.fn(() => ({ + id: 'test-plugin', + manifest: JSON.stringify({ + version: '1.0.0', + description: 'Test plugin', + }), + enabled: true, + lastError: null, + })), + FunctionField: ({ render, label }) => ( + {render && render()} + ), + Datagrid: ({ children }) => ( + {children}
+ ), + TextField: ({ source }) => , + } +}) + +// Mock common components +vi.mock('../common', async () => { + return { + List: ({ children, ...props }) => ( +
+ {children} +
+ ), + DateField: ({ source }) => , + SimpleList: ({ primaryText, secondaryText }) => ( +
+ ), + } +}) + +// Mock Material-UI +vi.mock('@material-ui/core', async () => { + const actual = await vi.importActual('@material-ui/core') + return { + ...actual, + useMediaQuery: vi.fn(() => false), + } +}) + +import PluginList from './PluginList' + +describe('PluginList', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the list component', () => { + render() + expect(screen.getByTestId('list')).toBeInTheDocument() + }) + + it('renders the datagrid on desktop', () => { + render() + expect(screen.getByTestId('datagrid')).toBeInTheDocument() + }) +}) diff --git a/ui/src/plugin/PluginShow.jsx b/ui/src/plugin/PluginShow.jsx new file mode 100644 index 000000000..f683aab60 --- /dev/null +++ b/ui/src/plugin/PluginShow.jsx @@ -0,0 +1,349 @@ +import React, { useState, useCallback } from 'react' +import { + Show, + SimpleShowLayout, + TextField, + useTranslate, + useUpdate, + useNotify, + useRefresh, + useRecordContext, + Toolbar, + SaveButton, +} from 'react-admin' +import { + Typography, + Box, + Switch, + FormControlLabel, + Card, + CardContent, + TextField as MuiTextField, + Accordion, + AccordionSummary, + AccordionDetails, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { MdExpandMore, MdError, MdCheckCircle } from 'react-icons/md' +import { Title, DateField } from '../common' +import { validateJson } from './jsonValidation' + +const useStyles = makeStyles((theme) => ({ + root: { + padding: theme.spacing(2), + maxWidth: 900, + }, + section: { + marginBottom: theme.spacing(3), + }, + sectionTitle: { + marginBottom: theme.spacing(1), + fontWeight: 600, + }, + errorBox: { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.contrastText, + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + marginBottom: theme.spacing(2), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1), + }, + errorIcon: { + marginTop: 2, + }, + manifestBox: { + backgroundColor: + theme.palette.type === 'dark' + ? theme.palette.grey[900] + : theme.palette.grey[100], + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + fontFamily: 'monospace', + fontSize: '0.85rem', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + overflow: 'auto', + maxHeight: 400, + }, + configInput: { + fontFamily: 'monospace', + fontSize: '0.85rem', + }, + statusEnabled: { + color: theme.palette.success?.main || theme.palette.primary.main, + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + }, + statusDisabled: { + color: theme.palette.text.secondary, + }, + toolbar: { + display: 'flex', + justifyContent: 'flex-start', + paddingLeft: 0, + paddingRight: 0, + marginTop: theme.spacing(2), + }, + infoGrid: { + display: 'grid', + gridTemplateColumns: 'auto 1fr', + gap: theme.spacing(1, 2), + '& dt': { + fontWeight: 500, + color: theme.palette.text.secondary, + }, + '& dd': { + margin: 0, + }, + }, + pathField: { + fontFamily: 'monospace', + fontSize: '0.85rem', + wordBreak: 'break-all', + }, +})) + +const PluginTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.plugin.name', { smart_count: 1 }) + return ( + + ) +} + +const PluginShowContent = () => { + const record = useRecordContext() + const classes = useStyles() + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + + const [config, setConfig] = useState(record?.config || '') + const [configError, setConfigError] = useState(null) + const [isDirty, setIsDirty] = useState(false) + + const [updatePlugin, { loading }] = useUpdate( + 'plugin', + record?.id, + {}, + record, + { + undoable: false, + onSuccess: () => { + refresh() + setIsDirty(false) + notify('resources.plugin.notifications.updated', 'info') + }, + onFailure: (error) => { + notify( + error?.message || 'resources.plugin.notifications.error', + 'warning', + ) + }, + }, + ) + + const handleToggleEnabled = useCallback(() => { + updatePlugin('plugin', record.id, { enabled: !record.enabled }, record) + }, [updatePlugin, record]) + + const handleConfigChange = useCallback( + (e) => { + const value = e.target.value + setConfig(value) + setIsDirty(value !== (record?.config || '')) + + if (value === '') { + setConfigError(null) + } else { + const validation = validateJson(value) + setConfigError(validation.error) + } + }, + [record?.config], + ) + + const handleSaveConfig = useCallback(() => { + if (configError) { + notify('resources.plugin.validation.invalidJson', 'warning') + return + } + updatePlugin('plugin', record.id, { config }, record) + }, [updatePlugin, record, config, configError, notify]) + + if (!record) { + return null + } + + let manifest = null + let manifestJson = '' + try { + manifest = JSON.parse(record.manifest) + manifestJson = JSON.stringify(manifest, null, 2) + } catch { + manifestJson = record.manifest + } + + return ( + <Box className={classes.root}> + {/* Error Section */} + {record.lastError && ( + <Box className={classes.errorBox}> + <MdError size={20} className={classes.errorIcon} /> + <Box> + <Typography variant="subtitle2"> + {translate('resources.plugin.fields.lastError')} + </Typography> + <Typography variant="body2">{record.lastError}</Typography> + </Box> + </Box> + )} + + {/* Status and Enable/Disable */} + <Card className={classes.section}> + <CardContent> + <Typography variant="h6" className={classes.sectionTitle}> + {translate('resources.plugin.sections.status')} + </Typography> + <Box + display="flex" + alignItems="center" + justifyContent="space-between" + > + <Box> + {record.enabled ? ( + <Typography className={classes.statusEnabled}> + <MdCheckCircle /> + {translate('resources.plugin.status.enabled')} + </Typography> + ) : ( + <Typography className={classes.statusDisabled}> + {translate('resources.plugin.status.disabled')} + </Typography> + )} + </Box> + <FormControlLabel + control={ + <Switch + checked={record.enabled} + onChange={handleToggleEnabled} + disabled={loading} + color="primary" + /> + } + label={translate( + record.enabled + ? 'resources.plugin.actions.disable' + : 'resources.plugin.actions.enable', + )} + labelPlacement="start" + /> + </Box> + </CardContent> + </Card> + + {/* Plugin Info */} + <Card className={classes.section}> + <CardContent> + <Typography variant="h6" className={classes.sectionTitle}> + {translate('resources.plugin.sections.info')} + </Typography> + <dl className={classes.infoGrid}> + <dt>{translate('resources.plugin.fields.name')}</dt> + <dd>{record.id}</dd> + + {manifest?.version && ( + <> + <dt>{translate('resources.plugin.fields.version')}</dt> + <dd>{manifest.version}</dd> + </> + )} + + {manifest?.description && ( + <> + <dt>{translate('resources.plugin.fields.description')}</dt> + <dd>{manifest.description}</dd> + </> + )} + + <dt>{translate('resources.plugin.fields.path')}</dt> + <dd className={classes.pathField}>{record.path}</dd> + + <dt>{translate('resources.plugin.fields.updatedAt')}</dt> + <dd> + <DateField record={record} source="updatedAt" showTime /> + </dd> + + <dt>{translate('resources.plugin.fields.createdAt')}</dt> + <dd> + <DateField record={record} source="createdAt" showTime /> + </dd> + </dl> + </CardContent> + </Card> + + {/* Configuration */} + <Card className={classes.section}> + <CardContent> + <Typography variant="h6" className={classes.sectionTitle}> + {translate('resources.plugin.sections.configuration')} + </Typography> + <Typography variant="body2" color="textSecondary" gutterBottom> + {translate('resources.plugin.messages.configHelp')} + </Typography> + <MuiTextField + multiline + fullWidth + minRows={4} + maxRows={15} + variant="outlined" + value={config} + onChange={handleConfigChange} + error={!!configError} + helperText={configError} + placeholder="{}" + InputProps={{ + className: classes.configInput, + }} + /> + <Toolbar className={classes.toolbar}> + <SaveButton + handleSubmitWithRedirect={handleSaveConfig} + disabled={!isDirty || !!configError || loading} + saving={loading} + /> + </Toolbar> + </CardContent> + </Card> + + {/* Manifest */} + <Accordion> + <AccordionSummary expandIcon={<MdExpandMore />}> + <Typography variant="h6"> + {translate('resources.plugin.sections.manifest')} + </Typography> + </AccordionSummary> + <AccordionDetails> + <Box className={classes.manifestBox} width="100%"> + {manifestJson} + </Box> + </AccordionDetails> + </Accordion> + </Box> + ) +} + +const PluginShow = (props) => { + return ( + <Show title={<PluginTitle />} actions={false} {...props}> + <SimpleShowLayout> + <PluginShowContent /> + </SimpleShowLayout> + </Show> + ) +} + +export default PluginShow diff --git a/ui/src/plugin/index.js b/ui/src/plugin/index.js new file mode 100644 index 000000000..2385308cc --- /dev/null +++ b/ui/src/plugin/index.js @@ -0,0 +1,9 @@ +import { VscExtensions } from 'react-icons/vsc' +import PluginList from './PluginList' +import PluginShow from './PluginShow' + +export default { + icon: VscExtensions, + list: PluginList, + show: PluginShow, +} diff --git a/ui/src/plugin/jsonValidation.js b/ui/src/plugin/jsonValidation.js new file mode 100644 index 000000000..408d6dac5 --- /dev/null +++ b/ui/src/plugin/jsonValidation.js @@ -0,0 +1,68 @@ +/** + * Validates a JSON string and returns validation result + * @param {string} value - The JSON string to validate + * @returns {{ valid: boolean, error: string|null, parsed: object|null }} + */ +export const validateJson = (value) => { + if (!value || value.trim() === '') { + return { valid: true, error: null, parsed: null } + } + + try { + const parsed = JSON.parse(value) + // Ensure config is an object, not an array or primitive + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + return { + valid: false, + error: 'Configuration must be a JSON object', + parsed: null, + } + } + return { valid: true, error: null, parsed } + } catch (e) { + // Try to provide helpful error messages + let error = 'Invalid JSON' + + if (e instanceof SyntaxError) { + const message = e.message + + // Extract position information if available + const positionMatch = message.match(/position (\d+)/) + if (positionMatch) { + const position = parseInt(positionMatch[1], 10) + const lines = value.substring(0, position).split('\n') + const line = lines.length + const column = lines[lines.length - 1].length + 1 + error = `Invalid JSON at line ${line}, column ${column}` + } else if (message.includes('Unexpected end of JSON')) { + error = 'Incomplete JSON - check for missing brackets or quotes' + } else if (message.includes('Unexpected token')) { + error = 'Invalid JSON - unexpected character found' + } + } + + return { valid: false, error, parsed: null } + } +} + +/** + * Formats JSON string with proper indentation + * @param {string} value - The JSON string to format + * @returns {string} - Formatted JSON string or original if invalid + */ +export const formatJson = (value) => { + if (!value || value.trim() === '') { + return value + } + + try { + const parsed = JSON.parse(value) + return JSON.stringify(parsed, null, 2) + } catch { + return value + } +} diff --git a/ui/src/plugin/jsonValidation.test.js b/ui/src/plugin/jsonValidation.test.js new file mode 100644 index 000000000..f56549d79 --- /dev/null +++ b/ui/src/plugin/jsonValidation.test.js @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest' +import { validateJson, formatJson } from './jsonValidation' + +describe('validateJson', () => { + it('returns valid for empty string', () => { + const result = validateJson('') + expect(result.valid).toBe(true) + expect(result.error).toBeNull() + expect(result.parsed).toBeNull() + }) + + it('returns valid for whitespace only', () => { + const result = validateJson(' ') + expect(result.valid).toBe(true) + expect(result.error).toBeNull() + }) + + it('returns valid for valid JSON object', () => { + const result = validateJson('{"key": "value"}') + expect(result.valid).toBe(true) + expect(result.error).toBeNull() + expect(result.parsed).toEqual({ key: 'value' }) + }) + + it('returns valid for nested JSON object', () => { + const result = validateJson('{"outer": {"inner": 123}}') + expect(result.valid).toBe(true) + expect(result.parsed).toEqual({ outer: { inner: 123 } }) + }) + + it('returns invalid for JSON array', () => { + const result = validateJson('[1, 2, 3]') + expect(result.valid).toBe(false) + expect(result.error).toBe('Configuration must be a JSON object') + }) + + it('returns invalid for JSON primitive string', () => { + const result = validateJson('"hello"') + expect(result.valid).toBe(false) + expect(result.error).toBe('Configuration must be a JSON object') + }) + + it('returns invalid for JSON primitive number', () => { + const result = validateJson('42') + expect(result.valid).toBe(false) + expect(result.error).toBe('Configuration must be a JSON object') + }) + + it('returns invalid for JSON null', () => { + const result = validateJson('null') + expect(result.valid).toBe(false) + expect(result.error).toBe('Configuration must be a JSON object') + }) + + it('returns invalid for malformed JSON', () => { + const result = validateJson('{"key": }') + expect(result.valid).toBe(false) + expect(result.error).toContain('Invalid JSON') + }) + + it('returns invalid for incomplete JSON', () => { + const result = validateJson('{"key": "value"') + expect(result.valid).toBe(false) + expect(result.error).toContain('Invalid JSON') + }) + + it('returns invalid for JSON with trailing comma', () => { + const result = validateJson('{"key": "value",}') + expect(result.valid).toBe(false) + expect(result.error).toContain('Invalid JSON') + }) +}) + +describe('formatJson', () => { + it('returns empty string unchanged', () => { + expect(formatJson('')).toBe('') + }) + + it('returns whitespace unchanged', () => { + expect(formatJson(' ')).toBe(' ') + }) + + it('formats compact JSON with indentation', () => { + const result = formatJson('{"key":"value"}') + expect(result).toBe('{\n "key": "value"\n}') + }) + + it('formats nested JSON with proper indentation', () => { + const result = formatJson('{"outer":{"inner":123}}') + expect(result).toBe('{\n "outer": {\n "inner": 123\n }\n}') + }) + + it('returns invalid JSON unchanged', () => { + const invalid = '{"key": }' + expect(formatJson(invalid)).toBe(invalid) + }) +})