@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 +16 -0
- package/dist/cli.js +100 -3
- package/dist/commands/calendar.js +67 -0
- package/dist/commands/expenses.js +69 -0
- package/dist/commands/marketing.js +218 -0
- package/dist/preview.js +231 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
+
}
|
package/dist/preview.js
ADDED
|
@@ -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("&", "&")
|
|
41
|
+
.replaceAll("<", "<")
|
|
42
|
+
.replaceAll(">", ">")
|
|
43
|
+
.replaceAll('"', """);
|
|
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
|
+
}
|