fix(extensions): sanitize suggested skill folder names
This commit is contained in:
parent
4578f11dd7
commit
86bc4ce4e8
5 changed files with 88 additions and 18 deletions
|
|
@ -32,6 +32,7 @@ import {
|
||||||
readSkillTemplateContent,
|
readSkillTemplateContent,
|
||||||
updateSkillTemplateFrontmatter,
|
updateSkillTemplateFrontmatter,
|
||||||
} from './skillDraftUtils';
|
} from './skillDraftUtils';
|
||||||
|
import { toSuggestedSkillFolderName } from './skillFolderNameUtils';
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
|
|
||||||
|
|
@ -61,16 +62,6 @@ function parseInitialDescription(detail: SkillDetail | null): string {
|
||||||
return detail?.item.description ?? '';
|
return detail?.item.description ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSuggestedFolderName(value: string): string {
|
|
||||||
return value
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[^\x00-\x7F]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 80);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SkillEditorDialog = ({
|
export const SkillEditorDialog = ({
|
||||||
open,
|
open,
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -192,7 +183,7 @@ export const SkillEditorDialog = ({
|
||||||
notes: nextNotes,
|
notes: nextNotes,
|
||||||
});
|
});
|
||||||
const rawInput = readSkillTemplateContent(nextRawContent);
|
const rawInput = readSkillTemplateContent(nextRawContent);
|
||||||
const suggestedFolderName = toSuggestedFolderName(nextName || 'New Skill');
|
const suggestedFolderName = toSuggestedSkillFolderName(nextName || 'New Skill');
|
||||||
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
|
const hasCustomMarkdown = mode === 'edit' && rawInput.hasUnstructuredBody;
|
||||||
|
|
||||||
setScope(nextScope);
|
setScope(nextScope);
|
||||||
|
|
@ -485,7 +476,7 @@ export const SkillEditorDialog = ({
|
||||||
const nextValue = event.target.value;
|
const nextValue = event.target.value;
|
||||||
setName(nextValue);
|
setName(nextValue);
|
||||||
if (mode === 'create' && !folderNameEdited) {
|
if (mode === 'create' && !folderNameEdited) {
|
||||||
setFolderName(toSuggestedFolderName(nextValue || 'New Skill'));
|
setFolderName(toSuggestedSkillFolderName(nextValue || 'New Skill'));
|
||||||
}
|
}
|
||||||
applyFormToRawContent({ name: nextValue });
|
applyFormToRawContent({ name: nextValue });
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
import { FileSearch, FolderOpen, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { getSuggestedSkillFolderNameFromPath } from './skillFolderNameUtils';
|
||||||
import { SkillReviewDialog } from './SkillReviewDialog';
|
import { SkillReviewDialog } from './SkillReviewDialog';
|
||||||
import { resolveSkillProjectPath } from './skillProjectUtils';
|
import { resolveSkillProjectPath } from './skillProjectUtils';
|
||||||
|
|
||||||
|
|
@ -57,11 +58,6 @@ interface SkillImportDialogProps {
|
||||||
onImported: (skillId: string | null) => void;
|
onImported: (skillId: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFolderNameFromPath(value: string): string {
|
|
||||||
const segments = value.split(/[\\/]/u).filter(Boolean);
|
|
||||||
return segments.at(-1) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SkillImportDialog = ({
|
export const SkillImportDialog = ({
|
||||||
open,
|
open,
|
||||||
projectPath,
|
projectPath,
|
||||||
|
|
@ -101,7 +97,7 @@ export const SkillImportDialog = ({
|
||||||
if (!open || folderNameEdited) {
|
if (!open || folderNameEdited) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFolderName(getFolderNameFromPath(sourceDir));
|
setFolderName(getSuggestedSkillFolderNameFromPath(sourceDir));
|
||||||
}, [folderNameEdited, open, sourceDir]);
|
}, [folderNameEdited, open, sourceDir]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function toSuggestedSkillFolderName(value: string, fallback = 'new-skill'): string {
|
||||||
|
const normalized = value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[^\x00-\x7F]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
return normalized || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestedSkillFolderNameFromPath(
|
||||||
|
value: string,
|
||||||
|
fallback = 'imported-skill'
|
||||||
|
): string {
|
||||||
|
const segments = value.split(/[\\/]/u).filter(Boolean);
|
||||||
|
return toSuggestedSkillFolderName(segments.at(-1) ?? '', fallback);
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,44 @@ describe('SkillImportDialog', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sanitizes the suggested destination folder when the source folder name is not CLI-safe', async () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
selectFoldersMock.mockResolvedValueOnce(['/tmp/My Skill Folder']);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(SkillImportDialog, {
|
||||||
|
open: true,
|
||||||
|
projectPath: null,
|
||||||
|
projectLabel: null,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onImported: vi.fn(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const browseButton = Array.from(host.querySelectorAll('button')).find(
|
||||||
|
(button) => button.textContent?.includes('Browse')
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
browseButton.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderInput = host.querySelector('#skill-import-folder') as HTMLInputElement;
|
||||||
|
expect(folderInput.value).toBe('my-skill-folder');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to user scope when the project context disappears mid-dialog', async () => {
|
it('falls back to user scope when the project context disappears mid-dialog', async () => {
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getSuggestedSkillFolderNameFromPath,
|
||||||
|
toSuggestedSkillFolderName,
|
||||||
|
} from '../../../../../src/renderer/components/extensions/skills/skillFolderNameUtils';
|
||||||
|
|
||||||
|
describe('skillFolderNameUtils', () => {
|
||||||
|
it('creates a safe slug from a human-readable skill name', () => {
|
||||||
|
expect(toSuggestedSkillFolderName('Review Helper 2')).toBe('review-helper-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to a safe default when the name cannot be slugged', () => {
|
||||||
|
expect(toSuggestedSkillFolderName('Привет мир')).toBe('new-skill');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes imported folder names from the selected source path', () => {
|
||||||
|
expect(getSuggestedSkillFolderNameFromPath('/tmp/My Skill Folder')).toBe(
|
||||||
|
'my-skill-folder'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an import-specific fallback when the source folder name is unusable', () => {
|
||||||
|
expect(getSuggestedSkillFolderNameFromPath('/tmp/技能')).toBe('imported-skill');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue