Support binary portability files in UI and CLI
This commit is contained in:
@@ -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<string, string> = {
|
||||
".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<string, string>): Promise<void> {
|
||||
async function collectPackageFiles(
|
||||
root: string,
|
||||
current: string,
|
||||
files: Record<string, CompanyPortabilityFileEntry>,
|
||||
): Promise<void> {
|
||||
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<string, string>;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
}> {
|
||||
const resolved = path.resolve(inputPath);
|
||||
const resolvedStat = await stat(resolved);
|
||||
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
||||
const files: Record<string, string> = {};
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
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<string, string> }
|
||||
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
|
||||
| { type: "url"; url: string }
|
||||
| { type: "github"; url: string };
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ const TREE_ROW_HEIGHT_CLASS = "min-h-9";
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
export function buildFileTree(
|
||||
files: Record<string, string>,
|
||||
files: Record<string, unknown>,
|
||||
actionMap?: Map<string, string>,
|
||||
): FileTreeNode[] {
|
||||
const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] };
|
||||
|
||||
@@ -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<string, string> = {
|
||||
".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<string, string>;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
} {
|
||||
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<string, string> = {};
|
||||
const files: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
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<string, string>, rootPath: string): Uint8Array {
|
||||
export function createZipArchive(files: Record<string, CompanyPortabilityFileEntry>, rootPath: string): Uint8Array {
|
||||
const normalizedRoot = normalizeArchivePath(rootPath);
|
||||
const localChunks: Uint8Array[] = [];
|
||||
const centralChunks: Uint8Array[] = [];
|
||||
@@ -165,7 +213,7 @@ export function createZipArchive(files: Record<string, string>, 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);
|
||||
|
||||
@@ -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>): 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<string>,
|
||||
effectiveFiles: Record<string, string>,
|
||||
effectiveFiles: Record<string, CompanyPortabilityFileEntry>,
|
||||
) {
|
||||
const filteredFiles: Record<string, string> = {};
|
||||
const filteredFiles: Record<string, CompanyPortabilityFileEntry> = {};
|
||||
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 (
|
||||
<div className="min-w-0">
|
||||
@@ -489,11 +499,19 @@ function ExportPreviewPane({
|
||||
{parsed.body.trim() && <MarkdownBody>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody>{content}</MarkdownBody>
|
||||
) : (
|
||||
<MarkdownBody>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
</div>
|
||||
) : textContent !== null ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{content}</code>
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
|
||||
Binary asset preview is not available for this file type.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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<string, string>;
|
||||
if (!exportData) return {} as Record<string, CompanyPortabilityFileEntry>;
|
||||
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 <EmptyState icon={Package} message="Loading export data..." />;
|
||||
}
|
||||
|
||||
const previewContent = selectedFile ? (effectiveFiles[selectedFile] ?? null) : null;
|
||||
const previewContent = selectedFile
|
||||
? (() => {
|
||||
return effectiveFiles[selectedFile] ?? null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -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() && <MarkdownBody>{parsed.body}</MarkdownBody>}
|
||||
</>
|
||||
) : isMarkdown ? (
|
||||
<MarkdownBody>{content}</MarkdownBody>
|
||||
) : (
|
||||
<MarkdownBody>{textContent ?? ""}</MarkdownBody>
|
||||
) : imageSrc ? (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-lg border border-border bg-accent/10 p-6">
|
||||
<img src={imageSrc} alt={selectedFile} className="max-h-[480px] max-w-full object-contain" />
|
||||
</div>
|
||||
) : textContent !== null ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words border-0 bg-transparent p-0 font-mono text-sm text-foreground">
|
||||
<code>{content}</code>
|
||||
<code>{textContent}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-accent/10 px-4 py-3 text-sm text-muted-foreground">
|
||||
Binary asset preview is not available for this file type.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -557,7 +569,7 @@ function AdapterPickerList({
|
||||
async function readLocalPackageZip(file: File): Promise<{
|
||||
name: string;
|
||||
rootPath: string | null;
|
||||
files: Record<string, string>;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
}> {
|
||||
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<string, string>;
|
||||
files: Record<string, CompanyPortabilityFileEntry>;
|
||||
} | 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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user