@bergetai/odoo-cli 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -140,6 +140,7 @@ Examples:
140
140
  .option("--body <text>", "note body (HTML allowed)")
141
141
  .option("--body-file <path>", "read note body from file ('-' for stdin)")
142
142
  .option("--author <email|name|id>", "note author (res.partner; default: your own partner)")
143
+ .option("--attach <path>", "attach a file to the note; repeatable", collect, [])
143
144
  .addHelpText("after", `
144
145
  IMPORTANT: This command creates INTERNAL notes only. These notes:
145
146
  • Are NEVER sent to the customer via email
@@ -153,7 +154,8 @@ Examples:
153
154
  $ odoo crm records add-note 338 --body "Internal note text"
154
155
  $ odoo crm records add-note 338 --body-file note.md
155
156
  $ cat note.md | odoo crm records add-note 338 --body-file -
156
- $ odoo crm records add-note 338 --body "Kundens svar: ..." --author andreas@berget.ai`)
157
+ $ odoo crm records add-note 338 --body "Kundens svar: ..." --author andreas@berget.ai
158
+ $ odoo crm records add-note 338 --body "Mötesanteckningar" --attach whiteboard.png --attach offert.pdf`)
157
159
  .action(async (id, opts) => {
158
160
  await run((g) => addNoteCommand(id, opts, g, "crm.lead"), program);
159
161
  });
@@ -394,6 +396,7 @@ the raw form.`)
394
396
  .option("--body <text>", "note body (HTML allowed)")
395
397
  .option("--body-file <path>", "read note body from file ('-' for stdin)")
396
398
  .option("--author <email|name|id>", "note author (res.partner; default: your own partner)")
