mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-16 04:28:39 +00:00
fix(publish): preserve package preview and version presentation compatibility
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
1c164c8702
commit
793a519bdb
9 changed files with 82 additions and 7 deletions
|
|
@ -49,7 +49,8 @@ public class SkillPublishController extends BaseApiController {
|
|||
throw new DomainForbiddenException("error.auth.required");
|
||||
}
|
||||
try {
|
||||
return ok("response.success.read", packagePreview.preview(skillPackageArchiveExtractor.extract(file)));
|
||||
return ok("response.success.read", packagePreview.preview(
|
||||
skillPackageArchiveExtractor.extractWithWarnings(file).entries()));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid", exception.getMessage());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,33 @@ class SkillPublishControllerTest {
|
|||
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagePreviewNormalizesNestedSkillAlongsideRootReadmeLikePublish() throws Exception {
|
||||
byte[] archive;
|
||||
try (var output = new ByteArrayOutputStream(); var zip = new ZipOutputStream(output)) {
|
||||
zip.putNextEntry(new ZipEntry("README.md"));
|
||||
zip.write("Repository overview".getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
zip.putNextEntry(new ZipEntry("foo/SKILL.md"));
|
||||
zip.write("---\nname: foo\ndescription: Nested skill\nversion: 1.0.0\nmetadata:\n display-name: 嵌套技能\n---\nBody".getBytes(StandardCharsets.UTF_8));
|
||||
zip.closeEntry();
|
||||
zip.finish();
|
||||
archive = output.toByteArray();
|
||||
}
|
||||
var principal = new PlatformPrincipal("preview_nested", "publisher", "publisher@example.com", "", "local", Set.of());
|
||||
var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
for (String base : List.of("/api/v1", "/api/web")) {
|
||||
mockMvc.perform(multipart(base + "/skills/package-preview")
|
||||
.file(new MockMultipartFile("file", "skill.zip", "application/zip", archive))
|
||||
.with(authentication(auth)).with(csrf()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.name").value("foo"))
|
||||
.andExpect(jsonPath("$.data.displayName").value("嵌套技能"))
|
||||
.andExpect(jsonPath("$.data.description").value("Nested skill"));
|
||||
}
|
||||
org.mockito.Mockito.verifyNoInteractions(skillPublishService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void packagePreviewRejectsAnonymousWithoutPublishing() throws Exception {
|
||||
mockMvc.perform(multipart("/api/v1/skills/package-preview")
|
||||
|
|
|
|||
|
|
@ -1831,6 +1831,7 @@
|
|||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"packageName": "Package name",
|
||||
"displayName": "Display name",
|
||||
"displayNamePlaceholder": "For example: Meeting notes assistant",
|
||||
"displayNameHelp": "Up to 200 characters. Defaults to the package title. Changes follow this version’s review and do not alter the identifier or package files. Leave blank to use the package title.",
|
||||
|
|
|
|||
|
|
@ -1822,6 +1822,7 @@
|
|||
"goToPage": "Перейти на страницу {{page}}"
|
||||
},
|
||||
"publish": {
|
||||
"packageName": "Имя в пакете",
|
||||
"displayName": "Отображаемое имя",
|
||||
"displayNamePlaceholder": "Например: Помощник для протоколов встреч",
|
||||
"displayNameHelp": "До 200 символов. По умолчанию — имя из пакета. Изменения проходят проверку версии и не меняют идентификатор или файлы пакета. Пустое поле означает имя из пакета.",
|
||||
|
|
|
|||
|
|
@ -1830,6 +1830,7 @@
|
|||
"copyright": "© 2026 SkillHub. Apache-2.0."
|
||||
},
|
||||
"publish": {
|
||||
"packageName": "包内名称",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "例如:会议纪要助手",
|
||||
"displayNameHelp": "支持中文,最多 200 个字符。默认读取包内名称;修改后随本次版本审核,不改变技能标识或包内文件。留空则使用包内名称。",
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ export function PublishPage() {
|
|||
<p>{t('publish.packagePreviewFailed')}</p>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => packagePreview.refetch()}>{t('common.retry')}</Button>
|
||||
</div>}
|
||||
{selectedFile && packagePreview.data && <p className="break-all font-mono text-xs text-muted-foreground">@{namespaceSlug || '…'}/{packagePreview.data.name}</p>}
|
||||
{selectedFile && packagePreview.data && <p className="break-all text-xs text-muted-foreground">{t('publish.packageName')}: <code>{packagePreview.data.name}</code></p>}
|
||||
</div>
|
||||
|
||||
<SkillAvatarEditor key={`${namespaceSlug}/${updateSlug}`} name={search.updateName ?? updateSlug} currentUrl={avatarTarget.data?.avatarUrl} canRemove={!!updateSlug} disabled={isBusy || avatarUnavailable} onChange={setAvatarEdit} />
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { FileTree } from '@/features/skill/file-tree'
|
|||
import { FilePreviewDialog } from '@/features/skill/file-preview-dialog'
|
||||
import type { FileTreeNode } from '@/features/skill/file-tree-builder'
|
||||
import type { SkillFile } from '@/api/types'
|
||||
import { getVersionPresentation } from '@/shared/lib/version-presentation'
|
||||
import { CopyButton } from '@/shared/components/copy-button'
|
||||
import { InstallCommand } from '@/features/skill/install-command'
|
||||
import { ShareButton } from '@/features/skill/share-button'
|
||||
|
|
@ -200,10 +201,13 @@ export function SkillDetailPage() {
|
|||
: headlineVersion?.version ?? versions?.[0]?.version
|
||||
const selectedVersionEntry = versions?.find((version) => version.version === selectedVersion) ?? versions?.[0]
|
||||
const selectedMetadata = selectedVersion === ownerPreviewVersion?.version ? ownerPreviewVersion
|
||||
: selectedVersion === publishedVersion?.version ? publishedVersion : headlineVersion
|
||||
const selectedDisplayName = selectedMetadata?.displayName ?? skill?.displayName
|
||||
const selectedSummary = selectedMetadata?.summary ?? skill?.summary
|
||||
const showingUnpublishedPreview = selectedMetadata != null && selectedMetadata.status !== 'PUBLISHED'
|
||||
: selectedVersion === publishedVersion?.version ? publishedVersion
|
||||
: selectedVersion === headlineVersion?.version ? headlineVersion : null
|
||||
const { data: selectedVersionDetail } = useSkillVersionDetail(qns, qslug, selectedVersion, skillReady && !selectedMetadata)
|
||||
const historicalMetadata = getVersionPresentation(selectedVersionDetail?.parsedMetadataJson)
|
||||
const selectedDisplayName = selectedMetadata?.displayName ?? historicalMetadata.displayName ?? skill?.displayName
|
||||
const selectedSummary = selectedMetadata?.summary ?? historicalMetadata.summary ?? skill?.summary
|
||||
const showingUnpublishedPreview = selectedVersionEntry != null && selectedVersionEntry.status !== 'PUBLISHED'
|
||||
|
||||
const { data: files } = useSkillFiles(qns, qslug, selectedVersion, skillReady)
|
||||
const documentationPath = resolveDocumentationFilePath(files)
|
||||
|
|
@ -1197,7 +1201,7 @@ export function SkillDetailPage() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">{t('skillDetail.version')}</div>
|
||||
<div className="max-w-[11rem] break-all text-right font-mono font-semibold leading-snug text-foreground">
|
||||
{headlineVersion ? `v${headlineVersion.version}` : '—'}
|
||||
{selectedVersion ? `v${selectedVersion}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
18
web/src/shared/lib/version-presentation.test.ts
Normal file
18
web/src/shared/lib/version-presentation.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getVersionPresentation } from './version-presentation'
|
||||
|
||||
describe('historical version presentation', () => {
|
||||
it('uses the explicit version override before original package metadata', () => {
|
||||
expect(getVersionPresentation(JSON.stringify({ name: 'slug', description: '旧版摘要', displayNameOverride: ' 网页名 ', frontmatter: { metadata: { 'display-name': '包内名' } } })))
|
||||
.toEqual({ displayName: '网页名', summary: '旧版摘要' })
|
||||
})
|
||||
it('keeps old package-only snapshots readable and falls back for unsupported metadata', () => {
|
||||
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': '包内名' } } })).displayName).toBe('包内名')
|
||||
for (const value of [123, '长'.repeat(201), null, ' ']) {
|
||||
expect(getVersionPresentation(JSON.stringify({ name: 'slug', frontmatter: { metadata: { 'display-name': value } } })).displayName).toBe('slug')
|
||||
}
|
||||
})
|
||||
it('tolerates absent, malformed and non-object snapshots', () => {
|
||||
for (const value of [undefined, '', '{', 'null', '[]', 'true']) expect(getVersionPresentation(value)).toEqual({})
|
||||
})
|
||||
})
|
||||
22
web/src/shared/lib/version-presentation.ts
Normal file
22
web/src/shared/lib/version-presentation.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** Read legacy version snapshots without rewriting the package or trusting arbitrary JSON. */
|
||||
export function getVersionPresentation(snapshot?: string): { displayName?: string; summary?: string } {
|
||||
if (!snapshot) return {}
|
||||
try {
|
||||
const value: unknown = JSON.parse(snapshot)
|
||||
if (!isObject(value)) return {}
|
||||
const frontmatter = isObject(value.frontmatter) ? value.frontmatter : {}
|
||||
const metadata = isObject(frontmatter.metadata) ? frontmatter.metadata : {}
|
||||
const displayName = validName(value.displayNameOverride) ?? validName(metadata['display-name'])
|
||||
?? (typeof value.name === 'string' ? value.name : undefined)
|
||||
return { displayName, summary: typeof value.description === 'string' ? value.description : undefined }
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
function validName(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.trim().length <= 200 ? value.trim() : undefined
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue