Files
budget-tracker/penpot-api-direct.mjs
T

186 lines
8.7 KiB
JavaScript

/**
* 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"`);