@bergetai/odoo-cli 0.13.1 → 0.15.1

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/README.md CHANGED
@@ -97,6 +97,12 @@ odoo attachments download 31337 --out ./spec.pdf
97
97
  odoo attachments delete 31337 --dry-run
98
98
  odoo attachments delete 31337
99
99
 
100
+ # Calendar (calendar.event) — read-only
101
+ odoo calendar events list --from 2026-11-04 --to 2026-11-14
102
+ odoo calendar events list --from 2026-11-04 --to 2026-11-14 --assignee erik@berget.ai
103
+ odoo calendar events list --search "Elajo" --json
104
+ odoo calendar events show 27183
105
+
100
106
  # Expenses (hr.expense) — receipts attached as ir.attachment
101
107
  odoo expenses list --json
102
108
  odoo expenses show 137
@@ -107,6 +113,8 @@ odoo expenses create --name "Taxi Arlanda" --date 2026-10-14 --amount 249.50 \
107
113
  --category "Travel & Accommodation" --attach receipt.pdf
108
114
  odoo expenses attach 137 --file kvitto.pdf
109
115
  odoo expenses delete 137 --dry-run
116
+ odoo expenses report create --name "Utlägg sept 2026" --expense 137 --expense 138 --submit --dry-run
117
+ odoo expenses report create --name "Utlägg sept 2026" --expense 137 --expense 138 --submit
110
118
 
111
119
  # Marketing (mailing.*) — email is the key; trace deletion is always explicit
112
120
  odoo marketing lists list
@@ -119,6 +127,13 @@ odoo marketing traces list --email person@example.com --json
119
127
  odoo marketing traces delete 7001 --dry-run
120
128
  odoo marketing traces delete 7001
121
129
 
130
+ # Marketing preview — render a mailing's HTML the way it is sent (read-only)
131
+ odoo marketing preview 145 # one-shot temp file, opens the browser
132
+ odoo marketing preview --search "Berget Code" # resolve by name/subject
133
+ odoo marketing preview --search code --serve --port 4173 # live-reload server (127.0.0.1 only)
134
+ odoo marketing preview --file template.html # local body_arch file (repeatable; '-' = stdin)
135
+ odoo marketing preview --file a.html --file b.html --serve # switcher with watch & live reload on file saves
136
+
122
137
  # Sign-in identities (res.users) — show + guarded delete only
123
138
  odoo users show 9
124
139
  odoo users delete 9 --dry-run
@@ -197,6 +212,7 @@ Note: `odoo --version` prints the CLI's own version; `odoo version` queries the
197
212
  - Read-only Helpdesk surface (`helpdesk tickets list/show`) — `--open`/`--closed` via stage `fold`, strict team-scoped stage resolution, module-not-installed error (exit 4)
198
213
  - Read-only Project surface (`project list/show`) — `--user` (project manager) and `--tag` filters, archived projects excluded by default, module-not-installed error (exit 4)
199
214
  - Read-only Project task surface (`project tasks list/show`) — `--project` (required on list, resolved by name or id), project-scoped `--stage`, `--assignee` (many2many `user_ids`), `--tag`, `--priority` (0/Low, 1/High), `--open`/`--closed` via the task's `is_closed` flag, `--domain`/`--filter`, table/JSON output with `search_count` envelope, module-not-installed error (exit 4)
215
+ - Marketing preview (`marketing preview`) — render a mailing's HTML in a browser the way it is sent: one-shot self-contained temp file (default, opened in the browser) or `--serve` live-reload server bound to 127.0.0.1 that re-fetches the mailings on every browser reload (edits made in the Odoo email editor appear live). Each mailing gets a "Draft" view (body_arch with the send-time base CSS extracted from a sent body_html — white card, Inter, #212529, 720px wrapper) and, when `state=done`, an "As sent" view of the final rendered body_html. `<id>` or `--search` (name/subject ilike), `--limit` for the switcher, `--port`, `--no-open`, or `--file <path>` (repeatable, `-` = stdin) to preview local body_arch-format HTML files instead of server records — files are read verbatim, and preview-only adjustments (shim, instance-absolute URLs) are injected at render time, never written back; with `--serve` file saves auto-reload the browser; `--json` emits `{mode, path|url, mailings|files}`
200
216
  - Generic model surface (`models fields/read/search`) — introspect, read and search any model exposed through `execute_kw`: `fields` lists `fields_get` metadata (type, label, selection, relation, required, store) with `--filter <regex>` (case-insensitive on field name or label) and `--custom` (only `x_*` fields); `read` fetches up to 100 records by `--ids 1,2,3` or a name ilike `--search`, with `--fields a,b,c` (default: name); `search` runs an arbitrary domain via `--domain <json>` (or repeatable `--filter f=v`, mutually exclusive) with `--fields` (default: id[,name]), `--limit`/`--offset`/`--order`, and `--include-archived` (runs with `active_test: false`; eager-fetches rows other list engines hide by default). Unknown models/fields use the standard error taxonomy
201
217
  - Documents surface (`documents`) — `tree` (hierarchical folder listing with `--count`/`--depth`/`--folder`), `records list/show` (folders excluded by default, `--type folder|binary|url|all`, `--folder`/`--owner`/`--partner`/`--tag`), `folders list`, `records create` (folder via `--folder-type folder`, link via `--link <url>`), `records update` (move via `--folder`), `records delete`, `upload` (local file → base64 `datas`, MIME guessed from extension, `--dry-run` previews without sending bytes), and `records download <id>` (binary document → local file, `--out <path>`/`--force`, `--out -` streams to stdout). Everywhere a `--folder` is accepted, folders resolve by name, numeric id, or nested `'Parent > Child'` path (segments resolved within their parent, which disambiguates repeated folder names). Folders are `documents.document` rows with `type='folder'`; the hierarchy is `folder_id`. Module-not-installed error (exit 4)
