From 6d564e05394d0186a85d8c0f0bc0ddf7a4bffa86 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 19 Mar 2026 07:23:36 -0500 Subject: [PATCH] Support binary portability files in UI and CLI --- cli/src/commands/client/company.ts | 49 ++++++++++++++++++---- ui/src/components/PackageFileTree.tsx | 2 +- ui/src/lib/zip.ts | 60 ++++++++++++++++++++++++--- ui/src/pages/CompanyExport.tsx | 48 +++++++++++++++------ ui/src/pages/CompanyImport.tsx | 32 ++++++++++---- 5 files changed, 155 insertions(+), 36 deletions(-) diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index b725a451..05cba06e 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -4,6 +4,7 @@ import path from "node:path"; import * as p from "@clack/prompts"; import type { Company, + CompanyPortabilityFileEntry, CompanyPortabilityExportResult, CompanyPortabilityInclude, CompanyPortabilityPreviewResult, @@ -50,6 +51,30 @@ interface CompanyImportOptions extends BaseClientOptions { dryRun?: boolean; } +const binaryContentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry { + const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()]; + if (!contentType) return contents.toString("utf8"); + return { + encoding: "base64", + data: contents.toString("base64"), + contentType, + }; +} + +function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array { + if (typeof entry === "string") return entry; + return Buffer.from(entry.data, "base64"); +} + function isUuidLike(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } @@ -95,7 +120,11 @@ function isGithubUrl(input: string): boolean { return /^https?:\/\/github\.com\//i.test(input.trim()); } -async function collectPackageFiles(root: string, current: string, files: Record): Promise { +async function collectPackageFiles( + root: string, + current: string, + files: Record, +): Promise { const entries = await readdir(current, { withFileTypes: true }); for (const entry of entries) { if (entry.name.startsWith(".git")) continue; @@ -107,20 +136,21 @@ async function collectPackageFiles(root: string, current: string, files: Record< if (!entry.isFile()) continue; const isMarkdown = entry.name.endsWith(".md"); const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml"; - if (!isMarkdown && !isPaperclipYaml) continue; + const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()]; + if (!isMarkdown && !isPaperclipYaml && !contentType) continue; const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/"); - files[relativePath] = await readFile(absolutePath, "utf8"); + files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath)); } } async function resolveInlineSourceFromPath(inputPath: string): Promise<{ rootPath: string; - files: Record; + files: Record; }> { const resolved = path.resolve(inputPath); const resolvedStat = await stat(resolved); const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved); - const files: Record = {}; + const files: Record = {}; await collectPackageFiles(rootDir, rootDir, files); return { rootPath: path.basename(rootDir), @@ -135,7 +165,12 @@ async function writeExportToFolder(outDir: string, exported: CompanyPortabilityE const normalized = relativePath.replace(/\\/g, "/"); const filePath = path.join(root, normalized); await mkdir(path.dirname(filePath), { recursive: true }); - await writeFile(filePath, content, "utf8"); + const writeValue = portableFileEntryToWriteValue(content); + if (typeof writeValue === "string") { + await writeFile(filePath, writeValue, "utf8"); + } else { + await writeFile(filePath, writeValue); + } } } @@ -397,7 +432,7 @@ export function registerCompanyCommands(program: Command): void { } let sourcePayload: - | { type: "inline"; rootPath?: string | null; files: Record } + | { type: "inline"; rootPath?: string | null; files: Record } | { type: "url"; url: string } | { type: "github"; url: string }; diff --git a/ui/src/components/PackageFileTree.tsx b/ui/src/components/PackageFileTree.tsx index a99327e6..5429328d 100644 --- a/ui/src/components/PackageFileTree.tsx +++ b/ui/src/components/PackageFileTree.tsx @@ -27,7 +27,7 @@ const TREE_ROW_HEIGHT_CLASS = "min-h-9"; // ── Helpers ─────────────────────────────────────────────────────────── export function buildFileTree( - files: Record, + files: Record, actionMap?: Map, ): FileTreeNode[] { const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; diff --git a/ui/src/lib/zip.ts b/ui/src/lib/zip.ts index ae9ee9b1..671a1f76 100644 --- a/ui/src/lib/zip.ts +++ b/ui/src/lib/zip.ts @@ -1,3 +1,5 @@ +import type { CompanyPortabilityFileEntry } from "@paperclipai/shared"; + const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -88,12 +90,58 @@ function sharedArchiveRoot(paths: string[]) { : null; } +const binaryContentTypeByExtension: Record = { + ".gif": "image/gif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", +}; + +function inferBinaryContentType(pathValue: string) { + const normalized = normalizeArchivePath(pathValue); + const extensionIndex = normalized.lastIndexOf("."); + if (extensionIndex === -1) return null; + return binaryContentTypeByExtension[normalized.slice(extensionIndex).toLowerCase()] ?? null; +} + +function bytesToBase64(bytes: Uint8Array) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function base64ToBytes(base64: string) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} + +function bytesToPortableFileEntry(pathValue: string, bytes: Uint8Array): CompanyPortabilityFileEntry { + const contentType = inferBinaryContentType(pathValue); + if (!contentType) return textDecoder.decode(bytes); + return { + encoding: "base64", + data: bytesToBase64(bytes), + contentType, + }; +} + +function portableFileEntryToBytes(entry: CompanyPortabilityFileEntry): Uint8Array { + if (typeof entry === "string") return textEncoder.encode(entry); + return base64ToBytes(entry.data); +} + export function readZipArchive(source: ArrayBuffer | Uint8Array): { rootPath: string | null; - files: Record; + files: Record; } { const bytes = source instanceof Uint8Array ? source : new Uint8Array(source); - const entries: Array<{ path: string; body: string }> = []; + const entries: Array<{ path: string; body: CompanyPortabilityFileEntry }> = []; let offset = 0; while (offset + 4 <= bytes.length) { @@ -133,7 +181,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): { if (archivePath && !archivePath.endsWith("/")) { entries.push({ path: archivePath, - body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)), + body: bytesToPortableFileEntry(archivePath, bytes.slice(bodyOffset, bodyEnd)), }); } @@ -141,7 +189,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): { } const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path)); - const files: Record = {}; + const files: Record = {}; for (const entry of entries) { const normalizedPath = rootPath && entry.path.startsWith(`${rootPath}/`) @@ -154,7 +202,7 @@ export function readZipArchive(source: ArrayBuffer | Uint8Array): { return { rootPath, files }; } -export function createZipArchive(files: Record, rootPath: string): Uint8Array { +export function createZipArchive(files: Record, rootPath: string): Uint8Array { const normalizedRoot = normalizeArchivePath(rootPath); const localChunks: Uint8Array[] = []; const centralChunks: Uint8Array[] = []; @@ -165,7 +213,7 @@ export function createZipArchive(files: Record, rootPath: string for (const [relativePath, contents] of Object.entries(files).sort(([left], [right]) => left.localeCompare(right))) { const archivePath = normalizeArchivePath(`${normalizedRoot}/${relativePath}`); const fileName = textEncoder.encode(archivePath); - const body = textEncoder.encode(contents); + const body = portableFileEntryToBytes(contents); const checksum = crc32(body); const localHeader = new Uint8Array(30 + fileName.length); diff --git a/ui/src/pages/CompanyExport.tsx b/ui/src/pages/CompanyExport.tsx index 28189490..200ce50e 100644 --- a/ui/src/pages/CompanyExport.tsx +++ b/ui/src/pages/CompanyExport.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import type { + CompanyPortabilityFileEntry, CompanyPortabilityExportPreviewResult, CompanyPortabilityExportResult, CompanyPortabilityManifest, @@ -16,6 +17,7 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { MarkdownBody } from "../components/MarkdownBody"; import { cn } from "../lib/utils"; import { createZipArchive } from "../lib/zip"; +import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files"; import { Download, Package, @@ -145,7 +147,13 @@ function filterPaperclipYaml(yaml: string, checkedFiles: Set): string { // Flush last section flushSection(); - return out.join("\n"); + let filtered = out.join("\n"); + const logoPathMatch = filtered.match(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*$/m); + if (logoPathMatch && !checkedFiles.has(logoPathMatch[1]!)) { + filtered = filtered.replace(/^\s{2}logoPath:\s*["']?([^"'\n]+)["']?\s*\n?/m, ""); + } + + return filtered; } /** Filter tree nodes whose path (or descendant paths) match a search string */ @@ -263,9 +271,9 @@ function paginateTaskNodes( function downloadZip( exported: CompanyPortabilityExportResult, selectedFiles: Set, - effectiveFiles: Record, + effectiveFiles: Record, ) { - const filteredFiles: Record = {}; + const filteredFiles: Record = {}; for (const [path] of Object.entries(exported.files)) { if (selectedFiles.has(path)) filteredFiles[path] = effectiveFiles[path] ?? exported.files[path]; } @@ -465,7 +473,7 @@ function ExportPreviewPane({ onSkillClick, }: { selectedFile: string | null; - content: string | null; + content: CompanyPortabilityFileEntry | null; onSkillClick?: (skill: string) => void; }) { if (!selectedFile || content === null) { @@ -474,8 +482,10 @@ function ExportPreviewPane({ ); } - const isMarkdown = selectedFile.endsWith(".md"); - const parsed = isMarkdown ? parseFrontmatter(content) : null; + const textContent = getPortableFileText(content); + const isMarkdown = selectedFile.endsWith(".md") && textContent !== null; + const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null; + const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null; return (
@@ -489,11 +499,19 @@ function ExportPreviewPane({ {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( - {content} - ) : ( + {textContent ?? ""} + ) : imageSrc ? ( +
+ {selectedFile} +
+ ) : textContent !== null ? (
-            {content}
+            {textContent}
           
+ ) : ( +
+ Binary asset preview is not available for this file type. +
)}
@@ -674,17 +692,17 @@ export function CompanyExport() { // Recompute .paperclip.yaml and README.md content whenever checked files // change so the preview & download always reflect the current selection. const effectiveFiles = useMemo(() => { - if (!exportData) return {} as Record; + if (!exportData) return {} as Record; const filtered = { ...exportData.files }; // Filter .paperclip.yaml const yamlPath = exportData.paperclipExtensionPath; - if (yamlPath && exportData.files[yamlPath]) { + if (yamlPath && typeof exportData.files[yamlPath] === "string") { filtered[yamlPath] = filterPaperclipYaml(exportData.files[yamlPath], checkedFiles); } // Regenerate README.md based on checked selection - if (exportData.files["README.md"]) { + if (typeof exportData.files["README.md"] === "string") { const companyName = exportData.manifest.company?.name ?? selectedCompany?.name ?? "Company"; const companyDescription = exportData.manifest.company?.description ?? null; filtered["README.md"] = generateReadmeFromSelection( @@ -818,7 +836,11 @@ export function CompanyExport() { return ; } - const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null; + const previewContent = selectedFile + ? (() => { + return effectiveFiles[selectedFile] ?? null; + })() + : null; return (
diff --git a/ui/src/pages/CompanyImport.tsx b/ui/src/pages/CompanyImport.tsx index d59e3c96..20220dab 100644 --- a/ui/src/pages/CompanyImport.tsx +++ b/ui/src/pages/CompanyImport.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { CompanyPortabilityCollisionStrategy, + CompanyPortabilityFileEntry, CompanyPortabilityPreviewResult, CompanyPortabilitySource, CompanyPortabilityAdapterOverride, @@ -41,6 +42,7 @@ import { PackageFileTree, } from "../components/PackageFileTree"; import { readZipArchive } from "../lib/zip"; +import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files"; // ── Import-specific helpers ─────────────────────────────────────────── @@ -179,7 +181,7 @@ function ImportPreviewPane({ renamedTo, }: { selectedFile: string | null; - content: string | null; + content: CompanyPortabilityFileEntry | null; action: string | null; renamedTo: string | null; }) { @@ -189,8 +191,10 @@ function ImportPreviewPane({ ); } - const isMarkdown = selectedFile.endsWith(".md"); - const parsed = isMarkdown ? parseFrontmatter(content) : null; + const textContent = getPortableFileText(content); + const isMarkdown = selectedFile.endsWith(".md") && textContent !== null; + const parsed = isMarkdown && textContent ? parseFrontmatter(textContent) : null; + const imageSrc = isPortableImageFile(selectedFile, content) ? getPortableFileDataUrl(selectedFile, content) : null; const actionColor = action ? (ACTION_COLORS[action] ?? ACTION_COLORS.skip) : ""; return ( @@ -222,11 +226,19 @@ function ImportPreviewPane({ {parsed.body.trim() && {parsed.body}} ) : isMarkdown ? ( - {content} - ) : ( + {textContent ?? ""} + ) : imageSrc ? ( +
+ {selectedFile} +
+ ) : textContent !== null ? (
-            {content}
+            {textContent}
           
+ ) : ( +
+ Binary asset preview is not available for this file type. +
)}
@@ -557,7 +569,7 @@ function AdapterPickerList({ async function readLocalPackageZip(file: File): Promise<{ name: string; rootPath: string | null; - files: Record; + files: Record; }> { if (!/\.zip$/i.test(file.name)) { throw new Error("Select a .zip company package."); @@ -592,7 +604,7 @@ export function CompanyImport() { const [localPackage, setLocalPackage] = useState<{ name: string; rootPath: string | null; - files: Record; + files: Record; } | null>(null); // Target state @@ -990,7 +1002,9 @@ export function CompanyImport() { const hasErrors = importPreview ? importPreview.errors.length > 0 : false; const previewContent = selectedFile && importPreview - ? (importPreview.files[selectedFile] ?? null) + ? (() => { + return importPreview.files[selectedFile] ?? null; + })() : null; const selectedAction = selectedFile ? (actionMap.get(selectedFile) ?? null) : null;