diff --git a/components/admin/admin-client.tsx b/components/admin/admin-client.tsx index a017c4c..48704e4 100644 --- a/components/admin/admin-client.tsx +++ b/components/admin/admin-client.tsx @@ -43,7 +43,8 @@ import { DownloadIcon } from '@/components/ui/animated-icons/download'; import { FilePenLineIcon } from '@/components/ui/animated-icons/file-pen-line'; import { UploadIcon } from '@/components/ui/animated-icons/upload'; import { importConfig } from '@/lib/actions'; -import { DEFAULT_APP_TITLE, type Category, type Service, type Preferences, type IconConfig } from '@/lib/types'; +import { createServiceDuplicateDraft } from '@/lib/service-duplicate'; +import { DEFAULT_APP_TITLE, type Category, type Service, type Preferences, type IconConfig, type ServiceFormData } from '@/lib/types'; import { AppSettingsCard } from '@/components/admin/app-settings/app-settings-card'; import { ConfigEditorDialog } from '@/components/admin/config-editor/config-editor-dialog'; import { PageFooter } from '@/components/layout/footer/page-footer'; @@ -72,6 +73,7 @@ export function AdminClient({ appTitle, appLogo, categories: initialCategories, const [serviceModalOpen, setServiceModalOpen] = useState(false); const [editingCategory, setEditingCategory] = useState(); const [editingService, setEditingService] = useState(); + const [serviceDraft, setServiceDraft] = useState(); const [searchQuery, setSearchQuery] = useState(''); const [refreshKey, setRefreshKey] = useState(0); const searchInputRef = useRef(null); @@ -159,11 +161,19 @@ export function AdminClient({ appTitle, appLogo, categories: initialCategories, const handleAddService = () => { setEditingService(undefined); + setServiceDraft(undefined); setServiceModalOpen(true); }; const handleEditService = (service: Service) => { setEditingService(service); + setServiceDraft(undefined); + setServiceModalOpen(true); + }; + + const handleDuplicateService = (service: Service) => { + setEditingService(undefined); + setServiceDraft(createServiceDuplicateDraft(service, services)); setServiceModalOpen(true); }; @@ -178,6 +188,7 @@ export function AdminClient({ appTitle, appLogo, categories: initialCategories, setServiceModalOpen(open); if (!open) { setEditingService(undefined); + setServiceDraft(undefined); } }; @@ -422,6 +433,7 @@ export function AdminClient({ appTitle, appLogo, categories: initialCategories, services={filteredData.services} categories={categories} onEdit={handleEditService} + onDuplicate={handleDuplicateService} onDeleted={handleRefresh} cacheKey={refreshKey} /> @@ -442,6 +454,8 @@ export function AdminClient({ appTitle, appLogo, categories: initialCategories, open={serviceModalOpen} onOpenChange={handleServiceModalClose} service={editingService} + initialValues={serviceDraft} + mode={editingService ? 'edit' : serviceDraft ? 'duplicate' : 'create'} categories={categories} onSuccess={handleRefresh} cacheKey={refreshKey} diff --git a/components/admin/services/service-form-modal.tsx b/components/admin/services/service-form-modal.tsx index 12af0db..434ef5e 100644 --- a/components/admin/services/service-form-modal.tsx +++ b/components/admin/services/service-form-modal.tsx @@ -7,12 +7,14 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { ServiceForm } from './service-form'; -import type { Category, Service } from '@/lib/types'; +import type { Category, Service, ServiceFormData } from '@/lib/types'; interface ServiceFormModalProps { open: boolean; onOpenChange: (open: boolean) => void; service?: Service; + initialValues?: ServiceFormData; + mode?: 'create' | 'duplicate' | 'edit'; categories: Category[]; onSuccess: () => void; cacheKey?: number; @@ -22,6 +24,8 @@ export function ServiceFormModal({ open, onOpenChange, service, + initialValues, + mode, categories, onSuccess, cacheKey, @@ -30,15 +34,28 @@ export function ServiceFormModal({ onSuccess(); onOpenChange(false); }; + const resolvedMode = mode ?? (service ? 'edit' : 'create'); + const formKey = service + ? `edit:${service.id}` + : initialValues + ? `draft:${initialValues.name}:${initialValues.url}:${initialValues.categoryId}` + : 'create'; + const title = resolvedMode === 'edit' + ? 'Edit Service' + : resolvedMode === 'duplicate' + ? 'Duplicate Service' + : 'Add Service'; return ( - {service ? 'Edit Service' : 'Add Service'} + {title} onOpenChange(false)} diff --git a/components/admin/services/service-form.tsx b/components/admin/services/service-form.tsx index 289a8e9..10dc183 100644 --- a/components/admin/services/service-form.tsx +++ b/components/admin/services/service-form.tsx @@ -26,10 +26,11 @@ import { type ServiceMetadataApplyMode, } from '@/lib/service-metadata-apply'; import { cn, slugify } from '@/lib/utils'; -import { ICON_TYPES, type Category, type IconConfig, type Service } from '@/lib/types'; +import { ICON_TYPES, type Category, type IconConfig, type Service, type ServiceFormData } from '@/lib/types'; interface ServiceFormProps { service?: Service; + initialValues?: ServiceFormData; categories: Category[]; onSuccess?: () => void; onCancel?: () => void; @@ -73,25 +74,29 @@ function getIconFetchId(serviceId: string, serviceUrl: string): string { } } -export function ServiceForm({ service, categories, onSuccess, onCancel, cacheKey }: ServiceFormProps) { - const [name, setName] = useState(service?.name || ''); - const [description, setDescription] = useState(service?.description || ''); - const [url, setUrl] = useState(service?.url || ''); - const [categoryId, setCategoryId] = useState(service?.categoryId || ''); - const [icon, setIcon] = useState(service?.icon); +export function ServiceForm({ service, initialValues, categories, onSuccess, onCancel, cacheKey }: ServiceFormProps) { + const formValues = service ?? initialValues; + const hasSeededValues = !!formValues; + const [name, setName] = useState(formValues?.name || ''); + const [description, setDescription] = useState(formValues?.description || ''); + const [url, setUrl] = useState(formValues?.url || ''); + const [categoryId, setCategoryId] = useState(formValues?.categoryId || ''); + const [icon, setIcon] = useState(formValues?.icon); const [pendingIconFile, setPendingIconFile] = useState(null); - const [active, setActive] = useState(service?.active ?? true); + const [active, setActive] = useState(formValues?.active ?? true); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [isFetchingIcon, setIsFetchingIcon] = useState(false); const [isFetchingMetadata, setIsFetchingMetadata] = useState(false); const [iconVersion, setIconVersion] = useState(0); - const [iconControl, setIconControl] = useState<'auto' | 'manual'>(service ? 'manual' : 'auto'); + const [iconControl, setIconControl] = useState<'auto' | 'manual'>( + hasSeededValues ? 'manual' : 'auto' + ); const urlRef = useRef(url); const nameRef = useRef(name); const descriptionRef = useRef(description); - const hasUserEditedNameRef = useRef(!!service); - const hasUserEditedDescriptionRef = useRef(!!service); + const hasUserEditedNameRef = useRef(hasSeededValues); + const hasUserEditedDescriptionRef = useRef(hasSeededValues); const lastAutoFetchKeyRef = useRef(null); const lastAutoMetadataFetchKeyRef = useRef(null); const autoFetchRequestRef = useRef(0); diff --git a/components/admin/services/service-list.tsx b/components/admin/services/service-list.tsx index 70ef1b1..469403c 100644 --- a/components/admin/services/service-list.tsx +++ b/components/admin/services/service-list.tsx @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'; import { CornerRibbon } from '@/components/ui/corner-ribbon'; import { CategoryIcon } from '@/components/common/icons/category-icon'; import { SortableList, SortableItem } from '@/components/ui/sortable'; -import { Pencil, Trash2, ExternalLink } from 'lucide-react'; +import { ExternalLink, Pencil, Trash2 } from 'lucide-react'; import { ServiceIcon } from '@/components/common/icons/service-icon'; import { DeleteConfirmDialog } from '../delete-confirm-dialog'; import { deleteService, reorderServices } from '@/lib/actions'; @@ -20,11 +20,12 @@ interface ServiceListProps { services: Service[]; categories: Category[]; onEdit: (service: Service) => void; + onDuplicate: (service: Service) => void; onDeleted: () => void; cacheKey?: number; } -export function ServiceList({ services, categories, onEdit, onDeleted, cacheKey }: ServiceListProps) { +export function ServiceList({ services, categories, onEdit, onDuplicate, onDeleted, cacheKey }: ServiceListProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [serviceToDelete, setServiceToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -218,6 +219,7 @@ export function ServiceList({ services, categories, onEdit, onDeleted, cacheKey diff --git a/components/common/context-menus/service-card-context.tsx b/components/common/context-menus/service-card-context.tsx index 95ed91e..5d24e4e 100644 --- a/components/common/context-menus/service-card-context.tsx +++ b/components/common/context-menus/service-card-context.tsx @@ -1,7 +1,7 @@ 'use client'; import React from 'react'; -import { Copy, ExternalLink, Pencil, Trash2 } from 'lucide-react'; +import { Clipboard, CopyPlus, ExternalLink, Pencil, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { ContextMenu, @@ -16,6 +16,7 @@ interface ServiceCardContextProps { service: Service; children: React.ReactNode; onEdit: (service: Service) => void; + onDuplicate?: (service: Service) => void; onDelete: (service: Service) => void; index: number; } @@ -28,6 +29,7 @@ export function ServiceCardContext({ service, children, onEdit, + onDuplicate, onDelete, index, }: ServiceCardContextProps) { @@ -75,9 +77,15 @@ export function ServiceCardContext({ Edit + {onDuplicate && ( + onDuplicate(service)}> + + Duplicate + + )} - + Copy URL diff --git a/lib/actions.ts b/lib/actions.ts index 4c684f1..e4a0f90 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -19,9 +19,9 @@ import { } from './validations'; import { backupServiceIconFiles, - deleteAppLogo, + copyIconToService, deleteIconFile, - deleteServiceIcon, + getAvailableIconBaseName, getAppLogoFilename, isProvisionalIconPath, isValidImageExtension, @@ -40,6 +40,7 @@ import { type ServiceFormData, type ServiceCreateData, type IconConfig, + type DashboardConfig, type ImportConfigResult, } from './types'; @@ -61,18 +62,129 @@ type SaveConfigJsonResult = ImportConfigResult & { revision: string; }; -async function getPromotedServiceIcon(icon: IconConfig | undefined, serviceId: string): Promise { +async function getPromotedServiceIcon( + icon: IconConfig | undefined, + serviceId: string, + protectedIconPaths?: Set, + reservedIconBasenames?: Set +): Promise { if (icon?.type !== ICON_TYPES.IMAGE || !isProvisionalIconPath(icon.value)) { return icon; } - const iconPath = await promoteProvisionalIcon(icon.value, serviceId); + const iconPath = await promoteProvisionalIcon(icon.value, serviceId, { + protectedIconPaths, + reservedIconBasenames, + }); return { type: ICON_TYPES.IMAGE, value: iconPath, }; } +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} + +function getImageIconPath(icon: IconConfig | undefined): string | undefined { + return icon?.type === ICON_TYPES.IMAGE ? icon.value : undefined; +} + +function getReferencedImageIconPaths( + config: DashboardConfig, + options: { excludeServiceId?: string; excludeAppLogo?: boolean } = {} +): Set { + const refs = new Set(); + + if (!options.excludeAppLogo) { + const appLogoPath = getImageIconPath(config.appLogo); + if (appLogoPath) { + refs.add(appLogoPath); + } + } + + for (const service of config.services) { + if (service.id === options.excludeServiceId) continue; + + const iconPath = getImageIconPath(service.icon); + if (iconPath) { + refs.add(iconPath); + } + } + + return refs; +} + +function getIconPathBasename(iconPath: string): string { + return path.parse(path.basename(iconPath)).name; +} + +function getImageIconPathBasenames(iconPaths: Set): Set { + return new Set( + [...iconPaths].map((iconPath) => getIconPathBasename(iconPath)) + ); +} + +function getReferencedImageIconBasenames( + config: DashboardConfig, + options: { excludeServiceId?: string; excludeAppLogo?: boolean } = {} +): Set { + return getImageIconPathBasenames(getReferencedImageIconPaths(config, options)); +} + +async function deleteImageIconIfUnreferenced(iconPath: string | undefined, config: DashboardConfig): Promise { + if (!iconPath || getReferencedImageIconPaths(config).has(iconPath)) return; + + await deleteIconFile(iconPath); +} + +function isServiceOwnedIconPath(iconPath: string, serviceId: string): boolean { + const iconBaseName = getIconPathBasename(iconPath); + + return iconBaseName === serviceId || iconBaseName.startsWith(`${serviceId}-`); +} + +async function getCreatedServiceIcon( + icon: IconConfig | undefined, + serviceId: string, + reservedIconPaths: Set, + reservedIconBasenames: Set +): Promise { + if (icon?.type !== ICON_TYPES.IMAGE) { + return icon; + } + + if (isProvisionalIconPath(icon.value)) { + return getPromotedServiceIcon(icon, serviceId, reservedIconPaths, reservedIconBasenames); + } + + try { + if ( + !reservedIconPaths.has(icon.value) && + !reservedIconBasenames.has(getIconPathBasename(icon.value)) && + isServiceOwnedIconPath(icon.value, serviceId) + ) { + return icon; + } + + return { + type: ICON_TYPES.IMAGE, + value: await copyIconToService(icon.value, serviceId, { reservedIconBasenames }), + }; + } catch (error) { + if (isMissingFileError(error)) { + return undefined; + } + + throw error; + } +} + function validateImageFile(file: File, fieldName: string): { success: false; errors: { field: string; message: string }[] } | null { if (!isAllowedImageMime(file.type)) { return { success: false, errors: [{ field: fieldName, message: IMAGE_TYPE_ERROR }] }; @@ -86,11 +198,16 @@ function validateImageFile(file: File, fieldName: string): { success: false; err return null; } -async function writeIconFile(file: File, filename: string, baseNameForCleanup: string): Promise { +async function writeIconFile( + file: File, + filename: string, + baseNameForCleanup: string, + protectedIconPaths?: Set +): Promise { const bytes = await file.arrayBuffer(); const buffer = Buffer.from(bytes); - return writeIconBuffer(buffer, filename, baseNameForCleanup); + return writeIconBuffer(buffer, filename, { baseNameForCleanup, protectedIconPaths }); } function mapValidationIssues(error: ZodError): Array<{ field: string; message: string }> { @@ -184,9 +301,15 @@ export async function uploadServiceIcon(formData: FormData): Promise service.id !== newService.id), + }; + await deleteImageIconIfUnreferenced(getImageIconPath(newService.icon), configWithoutNewService); } throw error; } @@ -709,7 +844,20 @@ export async function updateService(id: string, data: ServiceFormData): Promise< validated.icon?.type === ICON_TYPES.IMAGE && isProvisionalIconPath(validated.icon.value) ? await backupServiceIconFiles(idValidation.data) : null; - const nextIcon = await getPromotedServiceIcon(validated.icon, idValidation.data); + const protectedIconPaths = getReferencedImageIconPaths(config, { excludeServiceId: idValidation.data }); + const nextIcon = await getPromotedServiceIcon( + validated.icon, + idValidation.data, + protectedIconPaths, + getImageIconPathBasenames(protectedIconPaths) + ); + const promotedIconPath = + existingIconBackup && + nextIcon?.type === ICON_TYPES.IMAGE && + validated.icon?.type === ICON_TYPES.IMAGE && + nextIcon.value !== validated.icon.value + ? nextIcon.value + : undefined; const updatedService: Service = { ...config.services[index], ...validated, @@ -721,18 +869,15 @@ export async function updateService(id: string, data: ServiceFormData): Promise< await writeConfig(config); } catch (error) { if (existingIconBackup) { + if (promotedIconPath) { + await deleteIconFile(promotedIconPath); + } await restoreServiceIconFiles(idValidation.data, existingIconBackup); } throw error; } - // If we are moving away from an image icon, remove the old image file after the config persists - if ( - previousService.icon?.type === ICON_TYPES.IMAGE && - nextIcon?.type !== ICON_TYPES.IMAGE - ) { - await deleteServiceIcon(id); - } + await deleteImageIconIfUnreferenced(getImageIconPath(previousService.icon), config); revalidatePath('/'); revalidatePath('/admin'); @@ -758,12 +903,12 @@ export async function updateService(id: string, data: ServiceFormData): Promise< export async function deleteService(id: string): Promise> { try { const config = await readConfig(); + const serviceToDelete = config.services.find(svc => svc.id === id); config.services = config.services.filter(svc => svc.id !== id); await writeConfig(config); - // Delete icon file if it exists - await deleteServiceIcon(id); + await deleteImageIconIfUnreferenced(getImageIconPath(serviceToDelete?.icon), config); revalidatePath('/'); revalidatePath('/admin'); diff --git a/lib/favicon.ts b/lib/favicon.ts index 653b5fb..c3db853 100644 --- a/lib/favicon.ts +++ b/lib/favicon.ts @@ -6,7 +6,7 @@ import { isAllowedImageExtension, isAllowedImageMime, } from './image-constants'; -import { getProvisionalIconFilename, writeIconBuffer } from './file-utils'; +import { getAvailableIconBaseName, getProvisionalIconFilename, writeIconBuffer } from './file-utils'; import { SERVICE_FETCH_USER_AGENT, fetchPageHtml, @@ -232,12 +232,27 @@ async function findServiceFavicon(serviceUrl: string): Promise<{ buffer: Buffer; return null; } -export async function fetchServiceFavicon(serviceUrl: string, serviceId: string): Promise { +export async function fetchServiceFavicon( + serviceUrl: string, + serviceId: string, + options: { + protectedIconPaths?: Set; + reservedIconBasenames?: Set; + } = {} +): Promise { try { const icon = await findServiceFavicon(serviceUrl); if (!icon) return null; - return writeIconBuffer(icon.buffer, `${serviceId}${icon.ext}`, serviceId); + const targetBaseName = getAvailableIconBaseName( + serviceId, + options.reservedIconBasenames ?? new Set() + ); + + return writeIconBuffer(icon.buffer, `${targetBaseName}${icon.ext}`, { + baseNameForCleanup: targetBaseName, + protectedIconPaths: options.protectedIconPaths, + }); } catch (error) { if (error instanceof Error && error.name !== 'TimeoutError' && error.name !== 'AbortError') { console.error('Favicon fetch error:', error); diff --git a/lib/file-utils.ts b/lib/file-utils.ts index ffeba59..f43958e 100644 --- a/lib/file-utils.ts +++ b/lib/file-utils.ts @@ -12,6 +12,21 @@ interface IconFileBackup { buffer: Buffer; } +interface WriteIconBufferOptions { + baseNameForCleanup?: string; + protectedIconPaths?: Set; +} + +export function getAvailableIconBaseName(preferredBaseName: string, reservedIconBasenames: Set): string { + let targetBaseName = preferredBaseName; + + while (reservedIconBasenames.has(targetBaseName)) { + targetBaseName = `${preferredBaseName}-${randomUUID()}`; + } + + return targetBaseName; +} + async function getIconFilesForBaseName(baseName: string): Promise { const files = await fs.readdir(getIconsDir()).catch(() => []); @@ -72,17 +87,20 @@ export function getIconFilePath(filename: string): string { export async function writeIconBuffer( buffer: Buffer, filename: string, - baseNameForCleanup?: string + options: WriteIconBufferOptions = {} ): Promise { const filePath = getIconFilePath(filename); const iconsDir = path.dirname(filePath); const tempPath = path.join(iconsDir, `${filename}.tmp-${randomUUID()}`); + const { baseNameForCleanup, protectedIconPaths } = options; await fs.mkdir(iconsDir, { recursive: true }); const existingIcons = baseNameForCleanup ? await fs.readdir(iconsDir).catch(() => []) : []; const oldFiles = existingIcons.filter((file) => ( - path.parse(file).name === baseNameForCleanup && file !== filename + path.parse(file).name === baseNameForCleanup && + file !== filename && + !protectedIconPaths?.has(`icons/${file}`) )); try { @@ -139,15 +157,54 @@ export function isProvisionalIconPath(iconPath: string): boolean { ); } -export async function promoteProvisionalIcon(iconPath: string, serviceId: string): Promise { +export function isManagedIconPath(iconPath: string): boolean { + const filename = path.basename(iconPath); + + return iconPath === `icons/${filename}` && isValidImageExtension(filename); +} + +export async function promoteProvisionalIcon( + iconPath: string, + serviceId: string, + options: { + protectedIconPaths?: Set; + reservedIconBasenames?: Set; + } = {} +): Promise { if (!isProvisionalIconPath(iconPath)) { throw new Error('Only provisional icons can be promoted'); } const filename = path.basename(iconPath); const ext = path.extname(filename).toLowerCase(); + const targetBaseName = getAvailableIconBaseName( + serviceId, + options.reservedIconBasenames ?? new Set() + ); + const buffer = await fs.readFile(getIconFilePath(filename)); + return writeIconBuffer(buffer, `${targetBaseName}${ext}`, { + baseNameForCleanup: targetBaseName, + protectedIconPaths: options.protectedIconPaths, + }); +} + +export async function copyIconToService( + iconPath: string, + serviceId: string, + options: { reservedIconBasenames?: Set } = {} +): Promise { + if (!isManagedIconPath(iconPath)) { + throw new Error('Only managed icon paths can be copied'); + } + + const filename = path.basename(iconPath); + const ext = path.extname(filename).toLowerCase(); + const reservedIconBasenames = options.reservedIconBasenames ?? new Set(); + const targetBaseName = getAvailableIconBaseName(serviceId, reservedIconBasenames); + + const targetFilename = `${targetBaseName}${ext}`; const buffer = await fs.readFile(getIconFilePath(filename)); - return writeIconBuffer(buffer, `${serviceId}${ext}`, serviceId); + return writeIconBuffer(buffer, targetFilename, { baseNameForCleanup: targetBaseName }); } /** diff --git a/lib/service-duplicate.ts b/lib/service-duplicate.ts new file mode 100644 index 0000000..3d60977 --- /dev/null +++ b/lib/service-duplicate.ts @@ -0,0 +1,45 @@ +import { ICON_TYPES, SERVICE_NAME_MAX_LENGTH, type Service, type ServiceFormData } from './types'; +import { slugify } from './utils'; + +function getDuplicateCandidateName(baseName: string, suffix: string): string { + const copySuffix = ` (${suffix})`; + const maxBaseLength = SERVICE_NAME_MAX_LENGTH - copySuffix.length; + const truncatedBaseName = baseName.slice(0, maxBaseLength).trimEnd(); + + return `${truncatedBaseName || 'Service'}${copySuffix}`; +} + +function getUniqueDuplicateName(sourceName: string, existingServices: Service[]): string { + const trimmedName = sourceName.trim(); + const baseName = trimmedName || 'Service'; + const existingIds = new Set(existingServices.map((service) => service.id)); + const existingNames = new Set(existingServices.map((service) => service.name.trim().toLowerCase())); + + for (let copyNumber = 1; copyNumber < Number.MAX_SAFE_INTEGER; copyNumber += 1) { + const suffix = copyNumber === 1 ? 'Copy' : `Copy ${copyNumber}`; + const candidateName = getDuplicateCandidateName(baseName, suffix); + const candidateId = slugify(candidateName); + + if (candidateId && !existingIds.has(candidateId) && !existingNames.has(candidateName.toLowerCase())) { + return candidateName; + } + } + + throw new Error('Unable to generate a unique service name'); +} + +export function createServiceDuplicateDraft( + service: Service, + existingServices: Service[] +): ServiceFormData { + return { + name: getUniqueDuplicateName(service.name, existingServices), + description: service.description, + url: service.url, + categoryId: service.categoryId, + icon: service.icon?.type === ICON_TYPES.IMAGE + ? { type: ICON_TYPES.IMAGE, value: service.icon.value } + : service.icon, + active: service.active, + }; +} diff --git a/lib/service-metadata.ts b/lib/service-metadata.ts index 7cc7074..ee069aa 100644 --- a/lib/service-metadata.ts +++ b/lib/service-metadata.ts @@ -1,5 +1,5 @@ import { parse, type DefaultTreeAdapterTypes } from 'parse5'; -import { SERVICE_DESCRIPTION_MAX_LENGTH, SERVICE_NAME_MAX_LENGTH } from './validations'; +import { SERVICE_DESCRIPTION_MAX_LENGTH, SERVICE_NAME_MAX_LENGTH } from './types'; import { fetchPageHtml, isHttpUrl } from './service-url-fetch'; export interface ServiceMetadata { diff --git a/lib/types.ts b/lib/types.ts index e6baf28..289c93f 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -10,6 +10,8 @@ export type DashboardLayout = typeof LAYOUTS[keyof typeof LAYOUTS]; export const DEFAULT_LAYOUT: DashboardLayout = LAYOUTS.ROWS; export const PREFERENCES_COOKIE_NAME = 'preferences'; export const DEFAULT_APP_TITLE = 'crapdash'; +export const SERVICE_NAME_MAX_LENGTH = 100; +export const SERVICE_DESCRIPTION_MAX_LENGTH = 500; export interface Preferences { layout: DashboardLayout; diff --git a/lib/validations.ts b/lib/validations.ts index 33b7064..58842d3 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -1,10 +1,7 @@ import { z } from 'zod'; -import { ICON_TYPES } from './types'; +import { ICON_TYPES, SERVICE_DESCRIPTION_MAX_LENGTH, SERVICE_NAME_MAX_LENGTH } from './types'; import { resolveLucideIconName } from './lucide-icons'; -export const SERVICE_NAME_MAX_LENGTH = 100; -export const SERVICE_DESCRIPTION_MAX_LENGTH = 500; - export const slugSchema = z.string().regex( /^[a-z0-9-]+$/i, 'Slug must use letters, numbers, or dashes' diff --git a/tests/lib/actions.test.ts b/tests/lib/actions.test.ts index 420d587..a0b6401 100644 --- a/tests/lib/actions.test.ts +++ b/tests/lib/actions.test.ts @@ -1,7 +1,15 @@ -import { chmod, readFile, stat, writeFile } from 'fs/promises'; +import { chmod, readdir, readFile, stat, writeFile } from 'fs/promises'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { cleanupFetchedServiceIcon, createService, updateService } from '@/lib/actions'; +import { + cleanupFetchedServiceIcon, + createService, + deleteService, + updateAppSettings, + updateService, + uploadAppLogo, + uploadServiceIcon, +} from '@/lib/actions'; import { getConfigPath, getDataDir, getIconsDir } from '@/lib/paths'; import { ICON_TYPES, type DashboardConfig } from '@/lib/types'; import { createTestDataDir, removeTestDataDir } from './test-data-dir'; @@ -71,7 +79,43 @@ describe('service icon actions', () => { expect(savedConfig.services[0]?.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }); }); - it('restores previous icon files when update config persistence fails after promotion', async () => { + it('copies an existing image icon when creating a service with a different id', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }], + }); + await writeIcon('grafana.png', 'source icon'); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana Copy', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }); + await expect(readFile(path.join(getIconsDir(), 'grafana.png'), 'utf-8')).resolves.toBe('source icon'); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('source icon'); + + const savedConfig = JSON.parse(await readFile(getConfigPath(), 'utf-8')) as DashboardConfig; + expect(savedConfig.services[1]?.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }); + }); + + it('keeps duplicated image icons isolated even when the source basename matches the new id', async () => { await writeConfig({ categories: [{ id: 'infra', name: 'Infrastructure' }], services: [{ @@ -80,11 +124,393 @@ describe('service icon actions', () => { description: 'Dashboards', url: 'https://grafana.example.com', categoryId: 'infra', - icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.svg' }, + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }, active: true, }], }); + await writeIcon('grafana-copy.png', 'source icon'); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon?.type).toBe(ICON_TYPES.IMAGE); + expect(result.data.icon?.value).toMatch(/^icons\/grafana-copy-[a-f0-9-]+\.png$/); + const duplicateIconFilename = path.basename(result.data.icon?.value ?? ''); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('source icon'); + await expect(readFile(path.join(getIconsDir(), duplicateIconFilename), 'utf-8')).resolves.toBe('source icon'); + + const savedConfig = JSON.parse(await readFile(getConfigPath(), 'utf-8')) as DashboardConfig; + expect(savedConfig.services[0]?.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }); + expect(savedConfig.services[1]?.icon).toEqual(result.data.icon); + + const deleteResult = await deleteService('grafana-copy'); + + expect(deleteResult.success).toBe(true); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('source icon'); + expect(await fileExists(duplicateIconFilename)).toBe(false); + }); + + it('does not overwrite another service icon when the default copy destination is already referenced', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [ + { + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }, + { + id: 'metrics', + name: 'Metrics', + description: 'Metrics', + url: 'https://metrics.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }, + active: true, + }, + ], + }); + await writeIcon('grafana.png', 'source icon'); + await writeIcon('grafana-copy.png', 'reserved icon'); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon?.type).toBe(ICON_TYPES.IMAGE); + expect(result.data.icon?.value).toMatch(/^icons\/grafana-copy-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('reserved icon'); + }); + + it('does not remove another referenced icon with the same basename and a different extension', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [ + { + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }, + { + id: 'metrics', + name: 'Metrics', + description: 'Metrics', + url: 'https://metrics.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.svg' }, + active: true, + }, + ], + }); + await writeIcon('grafana.png', 'source icon'); + await writeIcon('grafana-copy.svg', 'reserved svg'); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon?.type).toBe(ICON_TYPES.IMAGE); + expect(result.data.icon?.value).toMatch(/^icons\/grafana-copy-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.svg'), 'utf-8')).resolves.toBe('reserved svg'); + }); + + it('preserves referenced same-basename icons when uploading a service icon', async () => { + await writeConfig({ + appLogo: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.svg' }, + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [], + }); + await writeIcon('grafana.svg', 'app logo'); + + const formData = new FormData(); + formData.append('serviceId', 'grafana'); + formData.append('file', new File(['service icon'], 'grafana.png', { type: 'image/png' })); + + const result = await uploadServiceIcon(formData); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatch(/^icons\/grafana-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'grafana.svg'), 'utf-8')).resolves.toBe('app logo'); + await expect(readFile(path.join(getIconsDir(), path.basename(result.data)), 'utf-8')).resolves.toBe('service icon'); + }); + + it('uses an unreferenced uploaded service icon without copying it again on create', async () => { + await writeConfig({ + appLogo: { type: ICON_TYPES.IMAGE, value: 'icons/app-logo.png' }, + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [], + }); + await writeIcon('app-logo.png', 'app logo'); + + const formData = new FormData(); + formData.append('serviceId', 'app-logo'); + formData.append('file', new File(['service icon'], 'service.png', { type: 'image/png' })); + const uploadResult = await uploadServiceIcon(formData); + + expect(uploadResult.success).toBe(true); + if (!uploadResult.success) return; + expect(uploadResult.data).toMatch(/^icons\/app-logo-[a-f0-9-]+\.png$/); + + const createResult = await createService({ + id: 'app-logo', + name: 'App Logo', + description: 'Service', + url: 'https://app-logo.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: uploadResult.data }, + active: true, + fetchFavicon: false, + }); + + expect(createResult.success).toBe(true); + if (!createResult.success) return; + expect(createResult.data.icon).toEqual({ type: ICON_TYPES.IMAGE, value: uploadResult.data }); + const iconFiles = await readdir(getIconsDir()); + expect(iconFiles.filter((file) => /^app-logo-[a-f0-9-]+\.png$/.test(file))).toHaveLength(1); + await expect(readFile(path.join(getIconsDir(), path.basename(uploadResult.data)), 'utf-8')).resolves.toBe('service icon'); + }); + + it('preserves referenced same-basename icons when uploading an app logo', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/app-logo.svg' }, + active: true, + }], + }); + await writeIcon('app-logo.svg', 'service icon'); + + const formData = new FormData(); + formData.append('file', new File(['app logo'], 'logo.png', { type: 'image/png' })); + + const result = await uploadAppLogo(formData); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toMatch(/^icons\/app-logo-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'app-logo.svg'), 'utf-8')).resolves.toBe('service icon'); + await expect(readFile(path.join(getIconsDir(), path.basename(result.data)), 'utf-8')).resolves.toBe('app logo'); + }); + + it('does not overwrite the app logo when the default copy destination is already referenced by it', async () => { + await writeConfig({ + appLogo: { type: ICON_TYPES.IMAGE, value: 'icons/grafana-copy.png' }, + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }], + }); + await writeIcon('grafana.png', 'source icon'); + await writeIcon('grafana-copy.png', 'app logo'); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon?.type).toBe(ICON_TYPES.IMAGE); + expect(result.data.icon?.value).toMatch(/^icons\/grafana-copy-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('app logo'); + }); + + it('creates the service without an icon when a copied image source file is missing', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }], + }); + + const result = await createService({ + id: 'grafana-copy', + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + fetchFavicon: false, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data.icon).toBeUndefined(); + expect(await fileExists('grafana-copy.png')).toBe(false); + + const savedConfig = JSON.parse(await readFile(getConfigPath(), 'utf-8')) as DashboardConfig; + expect(savedConfig.services[1]?.icon).toBeUndefined(); + }); + + it('does not delete a service icon file that is still referenced by the app logo', async () => { + await writeConfig({ + appLogo: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + active: true, + }], + }); + await writeIcon('shared.png', 'shared icon'); + + const result = await deleteService('grafana'); + + expect(result.success).toBe(true); + await expect(readFile(path.join(getIconsDir(), 'shared.png'), 'utf-8')).resolves.toBe('shared icon'); + }); + + it('does not delete an updated service icon file that is still referenced by another service', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [ + { + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + active: true, + }, + { + id: 'metrics', + name: 'Metrics', + description: 'Metrics', + url: 'https://metrics.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + active: true, + }, + ], + }); + await writeIcon('shared.png', 'shared icon'); + + const result = await updateService('grafana', { + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + active: true, + }); + + expect(result.success).toBe(true); + await expect(readFile(path.join(getIconsDir(), 'shared.png'), 'utf-8')).resolves.toBe('shared icon'); + }); + + it('does not delete an app logo file that is still referenced by a service', async () => { + await writeConfig({ + appLogo: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [{ + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/shared.png' }, + active: true, + }], + }); + await writeIcon('shared.png', 'shared icon'); + + const result = await updateAppSettings({ appLogo: null }); + + expect(result.success).toBe(true); + await expect(readFile(path.join(getIconsDir(), 'shared.png'), 'utf-8')).resolves.toBe('shared icon'); + }); + + it('restores previous icon files when update config persistence fails after promotion', async () => { + await writeConfig({ + categories: [{ id: 'infra', name: 'Infrastructure' }], + services: [ + { + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.svg' }, + active: true, + }, + { + id: 'metrics', + name: 'Metrics', + description: 'Metrics', + url: 'https://metrics.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + active: true, + }, + ], + }); await writeIcon('grafana.svg', 'old icon'); + await writeIcon('grafana.png', 'reserved icon'); await writeIcon('__tmp-favicon-preview.ico', 'new icon'); await chmod(getDataDir(), 0o555); @@ -98,11 +524,15 @@ describe('service icon actions', () => { }); expect(result.success).toBe(false); + const iconFiles = await readdir(getIconsDir()); expect(await fileExists('grafana.ico')).toBe(false); + expect(iconFiles.filter((file) => /^grafana-[a-f0-9-]+\.ico$/.test(file))).toEqual([]); await expect(readFile(path.join(getIconsDir(), 'grafana.svg'), 'utf-8')).resolves.toBe('old icon'); + await expect(readFile(path.join(getIconsDir(), 'grafana.png'), 'utf-8')).resolves.toBe('reserved icon'); await chmod(getDataDir(), 0o755); const savedConfig = JSON.parse(await readFile(getConfigPath(), 'utf-8')) as DashboardConfig; expect(savedConfig.services[0]?.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana.svg' }); + expect(savedConfig.services[1]?.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }); }); }); diff --git a/tests/lib/file-utils.test.ts b/tests/lib/file-utils.test.ts index fc7629e..f628874 100644 --- a/tests/lib/file-utils.test.ts +++ b/tests/lib/file-utils.test.ts @@ -3,9 +3,11 @@ import path from 'path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { backupServiceIconFiles, + copyIconToService, isProvisionalIconPath, promoteProvisionalIcon, restoreServiceIconFiles, + writeIconBuffer, } from '@/lib/file-utils'; import { getIconsDir } from '@/lib/paths'; import { createTestDataDir, removeTestDataDir } from './test-data-dir'; @@ -59,4 +61,46 @@ describe('file-utils icon persistence', () => { expect(files).not.toContain('grafana.ico'); await expect(readFile(path.join(getIconsDir(), 'grafana.svg'), 'utf-8')).resolves.toBe('old icon'); }); + + it('copies a managed icon to a new service basename', async () => { + await writeIcon('grafana.png', 'source icon'); + + const copiedPath = await copyIconToService('icons/grafana.png', 'grafana-copy'); + const files = await readdir(getIconsDir()); + + expect(copiedPath).toBe('icons/grafana-copy.png'); + expect(files).toEqual(expect.arrayContaining(['grafana.png', 'grafana-copy.png'])); + await expect(readFile(path.join(getIconsDir(), 'grafana.png'), 'utf-8')).resolves.toBe('source icon'); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.png'), 'utf-8')).resolves.toBe('source icon'); + }); + + it('chooses a unique target when the default copy basename is reserved', async () => { + await writeIcon('grafana.png', 'source icon'); + await writeIcon('grafana-copy.svg', 'reserved icon'); + + const copiedPath = await copyIconToService('icons/grafana.png', 'grafana-copy', { + reservedIconBasenames: new Set(['grafana-copy']), + }); + const copiedFilename = path.basename(copiedPath); + + expect(copiedPath).toMatch(/^icons\/grafana-copy-[a-f0-9-]+\.png$/); + await expect(readFile(path.join(getIconsDir(), 'grafana-copy.svg'), 'utf-8')).resolves.toBe('reserved icon'); + await expect(readFile(path.join(getIconsDir(), copiedFilename), 'utf-8')).resolves.toBe('source icon'); + }); + + it('preserves protected icon paths during same-basename cleanup', async () => { + await writeIcon('grafana.svg', 'protected icon'); + await writeIcon('grafana.ico', 'stale icon'); + + await writeIconBuffer(Buffer.from('new icon'), 'grafana.png', { + baseNameForCleanup: 'grafana', + protectedIconPaths: new Set(['icons/grafana.svg']), + }); + + const files = await readdir(getIconsDir()); + expect(files).toEqual(expect.arrayContaining(['grafana.svg', 'grafana.png'])); + expect(files).not.toContain('grafana.ico'); + await expect(readFile(path.join(getIconsDir(), 'grafana.svg'), 'utf-8')).resolves.toBe('protected icon'); + await expect(readFile(path.join(getIconsDir(), 'grafana.png'), 'utf-8')).resolves.toBe('new icon'); + }); }); diff --git a/tests/lib/service-duplicate.test.ts b/tests/lib/service-duplicate.test.ts new file mode 100644 index 0000000..dec7430 --- /dev/null +++ b/tests/lib/service-duplicate.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest'; +import { createServiceDuplicateDraft } from '@/lib/service-duplicate'; +import { ICON_TYPES, SERVICE_NAME_MAX_LENGTH, type Service } from '@/lib/types'; + +const baseService: Service = { + id: 'grafana', + name: 'Grafana', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.ICON, value: 'ChartNoAxesColumn' }, + active: true, +}; + +describe('service duplicate helpers', () => { + it('creates a create-mode draft with a unique copy name', () => { + const draft = createServiceDuplicateDraft(baseService, [baseService]); + + expect(draft).toEqual({ + name: 'Grafana (Copy)', + description: 'Dashboards', + url: 'https://grafana.example.com', + categoryId: 'infra', + icon: { type: ICON_TYPES.ICON, value: 'ChartNoAxesColumn' }, + active: true, + }); + }); + + it('increments the copy name when either the name or slug already exists', () => { + const draft = createServiceDuplicateDraft(baseService, [ + baseService, + { ...baseService, id: 'grafana-copy', name: 'Grafana Backup' }, + { ...baseService, id: 'grafana-copy-2', name: 'Grafana (Copy 2)' }, + ]); + + expect(draft.name).toBe('Grafana (Copy 3)'); + }); + + it('truncates long service names before appending the copy suffix', () => { + const longName = 'A'.repeat(SERVICE_NAME_MAX_LENGTH); + const draft = createServiceDuplicateDraft({ + ...baseService, + id: 'long-service', + name: longName, + }, [{ ...baseService, id: 'long-service', name: longName }]); + + expect(draft.name).toBe(`${'A'.repeat(93)} (Copy)`); + expect(draft.name).toHaveLength(SERVICE_NAME_MAX_LENGTH); + }); + + it('reserves enough room when an incremented copy suffix is needed', () => { + const longName = 'A'.repeat(SERVICE_NAME_MAX_LENGTH); + const draft = createServiceDuplicateDraft({ + ...baseService, + id: 'long-service', + name: longName, + }, [ + { ...baseService, id: 'long-service', name: longName }, + { ...baseService, id: `${'a'.repeat(93)}-copy`, name: `${'A'.repeat(93)} (Copy)` }, + ]); + + expect(draft.name).toBe(`${'A'.repeat(91)} (Copy 2)`); + expect(draft.name).toHaveLength(SERVICE_NAME_MAX_LENGTH); + }); + + it('preserves image icon references for the create action to copy under the new service id', () => { + const draft = createServiceDuplicateDraft({ + ...baseService, + icon: { type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }, + }, [baseService]); + + expect(draft.icon).toEqual({ type: ICON_TYPES.IMAGE, value: 'icons/grafana.png' }); + }); +});