diff --git a/penpot-api-direct.mjs b/penpot-api-direct.mjs new file mode 100644 index 0000000..6a04c15 --- /dev/null +++ b/penpot-api-direct.mjs @@ -0,0 +1,185 @@ +/** + * 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"`);