diff --git a/web/src/features/publish/folder-zip.test.ts b/web/src/features/publish/folder-zip.test.ts new file mode 100644 index 00000000..3568c806 --- /dev/null +++ b/web/src/features/publish/folder-zip.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { + collectFolderEntries, + createZipBlob, + crc32, + isIgnoredPath, + packageFolderAsZip, +} from './folder-zip' + +const utf8 = new TextEncoder() + +function fileAt(relativePath: string, content = 'x'): File { + const name = relativePath.split('/').pop() || relativePath + const file = new File([content], name) + Object.defineProperty(file, 'webkitRelativePath', { value: relativePath }) + return file +} + +async function bytesOf(blob: Blob): Promise { + return new Uint8Array(await blob.arrayBuffer()) +} + +describe('isIgnoredPath', () => { + it('keeps normal skill files', () => { + expect(isIgnoredPath('my-skill/SKILL.md')).toBe(false) + expect(isIgnoredPath('my-skill/scripts/run.sh')).toBe(false) + }) + + it('drops VCS, build and OS junk', () => { + expect(isIgnoredPath('my-skill/.git/config')).toBe(true) + expect(isIgnoredPath('my-skill/node_modules/x/index.js')).toBe(true) + expect(isIgnoredPath('my-skill/__pycache__/m.pyc')).toBe(true) + expect(isIgnoredPath('my-skill/.DS_Store')).toBe(true) + expect(isIgnoredPath('my-skill/._resource')).toBe(true) + expect(isIgnoredPath('my-skill/Thumbs.db')).toBe(true) + }) +}) + +describe('crc32', () => { + it('matches known CRC-32/ISO-HDLC vectors', () => { + expect(crc32(utf8.encode(''))).toBe(0x00000000) + expect(crc32(utf8.encode('a'))).toBe(0xe8b7be43) + expect(crc32(utf8.encode('abc'))).toBe(0x352441c2) + }) +}) + +describe('createZipBlob', () => { + it('writes a STORE archive with local, central and EOCD records', async () => { + const blob = createZipBlob([{ path: 'SKILL.md', data: utf8.encode('hello') }]) + const bytes = await bytesOf(blob) + const view = new DataView(bytes.buffer) + + // Local file header signature at offset 0. + expect(view.getUint32(0, true)).toBe(0x04034b50) + // Contains a central directory header and an end-of-central-directory record. + const eocd = bytes.length - 22 + expect(view.getUint32(eocd, true)).toBe(0x06054b50) + expect(view.getUint16(eocd + 10, true)).toBe(1) // total entries + // Central dir offset points at a central directory header signature. + const cdOffset = view.getUint32(eocd + 16, true) + expect(view.getUint32(cdOffset, true)).toBe(0x02014b50) + }) +}) + +describe('collectFolderEntries', () => { + it('filters junk and sorts remaining files by path', async () => { + const entries = await collectFolderEntries([ + fileAt('my-skill/scripts/run.sh', 'run'), + fileAt('my-skill/.git/config', 'gitcfg'), + fileAt('my-skill/SKILL.md', 'md'), + ]) + expect(entries.map((e) => e.path)).toEqual(['my-skill/SKILL.md', 'my-skill/scripts/run.sh']) + }) +}) + +describe('packageFolderAsZip', () => { + it('names the zip after the top-level folder', async () => { + const file = await packageFolderAsZip([fileAt('my-skill/SKILL.md', 'md')]) + expect(file.name).toBe('my-skill.zip') + expect(file.type).toBe('application/zip') + expect(file.size).toBeGreaterThan(0) + }) + + it('throws when everything was filtered out', async () => { + await expect(packageFolderAsZip([fileAt('my-skill/.git/config', 'x')])).rejects.toThrow( + 'empty-folder' + ) + }) +}) diff --git a/web/src/features/publish/folder-zip.ts b/web/src/features/publish/folder-zip.ts new file mode 100644 index 00000000..f10bcfa6 --- /dev/null +++ b/web/src/features/publish/folder-zip.ts @@ -0,0 +1,189 @@ +/** + * Dependency-free packaging of a selected folder into a skill ZIP. + * + * Browsers expose a picked folder as a flat FileList (each File carries a + * `webkitRelativePath` like `my-skill/SKILL.md`). We build a STORE-method ZIP + * (no compression — skill packages are small text files, and STORE keeps this + * dependency-free) from those files so the result flows through the exact same + * upload/publish path as a hand-made ZIP. + * + * We drop VCS/build/OS junk that a real on-disk folder almost always contains + * (`.git/`, `node_modules/`, `.DS_Store`, …) so it never bloats the package or + * the file-count limit. The server additionally strips a single root directory + * and OS-metadata entries, so paths are kept as-is (`my-skill/SKILL.md`). + */ + +/** Directory names whose entire subtree is excluded from the package. */ +const IGNORED_DIR_SEGMENTS = new Set([ + '.git', + '.svn', + '.hg', + 'node_modules', + '__pycache__', + '__MACOSX', +]) + +/** Exact file names that are always excluded. */ +const IGNORED_FILE_NAMES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']) + +/** Returns true if a relative path should be excluded from the package. */ +export function isIgnoredPath(relativePath: string): boolean { + const parts = relativePath.split('/') + const name = parts[parts.length - 1] + if (!name) return true // trailing slash / directory marker + if (parts.some((segment) => IGNORED_DIR_SEGMENTS.has(segment))) return true + if (IGNORED_FILE_NAMES.has(name)) return true + if (name.startsWith('._')) return true + if (name.endsWith('.pyc') || name.endsWith('.swp')) return true + return false +} + +// --- CRC-32 (IEEE 802.3, polynomial 0xEDB88320) ------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n++) { + let c = n + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c >>> 0 + } + return table +})() + +export function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (let i = 0; i < bytes.length; i++) { + crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8) + } + return (crc ^ 0xffffffff) >>> 0 +} + +// --- ZIP writer (STORE method, no data descriptors) --------------------------- + +export interface ZipEntry { + path: string + data: Uint8Array +} + +const utf8 = new TextEncoder() + +/** + * Builds a ZIP archive (STORE method) containing the given entries and returns + * it as a Blob. 32-bit size fields are used; skill packages are far below the + * 4 GB boundary where ZIP64 would be required. + */ +export function createZipBlob(entries: ZipEntry[]): Blob { + const localParts: Uint8Array[] = [] + const centralParts: Uint8Array[] = [] + let offset = 0 + + for (const entry of entries) { + const nameBytes = utf8.encode(entry.path) + const crc = crc32(entry.data) + const size = entry.data.length + + const local = new Uint8Array(30 + nameBytes.length) + const lv = new DataView(local.buffer) + lv.setUint32(0, 0x04034b50, true) // local file header signature + lv.setUint16(4, 20, true) // version needed + lv.setUint16(6, 0x0800, true) // flags: bit 11 = UTF-8 names + lv.setUint16(8, 0, true) // method: STORE + lv.setUint16(10, 0, true) // mod time + lv.setUint16(12, 0, true) // mod date + lv.setUint32(14, crc, true) + lv.setUint32(18, size, true) // compressed size (== uncompressed for STORE) + lv.setUint32(22, size, true) // uncompressed size + lv.setUint16(26, nameBytes.length, true) + lv.setUint16(28, 0, true) // extra length + local.set(nameBytes, 30) + + localParts.push(local, entry.data) + + const central = new Uint8Array(46 + nameBytes.length) + const cv = new DataView(central.buffer) + cv.setUint32(0, 0x02014b50, true) // central directory header signature + cv.setUint16(4, 20, true) // version made by + cv.setUint16(6, 20, true) // version needed + cv.setUint16(8, 0x0800, true) // flags + cv.setUint16(10, 0, true) // method: STORE + cv.setUint16(12, 0, true) // mod time + cv.setUint16(14, 0, true) // mod date + cv.setUint32(16, crc, true) + cv.setUint32(20, size, true) + cv.setUint32(24, size, true) + cv.setUint16(28, nameBytes.length, true) + cv.setUint16(30, 0, true) // extra length + cv.setUint16(32, 0, true) // comment length + cv.setUint16(34, 0, true) // disk number start + cv.setUint16(36, 0, true) // internal attrs + cv.setUint32(38, 0, true) // external attrs + cv.setUint32(42, offset, true) // relative offset of local header + central.set(nameBytes, 46) + centralParts.push(central) + + offset += local.length + entry.data.length + } + + const centralSize = centralParts.reduce((n, p) => n + p.length, 0) + const eocd = new Uint8Array(22) + const ev = new DataView(eocd.buffer) + ev.setUint32(0, 0x06054b50, true) // end of central directory signature + ev.setUint16(4, 0, true) // disk number + ev.setUint16(6, 0, true) // central dir start disk + ev.setUint16(8, entries.length, true) // entries on this disk + ev.setUint16(10, entries.length, true) // total entries + ev.setUint32(12, centralSize, true) // central dir size + ev.setUint32(16, offset, true) // central dir offset + ev.setUint16(20, 0, true) // comment length + + // Concatenate into a single buffer so the Blob part is a Uint8Array. + const parts = [...localParts, ...centralParts, eocd] + const total = parts.reduce((n, p) => n + p.length, 0) + const out = new Uint8Array(total) + let pos = 0 + for (const part of parts) { + out.set(part, pos) + pos += part.length + } + return new Blob([out], { type: 'application/zip' }) +} + +// --- Folder -> File ------------------------------------------------------------ + +/** Reads the picked folder's files into ZIP entries, skipping junk. */ +export async function collectFolderEntries(files: File[]): Promise { + const entries: ZipEntry[] = [] + for (const file of files) { + const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name + if (isIgnoredPath(path)) continue + const data = new Uint8Array(await file.arrayBuffer()) + entries.push({ path, data }) + } + entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) + return entries +} + +/** Top-level folder name of a webkitdirectory selection, for naming the zip. */ +function rootFolderName(files: File[]): string { + for (const file of files) { + const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath + if (rel && rel.includes('/')) return rel.slice(0, rel.indexOf('/')) + } + return 'skill' +} + +/** + * Packages a picked folder into a `.zip` File ready for the existing + * upload flow. Throws if every file was filtered out as junk. + */ +export async function packageFolderAsZip(fileList: FileList | File[]): Promise { + const files = Array.from(fileList) + const entries = await collectFolderEntries(files) + if (entries.length === 0) { + throw new Error('empty-folder') + } + const blob = createZipBlob(entries) + return new File([blob], `${rootFolderName(files)}.zip`, { type: 'application/zip' }) +} diff --git a/web/src/features/publish/upload-zone.tsx b/web/src/features/publish/upload-zone.tsx index b28fa92b..e5ab4c72 100644 --- a/web/src/features/publish/upload-zone.tsx +++ b/web/src/features/publish/upload-zone.tsx @@ -1,10 +1,12 @@ -import { useCallback } from 'react' +import { useCallback, useEffect, useRef, type ChangeEvent } from 'react' import { useTranslation } from 'react-i18next' import { useDropzone } from 'react-dropzone' import { cn } from '@/shared/lib/utils' interface UploadZoneProps { onFileSelect: (file: File) => void + /** Optional: called with the raw files of a picked folder (webkitdirectory). */ + onFolderSelect?: (files: File[]) => void disabled?: boolean } @@ -13,8 +15,20 @@ interface UploadZoneProps { * The component is intentionally stateless so packaging validation can remain in * the publish flow that knows the surrounding form and backend constraints. */ -export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { +export function UploadZone({ onFileSelect, onFolderSelect, disabled }: UploadZoneProps) { const { t } = useTranslation() + const folderInputRef = useRef(null) + + // `webkitdirectory` / `directory` are not in React's input attribute types; + // set them imperatively so the folder picker works without an untyped cast. + useEffect(() => { + const el = folderInputRef.current + if (el) { + el.setAttribute('webkitdirectory', '') + el.setAttribute('directory', '') + } + }, []) + const onDrop = useCallback( (acceptedFiles: File[]) => { if (acceptedFiles.length > 0) { @@ -33,44 +47,75 @@ export function UploadZone({ onFileSelect, disabled }: UploadZoneProps) { disabled, }) + const handleFolderChange = (event: ChangeEvent) => { + const files = event.target.files + if (files && files.length > 0) { + onFolderSelect?.(Array.from(files)) + } + // Reset so picking the same folder again re-triggers change. + event.target.value = '' + } + return ( -
- -
-
- - - -
- {isDragActive ? ( -

{t('upload.dropHint')}

- ) : ( - <> -

{t('upload.dragHint')}

-

{t('upload.formatHint')}

- +
+
+ +
+
+ + + +
+ {isDragActive ? ( +

{t('upload.dropHint')}

+ ) : ( + <> +

{t('upload.dragHint')}

+

{t('upload.formatHint')}

+ + )} +
+ {onFolderSelect && ( +
+ + +
+ )}
) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index dcfc28bf..f22f7cf9 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "Drop to upload...", "dragHint": "Drag a ZIP file here, or click to select", - "formatHint": "Only .zip format supported" + "formatHint": "Only .zip format supported", + "folderHint": "Or select a folder to package and upload" }, "layout": { "footerDescription": "Skill registry, providing efficient skill management and distribution for developers." @@ -1389,7 +1390,8 @@ "warningConfirmCancel": "Go back and fix", "frontmatterFailedTitle": "SKILL.md format is invalid", "frontmatterFailedDescription": "Please check the YAML frontmatter at the top of SKILL.md. If a field value contains a colon, wrap it in quotes.", - "selectRequired": "Please select namespace and file" + "selectRequired": "Please select namespace and file", + "folderPackagingFailed": "Could not package the selected folder. Make sure it contains files." }, "toast": { "success": "Success", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e2846cae..f858ce9a 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1212,7 +1212,8 @@ "upload": { "dropHint": "放开以上传文件...", "dragHint": "拖拽 ZIP 文件到此处,或点击选择", - "formatHint": "仅支持 .zip 格式" + "formatHint": "仅支持 .zip 格式", + "folderHint": "或选择文件夹,自动打包上传" }, "layout": { "footerDescription": "技能注册中心,为开发者提供高效的技能管理和分发平台。" @@ -1389,7 +1390,8 @@ "warningConfirmCancel": "返回修改", "frontmatterFailedTitle": "SKILL.md 格式有误", "frontmatterFailedDescription": "请检查 SKILL.md 顶部 frontmatter 的 YAML 格式。若字段值中包含冒号,请用引号包裹。", - "selectRequired": "请选择命名空间和文件" + "selectRequired": "请选择命名空间和文件", + "folderPackagingFailed": "无法打包所选文件夹,请确认其中包含文件。" }, "toast": { "success": "成功", diff --git a/web/src/pages/dashboard/publish.tsx b/web/src/pages/dashboard/publish.tsx index bcaeb16a..23770c85 100644 --- a/web/src/pages/dashboard/publish.tsx +++ b/web/src/pages/dashboard/publish.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { UploadZone } from '@/features/publish/upload-zone' +import { packageFolderAsZip } from '@/features/publish/folder-zip' import { extractPrecheckWarnings, isFrontmatterFailureMessage, @@ -41,6 +42,7 @@ export function PublishPage() { const [visibility, setVisibility] = useState(prefill.visibility) const [warningDialogOpen, setWarningDialogOpen] = useState(false) const [precheckWarnings, setPrecheckWarnings] = useState([]) + const [isPackaging, setIsPackaging] = useState(false) const { data: namespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const publishMutation = usePublishSkill() @@ -66,6 +68,18 @@ export function PublishPage() { setWarningDialogOpen(false) } + const handleFolderSelect = async (files: File[]) => { + setIsPackaging(true) + try { + const zip = await packageFolderAsZip(files) + handleFileSelect(zip) + } catch { + toast.error(t('publish.folderPackagingFailed')) + } finally { + setIsPackaging(false) + } + } + const publishSkill = async (confirmWarnings = false) => { if (!selectedFile || !namespaceSlug) { toast.error(t('publish.selectRequired')) @@ -201,7 +215,8 @@ export function PublishPage() { {selectedFile && (