399
+ .option("--attach <path>", "attach a file to the note; repeatable", collect, [])
397
400
  .addHelpText("after", `
398
401
  IMPORTANT: This command creates INTERNAL notes only. These notes:
399
402
  • Are NEVER sent to the customer via email
@@ -24,6 +24,57 @@ async function buildClient(globals) {
24
24
  const timeoutMs = parseDurationMs(globals.timeout);
25
25
  return new OdooClient(config.url, config.db, config.username, config.apiKey, { timeoutMs, verbose: globals.verbose });
26
26
  }
27
+ const MIME_BY_EXT = {
28
+ ".txt": "text/plain",
29
+ ".md": "text/markdown",
30
+ ".csv": "text/csv",
31
+ ".json": "application/json",
32
+ ".pdf": "application/pdf",
33
+ ".png": "image/png",
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".gif": "image/gif",
37
+ ".webp": "image/webp",
38
+ ".svg": "image/svg+xml",
39
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
42
+ ".doc": "application/msword",
43
+ ".xls": "application/vnd.ms-excel",
44
+ ".ppt": "application/vnd.ms-powerpoint",
45
+ ".zip": "application/zip",
46
+ };
47
+ /**
48
+ * Read files for --attach and prepare ir.attachment values. Attachments are
49
+ * linked to the note's mail.message (res_model="mail.message", res_id=note id)
50
+ * so they render on the message in the chatter — same as uploading via the UI.
51
+ */
52
+ async function prepareAttachments(paths) {
53
+ const { readFile, stat } = await import("node:fs/promises");
54
+ const { basename, extname } = await import("node:path");
55
+ const prepared = [];
56
+ for (const p of paths) {
57
+ let fileStat;
58
+ try {
59
+ fileStat = await stat(p);
60
+ }
61
+ catch {
62
+ throw usageError(`attachment file not found: ${p}`, "check the --attach path");
63
+ }
64
+ if (!fileStat.isFile()) {
65
+ throw usageError(`not a regular file: ${p}`);
66
+ }
67
+ const buffer = await readFile(p);
68
+ prepared.push({
69
+ path: p,
70
+ name: basename(p),
71
+ mimetype: MIME_BY_EXT[extname(p).toLowerCase()] ?? "application/octet-stream",
72
+ size: fileStat.size,
73
+ datas: buffer.toString("base64"),
74
+ });
75
+ }
76
+ return prepared;
77
+ }
27
78
  async function readBody(opts) {
28
79
  let body = opts.body;
29
80
  if (opts.bodyFile) {
@@ -50,6 +101,9 @@ export async function addNoteCommand(recordId, opts, globals, model) {
50
101
  throw usageError(`invalid ${spec.label} id: ${recordId}`, "provide a positive integer");
51
102
  }
52
103
  const body = await readBody(opts);
104
+ const attachments = opts.attach?.length
105
+ ? await prepareAttachments(opts.attach)
106
+ : [];
53
107
  const client = await buildClient(globals);
54
108
  // Verify the record exists
55
109
  const rows = (await client.executeKw(spec.model, "search_read", [[["id", "=", id]]], { fields: ["id", "name"], limit: 1 }));
@@ -100,6 +154,11 @@ export async function addNoteCommand(recordId, opts, globals, model) {
100
154
  author: author ? { id: author.id, name: author.name } : null,
101
155
  body_preview: body.substring(0, 100) + (body.length > 100 ? "..." : ""),
102
156
  body_length: body.length,
157
+ attachments: attachments.map((a) => ({
158
+ name: a.name,
159
+ mimetype: a.mimetype,
160
+ size: a.size,
161
+ })),
103
162
  };
104
163
  if (globals.json) {
105
164
  printJson(result);
@@ -113,12 +172,45 @@ export async function addNoteCommand(recordId, opts, globals, model) {
113
172
  }
114
173
  console.log(` Body length: ${body.length} characters`);
115
174
  console.log(` Body preview: ${result.body_preview}`);
175
+ for (const a of attachments) {
176
+ console.log(` Attachment: ${a.name} (${a.mimetype}, ${a.size} bytes)`);
177
+ }
116
178
  }
117
179
  return;
118
180
  }
119
- const noteId = (await client.executeKw("mail.message", "create", [
181
+ // NOTE: batch create returns a list of ids — unwrap the single id,
182
+ // otherwise res_id on attachments becomes a nested list and the
183
+ // server faults with "unhashable type: 'list'".
184
+ const noteIdRaw = (await client.executeKw("mail.message", "create", [
120
185
  [noteParams],
121
186
  ]));
187
+ const noteId = Array.isArray(noteIdRaw) ? noteIdRaw[0] : noteIdRaw;
188
+ // Upload attachments, linked to the note's message so they render on it
189
+ // in the chatter (same as attaching via the Odoo UI).
190
+ const createdAttachments = [];
191
+ for (const a of attachments) {
192
+ const attachmentIdRaw = (await client.executeKw("ir.attachment", "create", [
193
+ [
194
+ {
195
+ name: a.name,
196
+ mimetype: a.mimetype,
197
+ datas: a.datas,
198
+ res_model: "mail.message",
199
+ res_id: noteId,
200
+ },
201
+ ],
202
+ ]));
203
+ const attachmentId = Array.isArray(attachmentIdRaw)
204
+ ? attachmentIdRaw[0]
205
+ : attachmentIdRaw;
206
+ // Link the attachment into the message's attachment_ids m2m — without
207
+ // this the file exists but never renders on the message in the chatter.
208
+ await client.executeKw("mail.message", "write", [
209
+ [noteId],
210
+ { attachment_ids: [[4, attachmentId]] },
211
+ ]);
212
+ createdAttachments.push({ id: attachmentId, name: a.name });
213
+ }
122
214
  const result = {
123
215
  note_id: noteId,
124
216
  model: spec.model,
@@ -127,6 +219,7 @@ export async function addNoteCommand(recordId, opts, globals, model) {
127
219
  note_type: "internal_log_note",
128
220
  email_sent: false,
129
221
  author: author ? { id: author.id, name: author.name } : null,
222
+ attachments: createdAttachments,
130
223
  };
131
224
  if (globals.json) {
132
225
  printJson(result);
@@ -138,6 +231,9 @@ export async function addNoteCommand(recordId, opts, globals, model) {
138
231
  if (author) {
139
232
  console.log(` Author: ${author.name} (${author.id})`);
140
233
  }
234
+ for (const a of createdAttachments) {
235
+ console.log(` Attachment: ${a.name} (ID: ${a.id})`);
236
+ }
141
237
  }
142
238
  }
143
239
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"