202
218
  - Writes (`create`, `update`, `delete`) — single-call atomic batch `write`, client-side no-op diffing, delete convergence with `skipped` ids, post-failure "record may have been created" hint on `create`
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
2
3
  import { Command, CommanderError } from "commander";
3
4
  import { authLoginCommand, authStatusCommand } from "./commands/auth.js";
4
5
  import { leadsCreateCommand, leadsDeleteCommand, leadsListCommand, leadsShowCommand, leadsUpdateCommand, crmLeads, } from "./commands/crm.js";
@@ -12,17 +13,20 @@ import { versionCommand } from "./commands/version.js";
12
13
  import { partnersCreateCommand, partnersDeleteCommand, partnersListCommand, partnersShowCommand, partnersTagsCommand, partnersUpdateCommand, resPartner, } from "./commands/partners.js";
13
14
  import { usersDeleteCommand, usersShowCommand, } from "./commands/users.js";
14
15
  import { attachmentsDeleteCommand, attachmentsDownloadCommand, attachmentsListCommand, attachmentsShowCommand, } from "./commands/attachments.js";
15
- import { expensesAttachCommand, expensesCategoriesCommand, expensesCreateCommand, expensesDeleteCommand, expensesListCommand, expensesShowCommand, } from "./commands/expenses.js";
16
+ import { expensesAttachCommand, expensesCategoriesCommand, expensesCreateCommand, expensesDeleteCommand, expensesListCommand, expensesReportCreateCommand, expensesShowCommand, } from "./commands/expenses.js";
16
17
  import { signTemplatesCreateFromDocumentCommand, signTemplatesListCommand, signTemplatesShowCommand, signItemsAddCommand, signRequestsCreateCommand, signRequestsListCommand, signRequestsCancelCommand, } from "./commands/sign.js";
17
18
  import { invoicesPrecedentCommand, linesAddCommand, linesUpdateCommand, ordersCreateCommand, ordersListCommand, ordersReadCommand, ordersUpdateCommand, pdfCommand, plansListCommand, plansPricingCreateCommand, plansPricingListCommand, plansPricingUpdateCommand, } from "./commands/sales.js";
18
19
  import { messagesDeleteCommand, messagesListCommand, messagesScrubCommand, messagesShowCommand, } from "./commands/messages.js";
19
- import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
20
+ import { contactsDeleteCommand, contactsListCommand, contactsShowCommand, listsListCommand, listsShowCommand, previewCommand, tracesDeleteCommand, tracesListCommand, tracesShowCommand, } from "./commands/marketing.js";
20
21
  import { modelsFieldsCommand, modelsReadCommand, modelsSearchCommand, } from "./commands/models.js";
22
+ import { eventsListCommand, eventsShowCommand, } from "./commands/calendar.js";
21
23
  import { footprintCommand, } from "./commands/footprint.js";
22
24
  import { CliError } from "./errors.js";
23
25
  import { applyGlobalOptions, enforceTransportSecurity, DEFAULT_TIMEOUT, } from "./global-options.js";
24
26
  import { printError } from "./output.js";
25
- const CLI_VERSION = "0.1.0";
27
+ // Read the CLI version from package.json so --version never drifts from the
28
+ // published version (previously hardcoded and forgotten at release time).
29
+ const CLI_VERSION = createRequire(import.meta.url)("../package.json").version;
26
30
  /** commander value parser for repeatable options (accumulates values). */
27
31
  function collect(value, previous) {
28
32
  return [...previous, value];
@@ -766,6 +770,45 @@ Examples:
766
770
  .action(async (ids) => {
767
771
  await run((g) => usersDeleteCommand(ids, g), program);
768
772
  });
773
+ const calendar = program
774
+ .command("calendar")
775
+ .description("Calendar app (calendar.event)");
776
+ const events = calendar.command("events").description("calendar events");
777
+ events
778
+ .command("list")
779
+ .description("list events overlapping a time window (calendar.event, read-only)")
780
+ .option("--from <datetime>", "events ending after this time (YYYY-MM-DD [HH:MM])")
781
+ .option("--to <datetime>", "events starting before this time (YYYY-MM-DD [HH:MM])")
782
+ .option("--search <text>", "ilike search on the event subject")
783
+ .option("--assignee <name|id>", "filter by responsible user (login, email, or name)")
784
+ .option("--filter <f=v>", "equality filter; repeatable, AND-ed", collect, [])
785
+ .option("--domain <json>", "Odoo domain as a JSON array (mutually exclusive with --filter)")
786
+ .option("--fields <a,b,c>", "columns to display")
787
+ .option("--limit <n>", "max records (default: 50)", "50")
788
+ .option("--offset <n>", "skip the first n records", "0")
789
+ .option("--order <order>", 'sort order, e.g. "start asc"', "start asc")
790
+ .addHelpText("after", `
791
+ --from/--to select events OVERLAPPING the window: an event matches when it
792
+ starts before --to and stops after --from. Times are server-local (UTC in
793
+ Odoo's storage) — the Odoo calendar usually does not mirror the full Google
794
+ Calendar, so treat results as partial.
795
+
796
+ Examples:
797
+ $ odoo calendar events list --from 2026-11-04 --to 2026-11-14
798
+ $ odoo calendar events list --from 2026-11-04 --to 2026-11-14 --assignee erik@berget.ai
799
+ $ odoo calendar events list --search "Elajo" --json`)
800
+ .action(async (opts) => {
801
+ await run((g) => eventsListCommand(opts, g), program);
802
+ });
803
+ events
804
+ .command("show")
805
+ .description("read one event")
806
+ .argument("<id>", "record id")
807
+ .option("--fields <a,b,c>", "fields to display")
808
+ .option("--all-fields", "display every readable field")
809
+ .action(async (id, opts) => {
810
+ await run((g) => eventsShowCommand(id, opts, g), program);
811
+ });
769
812
  const attachments = program
770
813
  .command("attachments")
771
814
  .description("record attachments (ir.attachment)");
@@ -886,6 +929,29 @@ Examples:
886
929
  .action(async (opts) => {
887
930
  await run((g) => expensesCreateCommand(opts, g), program);
888
931
  });
932
+ const expensesReport = expenses
933
+ .command("report")
934
+ .description("expense reports (hr.expense.sheet) — collect and submit");
935
+ expensesReport
936
+ .command("create")
937
+ .description("create an expense report from draft expenses; add --submit to send it for approval")
938
+ .requiredOption("--name <text>", "report name, e.g. 'Utlägg september 2026'")
939
+ .requiredOption("--expense <id>", "expense id to include (draft, not yet reported); repeatable", collect, [])
940
+ .option("--submit", "submit the report for approval immediately (state: Submitted)", false)
941
+ .option("--employee <name|id>", "report owner (default: employee linked to the authenticated user)")
942
+ .addHelpText("after", `
943
+ Creates an hr.expense.sheet and links the given expenses to it (same as
944
+ "Create Report" in the web UI). Approval (action_approve_sheet) is NOT
945
+ supported — that step belongs to the approver in the Odoo UI.
946
+
947
+ Examples:
948
+ $ odoo expenses report create --name "Utlägg sept 2026" \\
949
+ --expense 138 --expense 139 --dry-run
950
+ $ odoo expenses report create --name "Utlägg sept 2026" \\
951
+ --expense 138 --expense 139 --submit --json`)
952
+ .action(async (opts) => {
953
+ await run((g) => expensesReportCreateCommand(opts, g), program);
954
+ });
889
955
  expenses
890
956
  .command("attach")
891
957
  .description("attach a receipt file to an existing expense")
@@ -1389,6 +1455,37 @@ Examples:
1389
1455
  .action(async (id, opts) => {
1390
1456
  await run((g) => tracesShowCommand(id, opts, g), program);
1391
1457
  });
1458
+ const mlPreview = marketing
1459
+ .command("preview")
1460
+ .description("render a mailing's HTML in a browser the way it is sent (read-only)")
1461
+ .addHelpText("after", `
1462
+ The one-shot mode writes a self-contained temp file: each mailing gets a
1463
+ "Draft" view (body_arch with the send-time base CSS the editor normally
1464
+ provides) and, for sent mailings, an "As sent" view of the final body_html.
1465
+ --serve instead runs a local-only server that re-fetches the mailings on every
1466
+ browser reload, so edits made in the Odoo email editor appear live.
1467
+
1468
+ --file previews local HTML files instead of server records. Files must hold
1469
+ the mailing HTML exactly as Odoo stores it in mailing.mailing.body_arch (an
1470
+ o_layout fragment); the shim and instance-absolute URLs are injected at
1471
+ preview time and never written back to the file. With --serve, saved file
1472
+ edits reload the browser automatically. "-" reads from stdin.
1473
+
1474
+ Examples:
1475
+ $ odoo marketing preview 145
1476
+ $ odoo marketing preview --search "Berget Code"
1477
+ $ odoo marketing preview --search Berget --serve --port 4173
1478
+ $ odoo marketing preview --file template.html --serve`)
1479
+ .argument("[id]", "mailing.mailing record id")
1480
+ .option("--search <text>", "ilike match on name OR subject")
1481
+ .option("--file <path>", "preview a local body_arch-format HTML file (repeatable; '-' reads stdin)", (value, collect) => [...collect, value], [])
1482
+ .option("--limit <n>", "max mailings to include in the switcher (default: 5)", "5")
1483
+ .option("--serve", "run a local live-reload server instead of a one-shot temp file")
1484
+ .option("--port <n>", "server port (default: 4173; 0 picks a free one)")
1485
+ .option("--no-open", "do not launch the browser")
1486
+ .action(async (id, opts) => {
1487
+ await run((g) => previewCommand(id, opts, g), program);
1488
+ });
1392
1489
  mlTraces
1393
1490
  .command("delete")
1394
1491
  .description("delete send traces explicitly (preview with --dry-run; unknown ids are skipped)")
@@ -0,0 +1,67 @@
1
+ import { usageError } from "../errors.js";
2
+ import { listCommand, showCommand, } from "../resource.js";
3
+ /**
4
+ * Validate a YYYY-MM-DD or "YYYY-MM-DD HH:MM" date string and return it in
5
+ * the "YYYY-MM-DD HH:MM:SS" format Odoo expects (server stores UTC).
6
+ */
7
+ function parseDateTime(raw, flagName) {
8
+ const m = /^(\d{4}-\d{2}-\d{2})(?:[T ](\d{2}:\d{2})(?::\d{2})?)?$/.exec(raw);
9
+ if (!m) {
10
+ throw usageError(`invalid ${flagName} ${JSON.stringify(raw)}`, 'expected "YYYY-MM-DD" or "YYYY-MM-DD HH:MM"');
11
+ }
12
+ return `${m[1]} ${m[2] ?? "00:00"}:00`;
13
+ }
14
+ export const calendarEvents = {
15
+ model: "calendar.event",
16
+ showCommand: "odoo calendar events show",
17
+ defaultListFields: [
18
+ "id",
19
+ "name",
20
+ "start",
21
+ "stop",
22
+ "allday",
23
+ "user_id",
24
+ "partner_ids",
25
+ ],
26
+ showExtraFields: [
27
+ "duration",
28
+ "location",
29
+ "videocall_location",
30
+ "description",
31
+ "privacy",
32
+ "write_date",
33
+ ],
34
+ resolutionHints: {
35
+ user_id: "--assignee",
36
+ },
37
+ priorityLabels: {},
38
+ pinnedDomain: [],
39
+ references: {
40
+ assignee: {
41
+ model: "res.users",
42
+ label: "user",
43
+ field: "user_id",
44
+ searchFields: ["login", "email", "name"],
45
+ },
46
+ },
47
+ // --from/--to select events OVERLAPPING the window (not just starting in
48
+ // it): an event matches when it starts before the window ends and stops
49
+ // after the window begins.
50
+ extraFilters: (opts) => {
51
+ const o = opts;
52
+ const domain = [];
53
+ if (o.from)
54
+ domain.push(["stop", ">=", parseDateTime(o.from, "--from")]);
55
+ if (o.to)
56
+ domain.push(["start", "<=", parseDateTime(o.to, "--to")]);
57
+ if (o.search)
58
+ domain.push(["name", "ilike", o.search]);
59
+ return Promise.resolve(domain);
60
+ },
61
+ };
62
+ export function eventsListCommand(opts, globals) {
63
+ return listCommand(calendarEvents, opts, globals);
64
+ }
65
+ export function eventsShowCommand(idArg, opts, globals) {
66
+ return showCommand(calendarEvents, idArg, opts, globals);
67
+ }
@@ -222,6 +222,75 @@ export async function expensesCreateCommand(opts, globals) {
222
222
  const line = `Created hr.expense ${id} (${opts.name})${attSuffix}`;
223
223
  process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
224
224
  }
225
+ export async function expensesReportCreateCommand(opts, globals) {
226
+ const client = await authenticatedClient(globals);
227
+ const ids = opts.expense.map(parseId);
228
+ if (ids.length === 0) {
229
+ throw usageError("pass at least one --expense <id>");
230
+ }
231
+ const rows = (await client.executeKw("hr.expense", "read", [ids], {
232
+ fields: ["id", "name", "state", "sheet_id", "employee_id", "total_amount", "currency_id"],
233
+ }));
234
+ if (rows.length !== ids.length) {
235
+ const found = new Set(rows.map((r) => r.id));
236
+ const missing = ids.filter((id) => !found.has(id));
237
+ throw notFoundError("record_not_found", `no hr.expense record(s) with id(s): ${missing.join(", ")}`, "expenses are only visible to their owner unless you are an Expense Manager");
238
+ }
239
+ const reported = rows.filter((r) => r.sheet_id !== false);
240
+ if (reported.length > 0) {
241
+ throw usageError(`expense(s) already on a report: ${reported.map((r) => `${r.id} (${r.name})`).join(", ")}`);
242
+ }
243
+ const employeeId = opts.employee !== undefined
244
+ ? (await resolveReference(client, "hr.employee", opts.employee, {
245
+ label: "employee",
246
+ })).id
247
+ : await currentEmployee(client);
248
+ const values = {
249
+ name: opts.name,
250
+ employee_id: employeeId,
251
+ expense_line_ids: [[6, 0, ids]],
252
+ };
253
+ if (globals.dryRun) {
254
+ const preview = { ...values, submit: opts.submit === true };
255
+ if (globals.json) {
256
+ printJson({ model: "hr.expense.sheet", action: "create", dry_run: true, values: preview });
257
+ return;
258
+ }
259
+ process.stdout.write(`Would create hr.expense.sheet:\n` +
260
+ Object.entries(preview)
261
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
262
+ .join("\n") +
263
+ "\n");
264
+ return;
265
+ }
266
+ const id = (await client.executeKw("hr.expense.sheet", "create", [values]));
267
+ let state = "draft";
268
+ if (opts.submit === true) {
269
+ await client.executeKw("hr.expense.sheet", "action_submit_sheet", [[id]]);
270
+ state = "submit";
271
+ }
272
+ const [created] = (await client.executeKw("hr.expense.sheet", "read", [[id]], {
273
+ fields: ["total_amount", "currency_id"],
274
+ }));
275
+ if (globals.json) {
276
+ printJson({
277
+ model: "hr.expense.sheet",
278
+ action: "create",
279
+ dry_run: false,
280
+ id,
281
+ name: opts.name,
282
+ expense_ids: ids,
283
+ state,
284
+ total_amount: created.total_amount,
285
+ currency: created.currency_id?.[1],
286
+ });
287
+ return;
288
+ }
289
+ const color = colorEnabled(false) && process.stdout.isTTY === true;
290
+ const line = `Created hr.expense.sheet ${id} (${opts.name}) with ${ids.length} expense(s)` +
291
+ (opts.submit === true ? " — submitted" : "");
292
+ process.stdout.write((color ? `\x1b[32m${line}\x1b[0m` : line) + "\n");
293
+ }
225
294
  export async function expensesAttachCommand(idArg, opts, globals) {
226
295
  const id = parseId(idArg);
227
296
  const client = await authenticatedClient(globals);
@@ -1,4 +1,10 @@
1
+ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { watch } from "node:fs";
1
5
  import { ModelFields, resolveReference } from "../model.js";
6
+ import { resolveConfig } from "../config.js";
7
+ import { buildPageFromViews, openInBrowser, startPreviewServer, viewFromFile, viewsFromMailings, } from "../preview.js";
2
8
  import { authenticatedClient, showCommand, } from "../resource.js";
3
9
  import { deleteCommand } from "../mutation.js";
4
10
  import { usageError } from "../errors.js";
@@ -182,3 +188,215 @@ export function tracesShowCommand(idArg, opts, globals) {
182
188
  export function tracesDeleteCommand(idArgs, globals) {
183
189
  return deleteCommand(mailingTrace, idArgs, globals);
184
190
  }
191
+ const PREVIEW_FIELDS = ["name", "subject", "state", "body_arch", "body_html"];
192
+ function parsePort(raw) {
193
+ if (raw === undefined)
194
+ return 4173;
195
+ if (!/^\d+$/.test(raw)) {
196
+ throw usageError(`invalid --port value ${JSON.stringify(raw)}`, "expected a TCP port number (0 picks a random free port)");
197
+ }
198
+ const port = Number(raw);
199
+ if (port > 65535) {
200
+ throw usageError(`invalid --port value ${JSON.stringify(raw)}`, "port > 65535");
201
+ }
202
+ return port;
203
+ }
204
+ async function fetchMailings(client, idArg, opts) {
205
+ const limitRaw = opts.limit;
206
+ if (!/^\d+$/.test(limitRaw) || Number(limitRaw) < 1) {
207
+ throw usageError(`invalid --limit value ${JSON.stringify(limitRaw)}`, "expected a positive integer");
208
+ }
209
+ const limit = Number(limitRaw);
210
+ const domain = [];
211
+ if (idArg !== undefined) {
212
+ if (!/^\d+$/.test(idArg)) {
213
+ throw usageError(`invalid mailing id ${JSON.stringify(idArg)}`, "ids are positive integers \u2014 pass --search <text> to find one by subject");
214
+ }
215
+ domain.push(["id", "=", Number(idArg)]);
216
+ }
217
+ else if (opts.search) {
218
+ domain.push("|", ["name", "ilike", opts.search], ["subject", "ilike", opts.search]);
219
+ }
220
+ const rows = (await client.executeKw("mailing.mailing", "search_read", [domain], { fields: PREVIEW_FIELDS, limit, order: "id desc" }));
221
+ if (rows.length === 0) {
222
+ throw usageError(idArg !== undefined
223
+ ? `no mailing with id ${idArg}`
224
+ : `no mailing matching ${JSON.stringify(opts.search)}`, "run `odoo models read mailing.mailing --search <text> --json` to browse");
225
+ }
226
+ return rows.map((row) => ({
227
+ id: row.id,
228
+ name: row.name ?? "",
229
+ subject: row.subject ?? "",
230
+ state: row.state ?? "",
231
+ body_arch: row.body_arch,
232
+ body_html: row.body_html,
233
+ }));
234
+ }
235
+ async function readStdinHtml() {
236
+ if (process.stdin.isTTY) {
237
+ throw usageError("--file - requires HTML on stdin", "pipe the mailing HTML, e.g. `cat template.html | odoo marketing preview --file -`");
238
+ }
239
+ const chunks = [];
240
+ for await (const chunk of process.stdin)
241
+ chunks.push(chunk);
242
+ const html = Buffer.concat(chunks).toString("utf8");
243
+ if (html.trim() === "") {
244
+ throw usageError("stdin HTML is empty", "pipe a mailing fragment or use --file <path>");
245
+ }
246
+ return html;
247
+ }
248
+ async function fetchFileViews(files, baseUrl) {
249
+ const stdinSlots = files.filter((f) => f === "-").length;
250
+ if (stdinSlots > 1) {
251
+ throw usageError("--file - appears more than once", "stdin can feed a single view");
252
+ }
253
+ const views = [];
254
+ for (const f of files) {
255
+ if (f === "-") {
256
+ views.push(viewFromFile("stdin.html", await readStdinHtml(), baseUrl));
257
+ continue;
258
+ }
259
+ let content;
260
+ try {
261
+ content = await readFile(f, "utf8");
262
+ }
263
+ catch (cause) {
264
+ const code = cause.code;
265
+ throw usageError(`could not read ${f}${code ? ` (${code})` : ""}`, "the file must hold the mailing HTML exactly as Odoo stores it in mailing.mailing.body_arch");
266
+ }
267
+ if (content.trim() === "") {
268
+ throw usageError(`${f} is empty`, "the file must hold the mailing HTML (body_arch format)");
269
+ }
270
+ views.push(viewFromFile(basename(f), content, baseUrl));
271
+ }
272
+ return views;
273
+ }
274
+ /** Watch the (--file) drafts and call onChange after writes settle. Returns a disposer. */
275
+ function watchFiles(files, onChange) {
276
+ const dirs = new Map();
277
+ for (const f of files) {
278
+ if (f === "-")
279
+ continue;
280
+ const abs = resolve(f);
281
+ const dir = dirname(abs);
282
+ const names = dirs.get(dir) ?? new Set();
283
+ names.add(basename(abs));
284
+ dirs.set(dir, names);
285
+ }
286
+ let timer;
287
+ const bump = () => {
288
+ clearTimeout(timer);
289
+ timer = setTimeout(onChange, 150);
290
+ };
291
+ const watchers = [];
292
+ for (const [dir, names] of dirs) {
293
+ try {
294
+ const watcher = watch(dir, (_event, filename) => {
295
+ if (filename && names.has(filename))
296
+ bump();
297
+ });
298
+ watcher.on("error", () => { }); // directory vanished; reload still works manually
299
+ watchers.push(watcher);
300
+ }
301
+ catch {
302
+ // watch() failed (unsupported platform / missing dir); reload still works manually
303
+ }
304
+ }
305
+ return () => {
306
+ clearTimeout(timer);
307
+ for (const watcher of watchers)
308
+ watcher.close();
309
+ };
310
+ }
311
+ /**
312
+ * `odoo marketing preview <id> [--search <text>] [--file <path...>]`: render
313
+ * mailing HTML in a browser the way it is sent. One-shot by default
314
+ * (self-contained temp file, opened in the browser); --serve runs a local-only
315
+ * live-reload server. Server records are fetched fresh on every reload; local
316
+ * files are watched and injected at render time (the file itself is never
317
+ * modified). Read-only against the server.
318
+ */
319
+ export async function previewCommand(idArg, opts, globals) {
320
+ const fileMode = (opts.file?.length ?? 0) > 0;
321
+ if (!fileMode && idArg === undefined && !opts.search) {
322
+ throw usageError("nothing to preview", "pass mailing id(s): `odoo marketing preview 145`, --search <text>, or --file <path>");
323
+ }
324
+ if (fileMode && (idArg !== undefined || opts.search)) {
325
+ throw usageError("--file is exclusive with <id> and --search", "--file previews local HTML; <id>/--search preview server mailing.mailing records");
326
+ }
327
+ if (idArg !== undefined && opts.search) {
328
+ throw usageError("<id> and --search are mutually exclusive", "pass either a mailing id or --search");
329
+ }
330
+ // Editor-relative URLs (e.g. /web/image/...) are rewritten against the
331
+ // instance at render time so views resolve outside the Odoo origin
332
+ // (file://, 127.0.0.1). Source content is never rewritten.
333
+ const resolved = await resolveConfig({
334
+ profile: globals.profile,
335
+ url: globals.url,
336
+ db: globals.db,
337
+ username: globals.username,
338
+ apiKeyFile: globals.apiKeyFile,
339
+ });
340
+ const baseUrl = resolved.url;
341
+ let computeViews;
342
+ let summary;
343
+ if (fileMode) {
344
+ const files = opts.file;
345
+ summary = { files };
346
+ computeViews = () => fetchFileViews(files, baseUrl);
347
+ }
348
+ else {
349
+ const client = await authenticatedClient(globals);
350
+ const mailings = await fetchMailings(client, idArg, opts);
351
+ summary = {
352
+ mailings: mailings.map((m) => ({
353
+ id: m.id,
354
+ name: m.name,
355
+ subject: m.subject,
356
+ state: m.state,
357
+ views: m.body_html ? ["draft", "as-sent"] : ["draft"],
358
+ })),
359
+ };
360
+ computeViews = async () => viewsFromMailings(mailings, baseUrl);
361
+ }
362
+ if (opts.serve) {
363
+ const initial = await computeViews();
364
+ const server = await startPreviewServer(computeViews, parsePort(opts.port));
365
+ const disposeWatcher = fileMode
366
+ ? watchFiles(opts.file, () => server.notifyChange())
367
+ : undefined;
368
+ const openTarget = `${server.url}/#${encodeURIComponent(initial[0].key)}`;
369
+ if (!opts.noOpen && !globals.json)
370
+ openInBrowser(openTarget);
371
+ if (globals.json) {
372
+ printJson({ mode: "serve", url: server.url, ...summary });
373
+ }
374
+ else {
375
+ process.stdout.write(`Preview server \u2192 ${server.url} (Ctrl-C to stop)\n`);
376
+ }
377
+ // Stay up until interrupted; close() makes the promise settle in tests.
378
+ return new Promise((resolve) => {
379
+ const stop = () => {
380
+ process.removeListener("SIGINT", stop);
381
+ process.removeListener("SIGTERM", stop);
382
+ disposeWatcher?.();
383
+ void server.close().then(resolve);
384
+ };
385
+ process.once("SIGINT", stop);
386
+ process.once("SIGTERM", stop);
387
+ });
388
+ }
389
+ const views = await computeViews();
390
+ const html = buildPageFromViews(views);
391
+ const dir = await mkdtemp(join(tmpdir(), "odoo-preview-"));
392
+ const path = join(dir, "preview.html");
393
+ await writeFile(path, html, "utf8");
394
+ if (!opts.noOpen && !globals.json)
395
+ openInBrowser(path);
396
+ if (globals.json) {
397
+ printJson({ mode: "file", path, ...summary });
398
+ }
399
+ else {
400
+ process.stdout.write(`Preview written to ${path}\n`);
401
+ }
402
+ }
@@ -0,0 +1,231 @@
1
+ import http from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ /**
4
+ * `marketing preview` support: renders mailing.mailing body_arch/body_html
5
+ * in a browser the way they are sent.
6
+ *
7
+ * Odoo inlines layout CSS into body_html at send time, but body_arch (the
8
+ * editable source) relies on classes the editor provides. The shim below is
9
+ * cribbed from prod sent body_html (mailings 51/111 on berget prod): the
10
+ * o_layout renders as a white card (#fff on #212529 text, Inter, 14px/1.5),
11
+ * 720px centered wrapper — NOT the editor's tinted theme.
12
+ */
13
+ export const PREVIEW_SHIM_CSS = `
14
+ body { margin: 0; }
15
+ div.o_layout { background-color: #ffffff !important; color: #212529;
16
+ font-family: Inter, 'Odoo Unicode Support Noto', Arial, sans-serif;
17
+ font-size: 14px; line-height: 1.5; }
18
+ .o_mail_wrapper { max-width: 720px; margin: 0 auto; }
19
+ h1 { font-size: 24px; } h2 { font-size: 20px; }
20
+ p { margin: 0 0 10px; } ul { margin: 0 0 10px; padding-left: 24px; }
21
+ img.mw-100, img.img-fluid { max-width: 100%; height: auto; display: block; }
22
+ img.mx-auto { margin: 0 auto; }
23
+ .row { display: flex; flex-wrap: wrap; }
24
+ [class*="col-lg"] { width: 100%; }
25
+ a { color: currentColor; }
26
+ .btn { display: inline-block; padding: .5rem 1.25rem; text-decoration: none; }
27
+ .bg-white { background-color: #fff; }
28
+ /* Odoo turns pt*/pb* classes into td padding at send; mirror that here so the
29
+ preview shows the same vertical rhythm as the inbox. */
30
+ .pt0 { padding-top: 0; } .pt8 { padding-top: 8px; }
31
+ .pt16 { padding-top: 16px; } .pt24 { padding-top: 24px; }
32
+ .pt32 { padding-top: 32px; }
33
+ .pb0 { padding-bottom: 0; } .pb8 { padding-bottom: 8px; }
34
+ .pb16 { padding-bottom: 16px; } .pb24 { padding-bottom: 24px; }
35
+ /* Every snippet td gets 15px side padding at send. */
36
+ .o_mail_snippet_general { padding-left: 15px; padding-right: 15px; }
37
+ `;
38
+ function escapeHtml(raw) {
39
+ return raw
40
+ .replaceAll("&", "&amp;")
41
+ .replaceAll("<", "&lt;")
42
+ .replaceAll(">", "&gt;")
43
+ .replaceAll('"', "&quot;");
44
+ }
45
+ /**
46
+ * Rewrite Odoo-relative URLs (src, href, data-original-src) against the
47
+ * instance base URL: body_arch stores /web/image/... and /unsubscribe_from_list
48
+ * relative — correct in the editor, broken in a file:// temp page or the
49
+ * local preview server. Already-absolute and scheme-relative URLs untouched.
50
+ */
51
+ export function absolutizeUrls(html, baseUrl) {
52
+ const origin = new URL(baseUrl).origin;
53
+ return html.replaceAll(/((?:src|href|data-original-src)=")\/(?!\/)/g, `$1${origin}/`);
54
+ }
55
+ /** Full document for a body_arch fragment: shim + Google Inter, self-contained. */
56
+ export function wrapFragment(bodyArch, title, baseUrl) {
57
+ const body = baseUrl ? absolutizeUrls(bodyArch, baseUrl) : bodyArch;
58
+ return `<!doctype html>
59
+ <html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
60
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
61
+ <style>${PREVIEW_SHIM_CSS}</style></head>
62
+ <body>${body}</body></html>`;
63
+ }
64
+ /** Bare document for a sent body_html (already carries inline send CSS). */
65
+ export function wrapSent(bodyHtml, title, baseUrl) {
66
+ const body = baseUrl ? absolutizeUrls(bodyHtml, baseUrl) : bodyHtml;
67
+ return `<!doctype html>
68
+ <html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
69
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
70
+ </head><body>${body}</body></html>`;
71
+ }
72
+ /** One "Draft (shimmed)" view per mailing plus "As sent" when body_html exists. */
73
+ export function viewsFromMailings(mailings, baseUrl) {
74
+ return buildViews(mailings, baseUrl);
75
+ }
76
+ function buildViews(mailings, baseUrl) {
77
+ const views = [];
78
+ for (const m of mailings) {
79
+ views.push({
80
+ key: `${m.id}-draft`,
81
+ label: `#${m.id} ${m.name || m.subject} — draft`,
82
+ doc: wrapFragment(m.body_arch ?? "", m.name || `mailing #${m.id}`, baseUrl),
83
+ });
84
+ if (m.body_html) {
85
+ views.push({
86
+ key: `${m.id}-sent`,
87
+ label: `#${m.id} ${m.name || m.subject} — as sent`,
88
+ doc: wrapSent(m.body_html, m.name || `mailing #${m.id}`, baseUrl),
89
+ });
90
+ }
91
+ }
92
+ return views;
93
+ }
94
+ /**
95
+ * A preview view for a local HTML file holding Odoo mailing content exactly as
96
+ * the Email Marketing editor would store it in `body_arch` (an `o_layout`
97
+ * fragment). The file itself is never modified: the shim and any URL
98
+ * absolutization are injected at preview-render time only.
99
+ */
100
+ export function viewFromFile(fileName, html, baseUrl) {
101
+ return {
102
+ key: `${fileName}-draft`,
103
+ label: `${fileName} — draft`,
104
+ doc: wrapFragment(html, fileName, baseUrl),
105
+ };
106
+ }
107
+ /** Embed JSON in a <script> safely: break </script> and <script> sequences. */
108
+ function embedJson(value) {
109
+ return JSON.stringify(value)
110
+ .replaceAll("<", "\\u003c")
111
+ .replaceAll(">", "\\u003e")
112
+ .replaceAll("&", "\\u0026");
113
+ }
114
+ const VIEWER_PAGE = String.raw `
115
+ <!doctype html>
116
+ <html lang="en"><head><meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1">
118
+ <title>Mailing preview</title>
119
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
120
+ <style>
121
+ body { margin: 0; background: #eef0f2; font-family: Inter, Arial, sans-serif; color: #212529; }
122
+ header { display: flex; gap: 12px; align-items: center; padding: 8px 16px; background: #fff; border-bottom: 1px solid #d8dbdd; position: sticky; top: 0; z-index: 1; }
123
+ select { font: inherit; padding: 4px 8px; max-width: 60vw; }
124
+ .status { margin-left: auto; font-size: 12px; color: #6c757d; }
125
+ main { padding: 24px 16px 48px; display: flex; justify-content: center; }
126
+ iframe { width: 720px; min-height: 90vh; background: #fff; border: none; box-shadow: 0 1px 4px rgba(0,0,0,.15); }
127
+ </style></head>
128
+ <body>
129
+ <header>
130
+ <label>Mailing <select id="view"></select></label>
131
+ <span class="status" id="status"></span>
132
+ </header>
133
+ <main><iframe id="frame" title="mailing preview"></iframe></main>
134
+ <script id="views-data" type="application/json">[]</script>
135
+ <script>
136
+ const views = JSON.parse(document.getElementById("views-data").textContent);
137
+ const sel = document.getElementById("view");
138
+ const frame = document.getElementById("frame");
139
+ const status = document.getElementById("status");
140
+ for (const v of views) {
141
+ const o = document.createElement("option");
142
+ o.value = v.key; o.textContent = v.label;
143
+ sel.append(o);
144
+ }
145
+ function show() {
146
+ const key = location.hash ? decodeURIComponent(location.hash.slice(1)) : sel.value;
147
+ const v = views.find(x => x.key === key);
148
+ if (!v) return;
149
+ sel.value = v.key;
150
+ frame.srcdoc = v.doc;
151
+ location.hash = v.key;
152
+ status.textContent = v.label;
153
+ }
154
+ sel.onchange = show;
155
+ show();
156
+ new EventSource("/events").onmessage = () => location.reload();
157
+ </script>
158
+ </body></html>
159
+ `;
160
+ /** Self-contained viewer page for a ready set of views. */
161
+ export function buildPageFromViews(views) {
162
+ const placeholder = '<script id="views-data" type="application/json">[]<\/script>';
163
+ return VIEWER_PAGE.replace(placeholder, `<${"script"} id="views-data" type="application/json">${embedJson(views)}</${"script"}>`).trimStart();
164
+ }
165
+ /** Compatibility wrapper: viewer page over mailing records. */
166
+ export function buildPreviewHtml(mailings, baseUrl) {
167
+ return buildPageFromViews(buildViews(mailings, baseUrl));
168
+ }
169
+ export function startPreviewServer(refetch, port) {
170
+ const clients = new Set();
171
+ const server = http.createServer(async (req, res) => {
172
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
173
+ if (url.pathname === "/events") {
174
+ res.writeHead(200, {
175
+ "content-type": "text/event-stream",
176
+ "cache-control": "no-cache",
177
+ connection: "keep-alive",
178
+ });
179
+ res.write("data: hello\n\n");
180
+ clients.add(res);
181
+ req.on("close", () => clients.delete(res));
182
+ return;
183
+ }
184
+ if (url.pathname !== "/") {
185
+ res.writeHead(404).end("not found");
186
+ return;
187
+ }
188
+ try {
189
+ const views = await refetch();
190
+ // Content changed server-side → nudge open pages to reload.
191
+ for (const client of clients)
192
+ client.write("data: reload\n\n");
193
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
194
+ res.end(buildPageFromViews(views));
195
+ }
196
+ catch (cause) {
197
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
198
+ res.end(`preview refetch failed: ${cause.message}`);
199
+ }
200
+ });
201
+ return new Promise((resolve, reject) => {
202
+ server.once("error", reject);
203
+ server.listen(port, "127.0.0.1", () => {
204
+ const address = server.address();
205
+ resolve({
206
+ url: `http://127.0.0.1:${address.port}`,
207
+ notifyChange: () => {
208
+ for (const client of clients)
209
+ client.write("data: reload\n\n");
210
+ },
211
+ close: () => new Promise((done) => {
212
+ for (const client of clients)
213
+ client.end();
214
+ clients.clear();
215
+ server.close(() => done());
216
+ }),
217
+ });
218
+ });
219
+ });
220
+ }
221
+ /** Open a file or URL with the platform opener; never throws on failure. */
222
+ export function openInBrowser(target) {
223
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
224
+ const args = process.platform === "win32" ? ["/c", "start", "", target] : [target];
225
+ try {
226
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
227
+ }
228
+ catch {
229
+ // headless environments: the caller's human output includes the path/URL
230
+ }
231
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bergetai/odoo-cli",
3
- "version": "0.13.1",
3
+ "version": "0.15.1",
4
4
  "description": "Agent-first CLI for the Odoo External API",
5
5
  "publishConfig": {
6
6
  "access": "public"