mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-24 02:16:41 +02:00
feat(wallpaper-generator): add device wallpaper generation package
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>
This commit is contained in:
parent
c480231128
commit
e5109da732
37 changed files with 5393 additions and 676 deletions
231
packages/qr-export/src/encoder.ts
Normal file
231
packages/qr-export/src/encoder.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
/**
|
||||
* 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'));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue