/** * Client Penpot API Direct — accès port 9003 (backend sans nginx) * update-file avec changes add-obj pour contrôle pixel-perfect * * Clés API response : kebab-case ("default-team-id") * Clés envoi body : kebab-case (via toKebab()) * Clés obj shapes : camelCase → converties par toKebab() */ import http from 'http'; import { randomUUID } from 'crypto'; const TOKEN = process.env.PENPOT_TOKEN; const HOST = '192.168.1.150'; const PORT = 9003; // ─── RPC ───────────────────────────────────────────────────────────────────── function toKebab(obj) { if (Array.isArray(obj)) return obj.map(toKebab); if (obj && typeof obj === 'object') { return Object.fromEntries(Object.entries(obj).map(([k, v]) => [ k.replace(/([a-z0-9])([A-Z])/g,'$1-$2').toLowerCase(), toKebab(v) ])); } return obj; } function rpc(cmd, body = {}) { return new Promise((resolve, reject) => { const payload = JSON.stringify(toKebab(body)); const req = http.request({ hostname: HOST, port: PORT, path: '/api/rpc/command/' + cmd, method: 'POST', headers: { 'Authorization': 'Token ' + TOKEN, 'Content-Type': 'application/json', 'Accept': 'application/json', 'Content-Length': Buffer.byteLength(payload), } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve(d); } }); }); req.on('error', reject); req.write(payload); req.end(); }); } // ─── Geometry ──────────────────────────────────────────────────────────────── function geo(x, y, w, h) { return { selrect: { x, y, width: w, height: h, x1: x, y1: y, x2: x+w, y2: y+h }, points: [{x,y},{x:x+w,y},{x:x+w,y:y+h},{x,y:y+h}], transform: {a:1,b:0,c:0,d:1,e:0,f:0}, transformInverse: {a:1,b:0,c:0,d:1,e:0,f:0}, }; } const ROOT = '00000000-0000-0000-0000-000000000000'; // ─── Builders ──────────────────────────────────────────────────────────────── function rect({ name, x, y, w, h, color, opacity=1, r=0, parentId, frameId: fid }) { const id = randomUUID(); const pid = parentId || fid || ROOT; const fi = fid || pid; return { type:'add-obj', id, pageId: PAGE_ID, frameId: fi, parentId: pid, obj: { id, type:'rect', name, x, y, width:w, height:h, parentId:pid, frameId:fi, fills:[{fillColor:color, fillOpacity:opacity}], ...(r > 0 && {r1:r,r2:r,r3:r,r4:r}), ...geo(x,y,w,h) }}; } function text({ name, text: textContent, x, y, w, h, size, weight='400', color='#FFFFFF', align='center', spacing=0, parentId, frameId: fid }) { const t = textContent; const id = randomUUID(); const pid = parentId || fid || ROOT; const fi = fid || pid; return { type:'add-obj', id, pageId: PAGE_ID, frameId: fi, parentId: pid, obj: { id, type:'text', name, x, y, width:w, height:h, parentId:pid, frameId:fi, fills:[{fillColor:color, fillOpacity:1}], fontFamily:'Work Sans', fontSize:String(size), fontWeight:String(weight), fontStyle:'normal', verticalAlign:'top', content: { type:'root', children:[{ type:'paragraph-set', children:[{ type:'paragraph', textAlign:align, children:[{ text: t, fontId:'gfont-work-sans', fontFamily:'Work Sans', fontSize: String(size), fontWeight: String(weight), fontStyle:'normal', letterSpacing: String(spacing), lineHeight: 1.2, textDecoration:'none', fills:[{fillColor:color, fillOpacity:1}], }] }]}]}, ...geo(x,y,w,h) }}; } // ─── MAIN ──────────────────────────────────────────────────────────────────── // 1. Récupérer profil + projet const prof = await rpc('get-profile'); // Le backend retourne les clés en kebab-case const teamId = prof['default-team-id'] || prof.defaultTeamId; const projectId = prof['default-project-id'] || prof.defaultProjectId; console.log(`👤 ${prof.fullname} teamId=${teamId?.slice(0,8)} projectId=${projectId?.slice(0,8)}`); if (!teamId || !projectId) { console.error('❌ Profil incomplet:', JSON.stringify(prof)); process.exit(1); } // 2. Supprimer ancien fichier si présent const filesResult = await rpc('get-project-files', { projectId }); const filesArr = Array.isArray(filesResult) ? filesResult : (filesResult?.files || []); for (const f of filesArr.filter(f => f.name?.includes('SOLDES Direct'))) { await rpc('delete-file', { id: f.id }); console.log('🗑 Supprimé:', f.name); } // 3. Créer nouveau fichier const file = await rpc('create-file', { projectId, name:'Affiche SOLDES Direct', isShared:false }); const FILE_ID = file.id; const PAGE_ID = Object.keys(file.data?.pagesIndex || {})[0] || file.data?.pages?.[0]; console.log(`📄 File ${FILE_ID?.slice(0,8)} Page ${PAGE_ID?.slice(0,8)}`); if (!FILE_ID || !PAGE_ID) { console.error('❌ IDs manquants', file); process.exit(1); } let revn = file.revn ?? 0; const W = 600, H = 900; // ─── 4. Shapes ─────────────────────────────────────────────────────────────── // Fond principal : rect rouge (pas frame — frame nécessite shapes[] explicite) const FRAME_ID = ROOT; // On place tout au root, le rect rouge sert de fond visuel const F = { parentId:ROOT, frameId:ROOT }; const changes = [ // Fond rouge (rectangle plein cadre) rect({...F, name:'Fond rouge', x:0, y:0, w:W, h:H, color:'#C0392B'}), // ── Rectangles ────────────────────────────────────────────────────────── rect({...F, name:'Bande haut', x:0, y:0, w:W, h:14, color:'#F1C40F'}), rect({...F, name:'Bande bas', x:0, y:886, w:W, h:14, color:'#F1C40F'}), rect({...F, name:'Header bg', x:0, y:14, w:W, h:92, color:'#7B241C'}), rect({...F, name:'Sep1', x:80, y:445, w:440, h:3, color:'#F1C40F'}), rect({...F, name:'Badge', x:90, y:460, w:420, h:70, color:'#F1C40F', r:35}), rect({...F, name:'Sep2', x:80, y:775, w:440, h:3, color:'#F8C471'}), // ── Textes (width=W → textAlign:center centré sur 600px) ───────────────── text({...F, name:'Collection', text:'✦ Collection Printemps 2026 ✦', x:0, y:32, w:W, h:48, size:14, weight:'700', color:'#F1C40F', align:'center', spacing:4}), text({...F, name:'SOLDES', text:'SOLDES', x:0, y:118, w:W, h:100, size:88, weight:'900', color:'#F1C40F', align:'center', spacing:8}), text({...F, name:'-50%', text:'-50%', x:0, y:218, w:W, h:220, size:185, weight:'900', color:'#FFFFFF', align:'center'}), text({...F, name:'Sous-titre', text:'SUR TOUTES NOS CHAUSSURES', x:0, y:400, w:W, h:42, size:21, weight:'700', color:'#F1C40F', align:'center', spacing:3}), text({...F, name:'Offre', text:'⚡ OFFRE LIMITÉE ⚡', x:90, y:476, w:420, h:40, size:22, weight:'900', color:'#7B241C', align:'center'}), text({...F, name:'Dates', text:'du 1er au 31 mars 2026', x:0, y:552, w:W, h:36, size:16, weight:'400', color:'#FADBD8', align:'center', spacing:2}), text({...F, name:'Chaussures', text:'👟 👠 👞', x:0, y:598, w:W, h:155, size:90, align:'center'}), text({...F, name:'Site web', text:'www.votre-boutique.fr', x:0, y:788, w:W, h:30, size:14, weight:'400', color:'#F8C471', align:'center', spacing:3}), text({...F, name:'Etoiles', text:'★ ★ ★ ★ ★', x:0, y:838, w:W, h:36, size:20, weight:'400', color:'#F1C40F', align:'center'}), ]; // 5. Envoyer shape par shape (revn incrémental) let currentRevn = revn; let ok = 0; for (const change of changes) { const sid = randomUUID(); const result = await rpc('update-file', { id: FILE_ID, sessionId: sid, revn: currentRevn, vern: 0, changes: [change] }); if (result?.type === 'error' || result?.code) { console.error(`❌ "${change.obj?.name}":`, (result.explain || result.message || '').slice(0,120)); } else { currentRevn = result?.revn ?? (currentRevn + 1); ok++; process.stdout.write(` ✓ ${change.obj?.name || change.type}\n`); } } console.log(`\n✅ ${ok}/${changes.length} shapes créés`); console.log(`🔗 http://192.168.1.150:9001 → Drafts → "Affiche SOLDES Direct"`);