mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 19:59:39 +02:00
Add new @manacore/wallpaper-generator package for creating device wallpapers from QR codes and images. Features: - 30 device presets (phones, tablets, desktops) - 3 layout types (center, corner, pattern) - 5 gradient presets + solid color backgrounds - Browser (Canvas) and Node.js (Sharp) renderers - Svelte WallpaperModal UI component Integrations: - @manacore/qr-export: toWallpaper() function - @manacore/spiral-db: toWallpaper() function - QRExportModal: "Als Wallpaper" button Also includes the full @manacore/qr-export package which was previously untracked. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
231 lines
4.9 KiB
TypeScript
231 lines
4.9 KiB
TypeScript
/**
|
|
* ManaQR Encoder/Decoder
|
|
*
|
|
* Encodes/decodes ManaQRExport data for QR codes using gzip compression.
|
|
*/
|
|
|
|
import pako from 'pako';
|
|
import type { ManaQRExport, EncodeResult, DecodeResult, DecodeError } from './types';
|
|
import { MANA_QR_LIMITS } from './types';
|
|
|
|
/** Prefix for ManaQR data (helps identify valid codes) */
|
|
export const MANA_QR_PREFIX = 'MANA1:';
|
|
|
|
/** Current format version */
|
|
export const MANA_QR_VERSION = 1;
|
|
|
|
/**
|
|
* Encode ManaQRExport data to a compressed string for QR codes
|
|
*/
|
|
export function encode(data: ManaQRExport): EncodeResult {
|
|
// Ensure version is set
|
|
const exportData: ManaQRExport = {
|
|
...data,
|
|
v: MANA_QR_VERSION,
|
|
ts: data.ts || Math.floor(Date.now() / 1000),
|
|
};
|
|
|
|
// Convert to JSON
|
|
const json = JSON.stringify(exportData);
|
|
|
|
// Compress with gzip
|
|
const compressed = pako.deflate(json);
|
|
|
|
// Convert to base64
|
|
const base64 = uint8ArrayToBase64(compressed);
|
|
|
|
// Add prefix
|
|
const result = MANA_QR_PREFIX + base64;
|
|
|
|
return {
|
|
data: result,
|
|
size: result.length,
|
|
fitsInQR: result.length <= MANA_QR_LIMITS.MAX_QR_BYTES,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decode a ManaQR string back to ManaQRExport data
|
|
*/
|
|
export function decode(qrString: string): DecodeResult {
|
|
// Check prefix
|
|
if (!qrString.startsWith(MANA_QR_PREFIX)) {
|
|
return {
|
|
success: false,
|
|
error: 'INVALID_PREFIX',
|
|
message: `String must start with "${MANA_QR_PREFIX}"`,
|
|
};
|
|
}
|
|
|
|
// Extract base64 part
|
|
const base64 = qrString.slice(MANA_QR_PREFIX.length);
|
|
|
|
// Decode base64
|
|
let compressed: Uint8Array;
|
|
try {
|
|
compressed = base64ToUint8Array(base64);
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error: 'INVALID_BASE64',
|
|
message: 'Invalid base64 encoding',
|
|
};
|
|
}
|
|
|
|
// Decompress
|
|
let json: string;
|
|
try {
|
|
json = pako.inflate(compressed, { to: 'string' });
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error: 'DECOMPRESSION_FAILED',
|
|
message: 'Failed to decompress data',
|
|
};
|
|
}
|
|
|
|
// Parse JSON
|
|
let data: unknown;
|
|
try {
|
|
data = JSON.parse(json);
|
|
} catch {
|
|
return {
|
|
success: false,
|
|
error: 'INVALID_JSON',
|
|
message: 'Invalid JSON data',
|
|
};
|
|
}
|
|
|
|
// Validate structure
|
|
const validation = validateExport(data);
|
|
if (!validation.valid) {
|
|
return {
|
|
success: false,
|
|
error: validation.error,
|
|
message: validation.message,
|
|
};
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: data as ManaQRExport,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Estimate the encoded size without actually encoding
|
|
* Useful for checking if data will fit before encoding
|
|
*/
|
|
export function estimateSize(data: ManaQRExport): number {
|
|
const json = JSON.stringify(data);
|
|
// Rough estimate: gzip typically achieves 60-70% compression on JSON
|
|
// Base64 adds ~33% overhead
|
|
// Add prefix length
|
|
const estimatedCompressed = json.length * 0.35;
|
|
const estimatedBase64 = estimatedCompressed * 1.33;
|
|
return Math.ceil(estimatedBase64 + MANA_QR_PREFIX.length);
|
|
}
|
|
|
|
/**
|
|
* Check if data will likely fit in a single QR code
|
|
*/
|
|
export function willFitInQR(data: ManaQRExport): boolean {
|
|
return estimateSize(data) <= MANA_QR_LIMITS.MAX_QR_BYTES;
|
|
}
|
|
|
|
// --- Validation ---
|
|
|
|
interface ValidationResult {
|
|
valid: boolean;
|
|
error: DecodeError;
|
|
message: string;
|
|
}
|
|
|
|
function validateExport(data: unknown): ValidationResult {
|
|
if (typeof data !== 'object' || data === null) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Data must be an object',
|
|
};
|
|
}
|
|
|
|
const obj = data as Record<string, unknown>;
|
|
|
|
// Check version
|
|
if (obj.v !== MANA_QR_VERSION) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_VERSION',
|
|
message: `Unsupported version: ${obj.v}`,
|
|
};
|
|
}
|
|
|
|
// Check required fields
|
|
if (typeof obj.ts !== 'number') {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Missing or invalid timestamp',
|
|
};
|
|
}
|
|
|
|
if (typeof obj.u !== 'object' || obj.u === null) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Missing user context',
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(obj.c)) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Contacts must be an array',
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(obj.e)) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Events must be an array',
|
|
};
|
|
}
|
|
|
|
if (!Array.isArray(obj.t)) {
|
|
return {
|
|
valid: false,
|
|
error: 'INVALID_STRUCTURE',
|
|
message: 'Todos must be an array',
|
|
};
|
|
}
|
|
|
|
return { valid: true, error: 'INVALID_STRUCTURE', message: '' };
|
|
}
|
|
|
|
// --- Base64 utilities (browser & Node compatible) ---
|
|
|
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
if (typeof btoa === 'function') {
|
|
// Browser
|
|
return btoa(String.fromCharCode(...bytes));
|
|
}
|
|
// Node.js
|
|
return Buffer.from(bytes).toString('base64');
|
|
}
|
|
|
|
function base64ToUint8Array(base64: string): Uint8Array {
|
|
if (typeof atob === 'function') {
|
|
// Browser
|
|
const binaryString = atob(base64);
|
|
const bytes = new Uint8Array(binaryString.length);
|
|
for (let i = 0; i < binaryString.length; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
// Node.js
|
|
return new Uint8Array(Buffer.from(base64, 'base64'));
|
|
}
|