From 1f8a082d5c4b08ee44fd4a07bdfeb2184b37c29c Mon Sep 17 00:00:00 2001 From: Ewan Croft Date: Thu, 16 Apr 2026 00:30:30 +0100 Subject: [PATCH] Bump Jasper CLI version to 0.2.0 and add browser-compatible import functions --- .pre-commit-config.yaml | 2 +- package.json | 5 + packages/jasper-web/package.json | 1 + .../jasper-web/src/routes/import/+page.svelte | 64 +++- packages/jasper/package.json | 5 +- packages/jasper/src/core/config.ts | 2 +- packages/jasper/src/core/types.ts | 2 + packages/jasper/src/lib/browser.ts | 302 ++++++++++++++++++ packages/jasper/src/lib/image-utils.ts | 77 +++++ packages/jasper/src/lib/publisher.ts | 11 +- pnpm-lock.yaml | 15 +- scripts/prettier-hook.sh | 25 ++ 12 files changed, 492 insertions(+), 19 deletions(-) create mode 100644 packages/jasper/src/lib/browser.ts create mode 100755 scripts/prettier-hook.sh diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 93fbfca..694712c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: hooks: - id: prettier name: prettier - entry: pnpm exec prettier --write --ignore-unknown + entry: ./scripts/prettier-hook.sh language: system types_or: [javascript, jsx, ts, tsx, json, yaml, markdown, css, svelte] pass_filenames: true diff --git a/package.json b/package.json index 2a19262..a3f3e9d 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,10 @@ "engines": { "node": ">=18", "pnpm": ">=9" + }, + "devDependencies": { + "prettier": "^3.8.3", + "prettier-plugin-svelte": "^3.5.1", + "prettier-plugin-tailwindcss": "^0.7.2" } } diff --git a/packages/jasper-web/package.json b/packages/jasper-web/package.json index 50dc9de..f51cfe4 100644 --- a/packages/jasper-web/package.json +++ b/packages/jasper-web/package.json @@ -47,6 +47,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.6.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.4.1", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/packages/jasper-web/src/routes/import/+page.svelte b/packages/jasper-web/src/routes/import/+page.svelte index e5fda26..fac0025 100644 --- a/packages/jasper-web/src/routes/import/+page.svelte +++ b/packages/jasper-web/src/routes/import/+page.svelte @@ -4,6 +4,7 @@ import { cubicOut } from 'svelte/easing'; import type { Agent } from '@atproto/api'; import { initOAuth, signInWithOAuth } from '$lib/core/oauth'; + import { runImport } from '@ewanc26/jasper/browser'; import logo from '$lib/assets/favicon.svg'; import { Upload, Loader2, ArrowRight } from '@lucide/svelte'; @@ -53,17 +54,31 @@ result = null; goTo(2); - // Placeholder for actual import logic - logs.push({ level: 'info', message: 'Import functionality coming soon...' }); - logs.push({ level: 'info', message: `File: ${file.name}` }); - logs.push({ level: 'info', message: `Agent: ${agent.did}` }); - - // Simulate completion - setTimeout(() => { - result = { success: 0, errors: 0 }; - logs.push({ level: 'success', message: dryRun ? 'Dry run complete' : 'Import complete' }); + try { + const importResult = await runImport( + agent, + file, + dryRun, + (current, total) => { + progress = { current, total }; + }, + (level, message) => { + logs = [...logs, { level, message }]; + } + ); + + result = { success: importResult.success, errors: importResult.errors }; + logs = [ + ...logs, + { level: 'success', message: dryRun ? 'Dry run complete' : 'Import complete' } + ]; + } catch (error) { + logs = [...logs, { level: 'error', message: `Import failed: ${error}` }]; + result = { success: 0, errors: 1 }; + } finally { isRunning = false; - }, 2000); + progress = null; + } } function handleReset() { @@ -179,6 +194,15 @@
Processing... + {#if progress} +
+
+
+ {progress.current} / {progress.total} + {/if}
{/if} @@ -320,12 +344,32 @@ .running-indicator { display: flex; + flex-direction: column; align-items: center; gap: 0.5rem; color: var(--accent); margin-bottom: 1rem; } + .progress-bar { + width: 100%; + height: 4px; + background: var(--border); + border-radius: 2px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.3s ease; + } + + .progress-text { + font-size: 0.8rem; + font-family: 'JetBrains Mono', monospace; + } + .logs { font-family: 'JetBrains Mono', monospace; font-size: 0.8rem; diff --git a/packages/jasper/package.json b/packages/jasper/package.json index c502f44..cecbfca 100644 --- a/packages/jasper/package.json +++ b/packages/jasper/package.json @@ -1,6 +1,6 @@ { "name": "@ewanc26/jasper", - "version": "0.1.0", + "version": "0.2.0", "description": "Convert Instagram data exports into posts on Grain.social", "author": "Ewan Croft", "license": "AGPL-3.0-only", @@ -34,7 +34,8 @@ "LICENCE" ], "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./browser": "./dist/lib/browser.js" }, "main": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/packages/jasper/src/core/config.ts b/packages/jasper/src/core/config.ts index 52a628b..1b30c35 100644 --- a/packages/jasper/src/core/config.ts +++ b/packages/jasper/src/core/config.ts @@ -4,7 +4,7 @@ import type { Config } from "./types.js"; -export const VERSION = "0.1.0"; +export const VERSION = "0.2.0"; export const config: Config = { /** Grain lexicon NSID for photos */ diff --git a/packages/jasper/src/core/types.ts b/packages/jasper/src/core/types.ts index a3e15f4..c9d6864 100644 --- a/packages/jasper/src/core/types.ts +++ b/packages/jasper/src/core/types.ts @@ -52,6 +52,8 @@ export interface ParsedMedia { originalUri: string; /** Creation timestamp */ timestamp: Date; + /** Loaded data (for browser) */ + data?: Blob; } export interface ParsedPost { diff --git a/packages/jasper/src/lib/browser.ts b/packages/jasper/src/lib/browser.ts new file mode 100644 index 0000000..4adb6f4 --- /dev/null +++ b/packages/jasper/src/lib/browser.ts @@ -0,0 +1,302 @@ +/** + * Browser-compatible functions for Jasper + * Used by jasper-web to run the import pipeline in-browser + */ + +import { + BlobReader, + BlobWriter, + TextWriter, + ZipReader, + type FileEntry, +} from "@zip.js/zip.js"; +import type { + InstagramExportPost, + ParsedPost, + ParsedMedia, +} from "../core/types.js"; +import { log } from "../utils/logger.js"; +import path from "path"; +import { fixFacebookEncoding } from "./parser.js"; +import { publishPhoto } from "./publisher.js"; +import type { Agent } from "@atproto/api"; + +/** + * Get the file extension from a path + */ +function getExtension(filePath: string): string { + return path.extname(filePath).toLowerCase(); +} + +/** + * Check if a file is a video + */ +function isVideo(filePath: string): boolean { + const ext = getExtension(filePath); + return ext === ".mp4" || ext === ".mov"; +} + +/** + * Determine media type from path + */ +function getMediaType(filePath: string): "image" | "video" { + if (isVideo(filePath)) return "video"; + return "image"; +} + +/** + * Parse Instagram export from a File (browser) + */ +export async function parseExportFromFile(file: File): Promise { + log.info(`Reading ZIP file: ${file.name}`); + + const zipReader = new ZipReader(new BlobReader(file)); + const entries = (await zipReader.getEntries()) as FileEntry[]; + + // Find posts_1.json + const postsEntry = findPostsJsonInZip(entries); + if (!postsEntry) { + await zipReader.close(); + throw new Error("posts_1.json not found in ZIP archive"); + } + + log.debug(`Found posts_1.json at: ${postsEntry.filename}`); + + // Extract and parse + const postsText = await postsEntry.getData(new TextWriter()); + + const posts = parsePostsJson(postsText); + + // Filter and log video posts + const imagePosts: ParsedPost[] = []; + for (const post of posts) { + const hasVideo = post.media.some((m) => m.type === "video"); + if (hasVideo) { + post.skipped = true; + post.skipReason = "Contains video(s) — Grain does not support videos yet"; + log.warn(`Skipping video post from ${post.createdAt.toISOString()}`); + continue; + } + + imagePosts.push(post); + } + + // Load media data for each post + for (const post of imagePosts) { + if (post.skipped) continue; + + for (const media of post.media) { + if (media.type === "image") { + try { + const mediaEntry = findMediaInZip(entries, media.originalUri); + if (mediaEntry) { + media.data = await mediaEntry.getData(new BlobWriter()); + } else { + log.warn(`Media file not found: ${media.originalUri}`); + post.skipped = true; + post.skipReason = `Media file missing: ${media.originalUri}`; + } + } catch (error) { + log.error(`Failed to load media ${media.originalUri}: ${error}`); + post.skipped = true; + post.skipReason = `Failed to load media: ${error}`; + } + } + } + } + + await zipReader.close(); + + log.info(`Parsed ${imagePosts.length} posts from ZIP`); + return imagePosts; +} + +/** + * Find posts_1.json in the ZIP entries + */ +function findPostsJsonInZip(entries: FileEntry[]): FileEntry | null { + for (const entry of entries) { + if (entry.filename.endsWith("posts_1.json")) { + return entry; + } + } + return null; +} + +/** + * Find a media file in the ZIP entries + */ +function findMediaInZip(entries: FileEntry[], relativePath: string): FileEntry | null { + // Instagram exports have media in various subdirectories + // Try different possible paths + const possiblePaths = [ + relativePath, + `media/${relativePath}`, + `photos/${relativePath}`, + `videos/${relativePath}`, + ]; + + for (const path of possiblePaths) { + for (const entry of entries) { + if (entry.filename === path || entry.filename.endsWith(`/${path}`)) { + return entry; + } + } + } + + return null; +} + +/** + * Parse posts_1.json content + */ +function parsePostsJson( + postsText: string, +): ParsedPost[] { + log.debug("Parsing posts_1.json"); + + // Fix encoding issues + const fixedText = fixFacebookEncoding(postsText); + + let rawPosts: InstagramExportPost[]; + try { + rawPosts = JSON.parse(fixedText); + } catch (error) { + throw new Error(`Failed to parse posts_1.json: ${error}`); + } + + log.debug(`Found ${rawPosts.length} raw posts`); + + const posts: ParsedPost[] = []; + + for (const rawPost of rawPosts) { + try { + const post = parsePost(rawPost); + if (post) { + posts.push(post); + } + } catch (error) { + log.warn(`Failed to parse post: ${error}`); + } + } + + return posts; +} + +/** + * Parse a single Instagram post + */ +function parsePost( + rawPost: InstagramExportPost, +): ParsedPost | null { + // Skip if no media + if (!rawPost.media || rawPost.media.length === 0) { + return null; + } + + // Use the first media item's timestamp as the post timestamp + const firstMedia = rawPost.media[0]; + const timestamp = new Date(firstMedia.creation_timestamp * 1000); + + // Generate ID from timestamp + const id = timestamp.toISOString(); + + const media: ParsedMedia[] = []; + + for (const rawMedia of rawPost.media) { + const mediaItem: ParsedMedia = { + path: rawMedia.uri, + type: getMediaType(rawMedia.uri), + originalUri: rawMedia.uri, + timestamp: new Date(rawMedia.creation_timestamp * 1000), + }; + + // In browser, we load data later from ZIP + media.push(mediaItem); + } + + return { + id, + caption: rawPost.title || undefined, + createdAt: timestamp, + media, + }; +} + +/** + * Browser-compatible publishPhoto that accepts Blob + */ +export async function publishPhotoFromBlob( + agent: Agent, + imageBlob: Blob, + aspectRatio: { width: number; height: number }, + createdAt: string, + alt?: string, + dryRun = false, +): Promise<{ success: boolean; uri?: string; cid?: string; error?: string }> { + // Convert Blob to Uint8Array + const arrayBuffer = await imageBlob.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); + + return publishPhoto(agent, uint8Array, aspectRatio, createdAt, alt, dryRun); +} + +/** + * Run the full import process in browser + */ +export async function runImport( + agent: Agent, + file: File, + dryRun: boolean, + onProgress?: (current: number, total: number) => void, + onLog?: (level: string, message: string) => void, +): Promise<{ success: number; errors: number }> { + const logger = onLog ? { info: (msg: string) => onLog('info', msg), warn: (msg: string) => onLog('warn', msg), error: (msg: string) => onLog('error', msg) } : log; + + try { + logger.info('Parsing Instagram export...'); + const posts = await parseExportFromFile(file); + + const validPosts = posts.filter(p => !p.skipped); + logger.info(`Found ${validPosts.length} posts to import`); + + let success = 0; + let errors = 0; + + for (let i = 0; i < validPosts.length; i++) { + const post = validPosts[i]; + onProgress?.(i + 1, validPosts.length); + + for (const media of post.media) { + if (media.type === 'image' && media.data) { + try { + logger.info(`Publishing photo from ${post.createdAt.toISOString()}`); + const result = await publishPhotoFromBlob( + agent, + media.data, + { width: 1, height: 1 }, // TODO: get actual aspect ratio + post.createdAt.toISOString(), + post.caption, + dryRun + ); + + if (result.success) { + success++; + } else { + errors++; + logger.error(`Failed to publish: ${result.error}`); + } + } catch (error) { + errors++; + logger.error(`Error publishing photo: ${error}`); + } + } + } + } + + return { success, errors }; + } catch (error) { + logger.error(`Import failed: ${error}`); + throw error; + } +} \ No newline at end of file diff --git a/packages/jasper/src/lib/image-utils.ts b/packages/jasper/src/lib/image-utils.ts index f277afc..74abd2f 100644 --- a/packages/jasper/src/lib/image-utils.ts +++ b/packages/jasper/src/lib/image-utils.ts @@ -200,3 +200,80 @@ export async function validateImage( }; } } + +// ============================================ +// Browser-compatible functions +// ============================================ + +/** + * Browser-compatible processImage that accepts Uint8Array + */ +export async function processImageBrowser(data: Uint8Array): Promise { + // For browser, we'll skip resizing for now and just return the original + // TODO: Implement browser-based image resizing using Canvas API + + const mimeType = getMimeTypeFromData(data); + + // Get dimensions using Image + const dimensions = await getImageDimensionsBrowser(data); + + const aspectRatio = calculateAspectRatio(dimensions.width, dimensions.height); + + return { + original: Buffer.from(data), + processed: Buffer.from(data), // No processing for now + dimensions, + aspectRatio, + mimeType, + size: data.length, + wasResized: false, + }; +} + +/** + * Get image dimensions in browser using Image API + */ +export async function getImageDimensionsBrowser(data: Uint8Array): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new (globalThis as any).Image(); + const url = URL.createObjectURL(new Blob([data as any])); + + img.onload = () => { + URL.revokeObjectURL(url); + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Failed to load image')); + }; + + img.src = url; + }); +} + +/** + * Get MIME type from image data + */ +function getMimeTypeFromData(data: Uint8Array): string { + // Check magic bytes + if (data.length >= 2) { + const firstBytes = data[0] << 8 | data[1]; + if (firstBytes === 0xFFD8) return 'image/jpeg'; + if (firstBytes === 0x8950) return 'image/png'; + if (data.length >= 4 && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46) return 'image/webp'; + } + return 'image/jpeg'; // fallback +} + +/** + * Browser-compatible validateImage + */ +export async function validateImageBrowser(data: Uint8Array): Promise<{ valid: boolean; error?: string }> { + try { + await getImageDimensionsBrowser(data); + return { valid: true }; + } catch (error) { + return { valid: false, error: `Invalid image: ${(error as Error).message}` }; + } +} diff --git a/packages/jasper/src/lib/publisher.ts b/packages/jasper/src/lib/publisher.ts index 87421c5..8d38388 100644 --- a/packages/jasper/src/lib/publisher.ts +++ b/packages/jasper/src/lib/publisher.ts @@ -7,7 +7,7 @@ import { generateTID } from "@ewanc26/tid"; import type { ParsedPost } from "../core/types.js"; import { config } from "../core/config.js"; import { log } from "../utils/logger.js"; -import { processImage, validateImage } from "./image-utils.js"; +import { processImage, processImageBrowser, validateImage } from "./image-utils.js"; /** * Result of publishing a single photo @@ -46,7 +46,7 @@ async function uploadBlob( */ export async function publishPhoto( agent: Agent, - imageData: Buffer, + imageData: Buffer | Uint8Array, _aspectRatio: { width: number; height: number }, createdAt: string, alt?: string, @@ -58,8 +58,11 @@ export async function publishPhoto( } try { - // Process image (resize if needed) - const processed = await processImage(imageData); + // Process image (resize if needed) - use browser or Node version + const isBrowser = typeof globalThis !== 'undefined' && 'window' in globalThis; + const processed = isBrowser + ? await processImageBrowser(imageData as Uint8Array) + : await processImage(imageData as Buffer); // Upload blob const blob = await uploadBlob( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c25e50a..0bee681 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,17 @@ settings: importers: - .: {} + .: + devDependencies: + prettier: + specifier: ^3.8.3 + version: 3.8.3 + prettier-plugin-svelte: + specifier: ^3.5.1 + version: 3.5.1(prettier@3.8.3)(svelte@5.55.4) + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier-plugin-svelte@3.5.1(prettier@3.8.3)(svelte@5.55.4))(prettier@3.8.3) packages/atproto: devDependencies: @@ -129,6 +139,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.18 version: 4.2.2(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 prettier: specifier: ^3.8.1 version: 3.8.3 diff --git a/scripts/prettier-hook.sh b/scripts/prettier-hook.sh new file mode 100755 index 0000000..81c2880 --- /dev/null +++ b/scripts/prettier-hook.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Pre-commit hook script to run prettier in the correct directories + +# Get the list of files to check +files="$@" + +# Find packages with prettier configs +packages_with_prettier=$(find packages -name ".prettierrc*" -type f | xargs dirname | sort | uniq) + +# Run prettier for files in packages with configs +for package_dir in $packages_with_prettier; do + package_files=$(echo "$files" | grep "^$package_dir/" || true) + if [ -n "$package_files" ]; then + echo "Running prettier in $package_dir..." + cd "$package_dir" && pnpm exec prettier --write --ignore-unknown $package_files + cd - > /dev/null + fi +done + +# Run prettier at root for files not in packages with configs +root_files=$(echo "$files" | grep -v "^packages/" || true) +if [ -n "$root_files" ]; then + echo "Running prettier at root..." + pnpm exec prettier --write --ignore-unknown $root_files +fi \ No newline at end of file -- 2.51.2