forked from 1b-agent/opencadstudio-upstream
1182 lines
47 KiB
TypeScript
1182 lines
47 KiB
TypeScript
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
|
// OneCAD Draft Odoo Documents glue (ported from the OneCAD Simulation glue —
|
|
// apps/fieldcraft-web/src/odoo/bridge.ts; the host wire protocol is unchanged).
|
|
/**
|
|
* OneCAD Draft odoo/bridge.ts — the Odoo Documents bridge client + Rust facade.
|
|
* -----------------------------------------------------------------------
|
|
* Port of the family adapter (one-cut-pro apps/web/src/odoo/bridge.ts,
|
|
* vanilla twin: onepixel frontend/src/odoo-host/bridge.ts) onto the
|
|
* wasm_app_framework host protocol — authoritative implementation:
|
|
* one-os-dev/core-components/wasm_app_framework/static/src/wasm_app_action.js
|
|
*
|
|
* request : {source: "one-cad-draft", kind: "odoo-documents", id, method, payload}
|
|
* posted on window.parent (same-origin Odoo shell iframe)
|
|
* reply : {source: "one-cad-draft-host", kind: "odoo-documents", id, ok,
|
|
* result | error:{message}}
|
|
*
|
|
* Host methods and payload shapes (confirmed against the framework):
|
|
* ping {} -> {user, folder, app:{root, projects?, assets?, exports?}}
|
|
* list {} -> {documents:[{id,name,updatedAt,size}]}
|
|
* read {recordId} -> {name, dataB64}
|
|
* write {recordId?, name?, dataB64, folderId?} -> {id}
|
|
* delete {recordId} -> {deleted}
|
|
* usage {} -> {bytesUsed, documentCount}
|
|
* folders {} -> {tree:[{id,name,parentId,system}], app:{...app, appsFilesRoot}}
|
|
* createFolder {name, parentId?} -> {id}
|
|
* listFolder {folderId} -> {folders:[{id,name,system}], files:[{id,name,ext,mimetype,size,updatedAt}]}
|
|
* saveExport {name, dataB64, folderId?} -> {id} (folderId omitted -> the app's "exports" subfolder)
|
|
* stat {recordId} -> {id, name, mimetype}
|
|
*
|
|
* Family laws: Documents is the ONLY storage; 60 s calls / 300 s writes;
|
|
* one correlation id per call; air-gapped (no external fetch ever).
|
|
* The 128 MB per-record cap is enforced HERE, on the raw bytes, before any
|
|
* base64 conversion (the host mirrors it on base64 length * 3/4).
|
|
*
|
|
* RUST EVENT CONTRACT (window CustomEvents; the Rust odoo bridge module is
|
|
* built separately and consumes host responses through these):
|
|
*
|
|
* LISTEN "onecaddraft:host-request" detail {id:number, method:string,
|
|
* (Rust -> JS) payload?:object}
|
|
* One door for everything. Known host
|
|
* methods (ping/list/read/write/delete/
|
|
* usage/folders/createFolder/listFolder/
|
|
* saveExport/stat) pass straight through
|
|
* to the host with their own payload.
|
|
* Orchestration methods run dialogs +
|
|
* writes in JS and never hit the host:
|
|
* "saveAs" {name?, dataB64?, recordId?, bytes?}
|
|
* "open" {}
|
|
* "export" {name?, dataB64?, bytes?}
|
|
* "importAsset" {name?, dataB64?, bytes?}
|
|
* Bytes may arrive as dataB64 (JSON) or a
|
|
* Uint8Array in payload.bytes; when absent
|
|
* the bridge asks the app to serialize
|
|
* itself via window.onecaddraft (below).
|
|
* REQUEST QUEUE: entries are processed
|
|
* strictly FIFO, one at a time, and each
|
|
* id is answered exactly once.
|
|
*
|
|
* EMIT "onecaddraft:host-response" detail {id, ok, result?, error?{message}}
|
|
* (JS -> Rust) correlate with the request id. Timeouts
|
|
* surface as ok:false errors (60 s calls,
|
|
* 300 s write/read/saveExport).
|
|
*
|
|
* EMIT "onecaddraft:host-ready" detail {host:boolean, documentId, name}
|
|
* (JS -> Rust) fired once by the boot glue (odoo/
|
|
* index.ts) after waitForHost() and any
|
|
* ?document= deep-link read. Late-booting
|
|
* Rust polls the same state sync at
|
|
* window.onecaddraftHost.{ready,host,documentId,name}
|
|
* and retries until the glue's boolean
|
|
* window.onecaddraftHostReady flips true
|
|
* (the glue — not Rust — sets it, once its
|
|
* host-response listener is attached).
|
|
*
|
|
* EMIT "onecaddraft:document-opened" detail {documentId, name, dataB64}
|
|
* (JS -> Rust) fired after the deep-link record was
|
|
* read. The full payload stays available
|
|
* for late listeners via
|
|
* window.onecaddraftHost.consumePendingOpen()
|
|
* (returns the detail once, then null).
|
|
*
|
|
* JS -> APP window.onecaddraft.request("odoo.document.bytes")
|
|
* -> Promise<Uint8Array | ArrayBuffer | {dataB64}> — serialize
|
|
* the current drawing (used when a save/export carries no
|
|
* bytes). request("odoo.document.info") -> Promise<{name?}> —
|
|
* suggested document name; failures are ignored.
|
|
* OneCAD Draft v1 installs NEITHER on the wasm side: the boot
|
|
* glue instead stages opens through ocs_web_drop_file (see
|
|
* index.ts) and saves flow through the save hook (below), so
|
|
* these two request names stay reserved contract surface.
|
|
*
|
|
* SAVE AIR GAP (the key adaptation from the FieldCraft port — this app has
|
|
* no services_odoo.rs take_writes drain; instead src/sys.rs::download_bytes
|
|
* already honors a page-installed save hook):
|
|
*
|
|
* window.openCadStudioSaveFile(name, bytes)
|
|
* Called BY the wasm app (sys.rs:723 download_bytes -> save_hook()
|
|
* -> hook.call2(window, name, Uint8Array)) for EVERY saved or exported
|
|
* file whenever the hook is defined — Save, Save As, save-before-close
|
|
* and all exports — and ONLY then (no file picker, no <a download>:
|
|
* bytes can never reach a browser download while the hook exists).
|
|
* The hook is installed HERE, from the bridge constructor, only when
|
|
* the app runs inside the framework iframe (standalone keeps native
|
|
* downloads). Routing:
|
|
* a. owned extension (.dxf/.dwg) AND a Documents binding exists for
|
|
* the name -> in-place write-back (odoo write on the bound record).
|
|
* b. owned extension, NOT bound -> explorer dialog (SAVE mode) ->
|
|
* create the record in the chosen folder -> bind -> write.
|
|
* c. any other extension (obj/stl/pdf/csv/…) -> saveExport into the
|
|
* Documents "Exports" folder.
|
|
* Every path consumes the bytes (base64-encodes them or drops them with
|
|
* a console warning); the hook never falls back to a browser download.
|
|
* Hook saves are serialized through one promise chain so two quick
|
|
* exports cannot stack two explorer dialogs.
|
|
*
|
|
* STANDALONE DEV MODE — when the app is not inside the framework iframe,
|
|
* the facade goes inert: waitForHost() resolves immediately (host:false),
|
|
* every host call rejects with a clear "standalone" error, host-request
|
|
* events are answered ok:false and the save hook is NOT installed — the
|
|
* app still boots, nothing hangs, downloads keep working.
|
|
*
|
|
* This file is erasable-syntax-only TypeScript with ZERO dependencies: the
|
|
* addon build (esbuild) compiles it to bridge.js next to this file.
|
|
*/
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const WRITE_TIMEOUT_MS = 300_000; // large .dxf/.dwg drawings over the bridge
|
|
const POLL_INTERVAL_MS = 100;
|
|
const HOST_WAIT_MS = 20_000; // ping every 100 ms for up to 20 s
|
|
/** Family cap (wasm_app_action.js MAX_DOCUMENT_BYTES), mirrored client-side. */
|
|
export const MAX_DOCUMENT_BYTES = 128 * 1024 * 1024;
|
|
|
|
export const SOURCE_TAG = "one-cad-draft";
|
|
export const HOST_SOURCE_TAG = "one-cad-draft-host";
|
|
export const BRIDGE_KIND = "odoo-documents";
|
|
|
|
/** Glue bundle version — MUST be bumped in lockstep with the "?v=" literals
|
|
* in the static import specifiers of index.ts and explorer.js and with the
|
|
* addon's bundleVersion/manifest version (see README-odoo.md). */
|
|
export const ODOO_GLUE_VERSION = "20.0.1.0.0";
|
|
|
|
/** Window CustomEvent names of the Rust facade contract (see header). */
|
|
export const EVT_HOST_REQUEST = "onecaddraft:host-request";
|
|
export const EVT_HOST_RESPONSE = "onecaddraft:host-response";
|
|
export const EVT_HOST_READY = "onecaddraft:host-ready";
|
|
export const EVT_DOCUMENT_OPENED = "onecaddraft:document-opened";
|
|
|
|
/** window.onecaddraft request names the bridge calls INTO the app. */
|
|
export const APP_SERIALIZE_METHOD = "odoo.document.bytes";
|
|
export const APP_INFO_METHOD = "odoo.document.info";
|
|
|
|
export interface OdooAppTree {
|
|
root: number;
|
|
projects?: number;
|
|
assets?: number;
|
|
exports?: number;
|
|
appsFilesRoot?: number;
|
|
}
|
|
|
|
export interface PingResult {
|
|
user: string;
|
|
folder: number;
|
|
app: OdooAppTree;
|
|
}
|
|
|
|
export interface FolderRow {
|
|
id: number;
|
|
name: string;
|
|
parentId: number | null;
|
|
system?: boolean;
|
|
}
|
|
|
|
export interface FileRow {
|
|
id: number;
|
|
name: string;
|
|
ext?: string;
|
|
mimetype?: string;
|
|
size: number;
|
|
updatedAt?: string;
|
|
}
|
|
|
|
export interface WriteResult {
|
|
id: number;
|
|
name?: string;
|
|
}
|
|
|
|
export interface ReadResult {
|
|
name: string;
|
|
dataB64: string;
|
|
}
|
|
|
|
export interface StatResult {
|
|
id: number;
|
|
name: string;
|
|
mimetype: string;
|
|
}
|
|
|
|
export interface UsageResult {
|
|
bytesUsed: number;
|
|
documentCount: number;
|
|
}
|
|
|
|
export interface FoldersResult {
|
|
tree: FolderRow[];
|
|
app: OdooAppTree;
|
|
}
|
|
|
|
export interface ListResult {
|
|
documents: FileRow[];
|
|
}
|
|
|
|
export interface CallOptions {
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
export interface BindEntry {
|
|
recordId: number;
|
|
name: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
/** The document the app is currently working on (deep link / last save). */
|
|
export interface CurrentBinding {
|
|
documentId: number | null;
|
|
name: string | null;
|
|
}
|
|
|
|
export interface ExplorerChoice {
|
|
name?: string;
|
|
folderId?: number;
|
|
recordId?: number;
|
|
recordName?: string;
|
|
file?: File;
|
|
}
|
|
|
|
export type ExplorerOpener = (opts: {
|
|
mode: "save" | "open" | "asset" | "export";
|
|
defaultName?: string;
|
|
extensions?: string[];
|
|
}) => Promise<ExplorerChoice | null>;
|
|
|
|
export interface HostRequestDetail {
|
|
id: number;
|
|
method: string;
|
|
payload?: Record<string, unknown> | null;
|
|
/** The Rust odoo bridge may name the request object `params`; payload is
|
|
* the glue spelling. runRequest accepts either. */
|
|
params?: Record<string, unknown> | null;
|
|
}
|
|
|
|
export interface HostResponseDetail {
|
|
id: number;
|
|
ok: boolean;
|
|
result?: unknown;
|
|
error?: { message: string } | null;
|
|
}
|
|
|
|
export interface HostReadyDetail {
|
|
host: boolean;
|
|
documentId: number | null;
|
|
name: string | null;
|
|
}
|
|
|
|
export interface DocumentOpenedDetail {
|
|
documentId: number;
|
|
name: string;
|
|
dataB64: string;
|
|
}
|
|
|
|
export interface SaveAsResult {
|
|
id: number;
|
|
name: string;
|
|
folderId: number | null;
|
|
}
|
|
|
|
export interface OpenResult {
|
|
id: number;
|
|
name: string;
|
|
dataB64: string;
|
|
}
|
|
|
|
export interface SavedResult {
|
|
id: number;
|
|
name: string;
|
|
}
|
|
|
|
/** Host method names accepted on "onecaddraft:host-request" (pass-through). */
|
|
const HOST_METHODS = new Set([
|
|
"ping", "list", "read", "write", "delete", "usage",
|
|
"folders", "createFolder", "listFolder", "saveExport", "stat",
|
|
]);
|
|
/** Host methods that move bulk bytes — family law says 300 s. */
|
|
const BULK_METHODS = new Set(["read", "write", "saveExport"]);
|
|
/** Orchestration methods served by this bridge (dialogs + writes). */
|
|
const UI_METHODS = new Set(["saveAs", "open", "export", "importAsset"]);
|
|
|
|
const BIND_KEY_PREFIX = "one-cad-draft:draft:bind:";
|
|
const OWNED_EXTENSION = ".dxf";
|
|
|
|
/** Extensions the app treats as its drawings: click/URL ownership and the
|
|
* save hook routing key off this pair, open pickers default to it and
|
|
* write-back binding keys are names with either stripped. */
|
|
const OWNED_EXTENSIONS = new Set(["dxf", "dwg"]);
|
|
|
|
/** Rust odoo bridge ASSET_EXTS — odoo.pickProgram offers these from
|
|
* Documents. v1: obj only, plus landxml (the LANDXMLIMPORT command is
|
|
* wired in src/app/commands/display.rs). odoo.import is implemented below
|
|
* but the app does not call it yet. */
|
|
const RUST_ASSET_EXTS = ["obj", "landxml"];
|
|
|
|
/** Emulated pending paths must sanitize exactly like a Rust-side writer. */
|
|
function sanitizeName(name: unknown): string {
|
|
const cleaned = String(name || "").replace(/[/\\]/g, "_").trim();
|
|
return cleaned || "file";
|
|
}
|
|
|
|
/** Pull the Uint8Array payload out of an odoo.write / odoo.import event. */
|
|
function bytesOf(payload: Record<string, unknown>): Uint8Array | null {
|
|
const raw = payload ? payload.bytes : undefined;
|
|
if (!raw) return null;
|
|
if (raw instanceof Uint8Array) return raw;
|
|
// Cross-realm payloads (tests, odd hosts): isView works across realms.
|
|
if (typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(raw)) {
|
|
return new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
}
|
|
if (typeof ArrayBuffer !== "undefined" && raw instanceof ArrayBuffer) return new Uint8Array(raw);
|
|
return normalizeBytes(raw);
|
|
}
|
|
|
|
function newToken(): string {
|
|
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
|
return "t" + window.crypto.randomUUID().replace(/-/g, "");
|
|
}
|
|
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
}
|
|
|
|
interface OnecaddraftApi {
|
|
request?: (method: string, params?: unknown) => Promise<unknown>;
|
|
}
|
|
|
|
interface PendingTokenBinding {
|
|
recordId: number | null;
|
|
name: string;
|
|
folderId: number | null;
|
|
}
|
|
|
|
interface PendingEntry {
|
|
resolve: (value: unknown) => void;
|
|
reject: (error: Error) => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
interface QueueEntry {
|
|
id: number;
|
|
method: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Codec + guard helpers (exported for the explorer and the app glue) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** Chunked btoa so multi-megabyte CAD drawings never blow the arg stack. */
|
|
export function bytesToBase64(bytes: Uint8Array): string {
|
|
let binary = "";
|
|
const chunk = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += chunk) {
|
|
const slice = bytes.subarray(i, i + chunk);
|
|
binary += String.fromCharCode.apply(null, Array.from(slice) as unknown as number[]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
export function base64ToBytes(dataB64: string): Uint8Array {
|
|
const binary = atob(dataB64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
return bytes;
|
|
}
|
|
|
|
/** Family 128 MB cap, enforced on RAW bytes before base64 conversion. */
|
|
export function guardCap(bytes: Uint8Array): void {
|
|
if (bytes.byteLength > MAX_DOCUMENT_BYTES) {
|
|
throw new Error(
|
|
"OneCAD Draft odoo bridge: document exceeds the 128 MB bridge limit (" +
|
|
bytes.byteLength + " raw bytes)",
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeBytes(value: unknown): Uint8Array | null {
|
|
if (value instanceof Uint8Array) return value;
|
|
if (value instanceof ArrayBuffer) return new Uint8Array(value);
|
|
if (value && typeof value === "object") {
|
|
const holder = value as { dataB64?: unknown };
|
|
if (typeof holder.dataB64 === "string") return base64ToBytes(holder.dataB64);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function ensureExtension(name: string, extension: string): string {
|
|
const trimmed = (name || "").trim() || "Untitled";
|
|
const dot = trimmed.lastIndexOf(".");
|
|
if (dot > 0) return trimmed;
|
|
return trimmed + extension;
|
|
}
|
|
|
|
/** A name is "owned" when it carries one of the app's drawing extensions. */
|
|
function extensionOf(name: string): string {
|
|
const dot = (name || "").lastIndexOf(".");
|
|
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
|
}
|
|
|
|
function stripOwnedExtension(name: string): string {
|
|
return (name || "").replace(/\.(dxf|dwg)$/i, "");
|
|
}
|
|
|
|
function detectInFrame(): boolean {
|
|
try {
|
|
return window.parent !== window;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function onecaddraftApi(): OnecaddraftApi | null {
|
|
const holder = window as unknown as { onecaddraft?: OnecaddraftApi };
|
|
const api = holder.onecaddraft;
|
|
return api && typeof api.request === "function" ? api : null;
|
|
}
|
|
|
|
function messageOf(error: unknown): string {
|
|
return error && typeof error === "object" && "message" in error
|
|
? String((error as { message?: unknown }).message)
|
|
: String(error);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* The bridge */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
class OneCadBridge {
|
|
inFrame: boolean;
|
|
standalone: boolean = false;
|
|
nextId: number = 1;
|
|
pending: Map<number, PendingEntry> = new Map();
|
|
queue: QueueEntry[] = [];
|
|
draining: boolean = false;
|
|
opener: ExplorerOpener | null = null;
|
|
binding: CurrentBinding = { documentId: null, name: null };
|
|
pendingOpen: DocumentOpenedDetail | null = null;
|
|
/** odoo.pickSave tokens -> binding (the Rust odoo bridge writes to
|
|
* /odoo/pending/<token>/<name>; the first write creates the record and
|
|
* every later Ctrl+S writes back in place). */
|
|
pendingTokens = new Map<string, PendingTokenBinding>();
|
|
/** Save-hook saves are serialized through this chain: one explorer
|
|
* dialog at a time, bytes routed strictly in arrival order. */
|
|
saveHookChain: Promise<void> = Promise.resolve();
|
|
|
|
constructor() {
|
|
this.inFrame = detectInFrame();
|
|
// The save hook MUST exist before the app's first save: sys.rs checks
|
|
// window.openCadStudioSaveFile on every download, and the wasm boot can
|
|
// win the race against later boot steps. Registered only in-frame so a
|
|
// standalone dev session keeps its normal browser downloads.
|
|
if (this.inFrame) this.installSaveHook();
|
|
window.addEventListener("message", (event) => this.onMessage(event));
|
|
window.addEventListener(EVT_HOST_REQUEST, ((event: Event) => {
|
|
const detail = (event as CustomEvent).detail as HostRequestDetail | undefined;
|
|
if (!detail || typeof detail.id !== "number" || typeof detail.method !== "string") return;
|
|
// The Rust odoo bridge may dispatch the request object as `params`;
|
|
// the orchestration glue historically used `payload`. Accept both.
|
|
const raw = detail.payload !== undefined ? detail.payload : detail.params;
|
|
this.enqueue({
|
|
id: detail.id,
|
|
method: detail.method,
|
|
payload: (raw && typeof raw === "object"
|
|
? raw
|
|
: {}) as Record<string, unknown>,
|
|
});
|
|
}) as EventListener);
|
|
}
|
|
|
|
/* ---------------- host plumbing ---------------- */
|
|
|
|
isHostAvailable(): boolean {
|
|
return this.inFrame && !this.standalone;
|
|
}
|
|
|
|
private onMessage(event: MessageEvent): void {
|
|
const message = event.data as
|
|
| { source?: string; kind?: string; id?: number; ok?: boolean; result?: unknown; error?: { message?: string } | null }
|
|
| null;
|
|
if (
|
|
!message ||
|
|
typeof message !== "object" ||
|
|
message.source !== HOST_SOURCE_TAG ||
|
|
message.kind !== BRIDGE_KIND ||
|
|
typeof message.id !== "number"
|
|
) {
|
|
return;
|
|
}
|
|
const pending = this.pending.get(message.id);
|
|
if (!pending) return;
|
|
this.pending.delete(message.id);
|
|
clearTimeout(pending.timer);
|
|
if (message.ok) pending.resolve(message.result);
|
|
else pending.reject(new Error(message.error?.message || "Odoo Documents bridge call failed"));
|
|
}
|
|
|
|
/** One correlation id per call; 60 s default / 300 s for writes. */
|
|
call<T>(method: string, payload?: Record<string, unknown>, options?: CallOptions): Promise<T> {
|
|
if (!this.inFrame) {
|
|
this.standalone = true;
|
|
return Promise.reject(
|
|
new Error(`OneCAD Draft odoo bridge: standalone (no Odoo host) — "${method}" unavailable`),
|
|
);
|
|
}
|
|
const timeoutMs = (options && options.timeoutMs) || DEFAULT_TIMEOUT_MS;
|
|
const id = this.nextId++;
|
|
const envelope = {
|
|
source: SOURCE_TAG,
|
|
kind: BRIDGE_KIND,
|
|
id,
|
|
method,
|
|
payload: payload || {},
|
|
};
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.pending.delete(id);
|
|
reject(new Error(`OneCAD Draft odoo bridge: "${method}" timed out after ${timeoutMs} ms`));
|
|
}, timeoutMs);
|
|
this.pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
|
// The framework shell is our parent window (same origin).
|
|
window.parent.postMessage(envelope, window.location.origin);
|
|
});
|
|
}
|
|
|
|
/** Ping every 100 ms until the host answers (mount-order independence). */
|
|
async waitForHost(timeoutMs: number = HOST_WAIT_MS): Promise<PingResult> {
|
|
if (!this.inFrame) {
|
|
this.standalone = true;
|
|
return { user: "", folder: 0, app: { root: 0 } };
|
|
}
|
|
const deadline = Date.now() + timeoutMs;
|
|
let lastError: Error | null = null;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const pong = await this.ping();
|
|
this.standalone = false;
|
|
return pong;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
}
|
|
}
|
|
throw new Error(
|
|
`OneCAD Draft odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`,
|
|
);
|
|
}
|
|
|
|
/* ---------------- raw host calls ---------------- */
|
|
|
|
ping(): Promise<PingResult> {
|
|
return this.call<PingResult>("ping");
|
|
}
|
|
|
|
list(): Promise<ListResult> {
|
|
return this.call<ListResult>("list");
|
|
}
|
|
|
|
stat(recordId: number): Promise<StatResult> {
|
|
return this.call<StatResult>("stat", { recordId });
|
|
}
|
|
|
|
read(recordId: number): Promise<ReadResult> {
|
|
return this.call<ReadResult>("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS });
|
|
}
|
|
|
|
write(payload: { recordId?: number | null; name?: string; dataB64: string; folderId?: number | null }, timeoutMs?: number): Promise<WriteResult> {
|
|
return this.call<WriteResult>("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
|
|
}
|
|
|
|
delete(recordId: number): Promise<{ deleted: boolean }> {
|
|
return this.call<{ deleted: boolean }>("delete", { recordId });
|
|
}
|
|
|
|
usage(): Promise<UsageResult> {
|
|
return this.call<UsageResult>("usage");
|
|
}
|
|
|
|
folders(): Promise<FoldersResult> {
|
|
return this.call<FoldersResult>("folders");
|
|
}
|
|
|
|
createFolder(payload: { name: string; parentId?: number }): Promise<{ id: number }> {
|
|
return this.call<{ id: number }>("createFolder", payload);
|
|
}
|
|
|
|
listFolder(folderId: number): Promise<{ folders: FolderRow[]; files: FileRow[] }> {
|
|
return this.call<{ folders: FolderRow[]; files: FileRow[] }>("listFolder", { folderId });
|
|
}
|
|
|
|
saveExport(payload: { name: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise<WriteResult> {
|
|
return this.call<WriteResult>("saveExport", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS });
|
|
}
|
|
|
|
/* ---------------- binding cache (localStorage) ---------------- */
|
|
|
|
getCurrentBinding(): CurrentBinding {
|
|
return this.binding;
|
|
}
|
|
|
|
/** document name -> Odoo record id, keyed by app + document name. */
|
|
bindLookup(name: string): BindEntry | null {
|
|
try {
|
|
const raw = window.localStorage.getItem(BIND_KEY_PREFIX + stripOwnedExtension(name));
|
|
if (!raw) return null;
|
|
const entry = JSON.parse(raw) as BindEntry;
|
|
return entry && typeof entry.recordId === "number" ? entry : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
bindSet(name: string, recordId: number): void {
|
|
this.binding = { documentId: recordId, name: stripOwnedExtension(name) };
|
|
try {
|
|
const entry: BindEntry = {
|
|
recordId,
|
|
name: stripOwnedExtension(name),
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
window.localStorage.setItem(BIND_KEY_PREFIX + entry.name, JSON.stringify(entry));
|
|
} catch {
|
|
/* private mode — memory-only binding still works for this session */
|
|
}
|
|
}
|
|
|
|
/* ---------------- document flows ---------------- */
|
|
|
|
/** Ctrl+S style write-back / create. Guard runs BEFORE base64. */
|
|
async saveDocument(bytes: Uint8Array | ArrayBuffer, name: string, recordId?: number): Promise<WriteResult> {
|
|
const raw = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
guardCap(raw);
|
|
const dataB64 = bytesToBase64(raw);
|
|
const clean = ensureExtension(name, OWNED_EXTENSION);
|
|
const result = await this.write({ recordId, name: clean, dataB64 });
|
|
this.bindSet(result.name || clean, result.id);
|
|
return result;
|
|
}
|
|
|
|
/** Deep-link read; also refreshes the current binding. */
|
|
async readDocument(recordId: number): Promise<ReadResult> {
|
|
const result = await this.read(recordId);
|
|
this.bindSet(result.name, recordId);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* ?document=<id> boot deep link. Never throws (boot must proceed);
|
|
* on success stores pendingOpen, fires "onecaddraft:document-opened" and
|
|
* refreshes the binding so host-ready can carry the metadata.
|
|
*/
|
|
async openDeepLink(search?: string): Promise<boolean> {
|
|
let params: URLSearchParams;
|
|
try {
|
|
params = new URLSearchParams(search === undefined ? window.location.search : search);
|
|
} catch {
|
|
return false;
|
|
}
|
|
const rawId = params.get("document");
|
|
if (!rawId) return false;
|
|
const id = Number(rawId);
|
|
if (!Number.isInteger(id) || id <= 0) {
|
|
console.warn("[onecaddraft-odoo] ignoring malformed deep link ?document=" + rawId);
|
|
return false;
|
|
}
|
|
try {
|
|
const opened = await this.readDocument(id);
|
|
this.pendingOpen = { documentId: id, name: opened.name, dataB64: opened.dataB64 };
|
|
window.dispatchEvent(
|
|
new CustomEvent<DocumentOpenedDetail>(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }),
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
console.warn("[onecaddraft-odoo] deep-link read failed for document " + id + ":", messageOf(error));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Late-boot consumers fetch the deep-link payload once, then null. */
|
|
consumePendingOpen(): DocumentOpenedDetail | null {
|
|
const pending = this.pendingOpen;
|
|
this.pendingOpen = null;
|
|
return pending;
|
|
}
|
|
|
|
setExplorerOpener(opener: ExplorerOpener): void {
|
|
this.opener = opener;
|
|
}
|
|
|
|
private async askExplorer(options: {
|
|
mode: "save" | "open" | "asset" | "export";
|
|
defaultName?: string;
|
|
extensions?: string[];
|
|
}): Promise<ExplorerChoice | null> {
|
|
if (!this.isHostAvailable()) {
|
|
throw new Error("OneCAD Draft odoo bridge: standalone (no Odoo host) — explorer unavailable");
|
|
}
|
|
if (!this.opener) {
|
|
throw new Error("OneCAD Draft odoo bridge: no explorer dialog wired (setExplorerOpener)");
|
|
}
|
|
return this.opener(options);
|
|
}
|
|
|
|
private async appSerialize(): Promise<Uint8Array> {
|
|
const api = onecaddraftApi();
|
|
if (api && api.request) {
|
|
const out = await api.request(APP_SERIALIZE_METHOD);
|
|
const bytes = normalizeBytes(out);
|
|
if (bytes) return bytes;
|
|
throw new Error("OneCAD Draft odoo bridge: window.onecaddraft did not return drawing bytes");
|
|
}
|
|
// OneCAD Draft fallback: the control channel's "save" op serializes the
|
|
// active drawing without any download (src/app/control/mod.rs "save") —
|
|
// used when a save/export flow arrives without bytes and no
|
|
// window.onecaddraft facade is installed. allow_lossy is acknowledged
|
|
// because a host save must never wedge on the lossy prompt.
|
|
const bindings = wasmControlApi();
|
|
if (!bindings) {
|
|
throw new Error(
|
|
"OneCAD Draft odoo bridge: no drawing bytes given and neither window.onecaddraft nor the control channel is available",
|
|
);
|
|
}
|
|
const reply = (await controlExchange(
|
|
bindings,
|
|
{ op: "save", allow_lossy: true },
|
|
WRITE_TIMEOUT_MS,
|
|
)) as { dataB64?: unknown; data_base64?: unknown; ok?: unknown; error?: unknown };
|
|
if (reply && reply.ok === false) {
|
|
throw new Error("OneCAD Draft odoo bridge: control save failed (" + messageOf(reply.error) + ")");
|
|
}
|
|
const raw =
|
|
typeof reply?.dataB64 === "string" ? reply.dataB64 :
|
|
typeof reply?.data_base64 === "string" ? reply.data_base64 : "";
|
|
const bytes = raw ? base64ToBytes(raw) : null;
|
|
if (!bytes) {
|
|
throw new Error("OneCAD Draft odoo bridge: control save returned no drawing bytes");
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
private async appInfo(): Promise<{ name?: string }> {
|
|
const api = onecaddraftApi();
|
|
if (!api || !api.request) return {};
|
|
try {
|
|
const info = (await api.request(APP_INFO_METHOD)) as { name?: unknown } | null;
|
|
return info && typeof info.name === "string" ? { name: info.name } : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** Save-as flow: bytes -> explorer (tree + name) -> write -> bind. */
|
|
async saveAs(options?: {
|
|
bytes?: Uint8Array | ArrayBuffer;
|
|
dataB64?: string;
|
|
name?: string;
|
|
recordId?: number;
|
|
}): Promise<SaveAsResult | null> {
|
|
const opts = options || {};
|
|
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
|
if (!dataB64) {
|
|
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
|
|
guardCap(raw);
|
|
dataB64 = bytesToBase64(raw);
|
|
}
|
|
const info = await this.appInfo();
|
|
const suggested = opts.name || info.name || "untitled";
|
|
const choice = await this.askExplorer({
|
|
mode: "save",
|
|
defaultName: ensureExtension(suggested, OWNED_EXTENSION),
|
|
});
|
|
if (!choice || !choice.name) return null; // cancelled
|
|
const name = ensureExtension(choice.name, OWNED_EXTENSION);
|
|
const result = await this.write({
|
|
recordId: opts.recordId,
|
|
name,
|
|
dataB64,
|
|
folderId: choice.folderId,
|
|
});
|
|
this.bindSet(result.name || name, result.id);
|
|
return { id: result.id, name: result.name || name, folderId: choice.folderId || null };
|
|
}
|
|
|
|
/** Open flow: explorer (.dxf/.dwg filter) -> read 300 s. */
|
|
async openFromDocuments(): Promise<OpenResult | null> {
|
|
const choice = await this.askExplorer({ mode: "open", extensions: ["dxf", "dwg"] });
|
|
if (!choice || typeof choice.recordId !== "number") return null; // cancelled
|
|
const opened = await this.readDocument(choice.recordId);
|
|
return { id: choice.recordId, name: opened.name, dataB64: opened.dataB64 };
|
|
}
|
|
|
|
/** Export flow: bytes -> (optional name dialog) -> saveExport -> Exports. */
|
|
async exportTo(options?: {
|
|
bytes?: Uint8Array | ArrayBuffer;
|
|
dataB64?: string;
|
|
name?: string;
|
|
}): Promise<SavedResult | null> {
|
|
const opts = options || {};
|
|
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
|
if (!dataB64) {
|
|
const raw = opts.bytes ? (opts.bytes instanceof Uint8Array ? opts.bytes : new Uint8Array(opts.bytes)) : await this.appSerialize();
|
|
guardCap(raw);
|
|
dataB64 = bytesToBase64(raw);
|
|
}
|
|
let name = (opts.name || "").trim();
|
|
let folderId: number | undefined;
|
|
if (!name) {
|
|
const info = await this.appInfo();
|
|
const choice = await this.askExplorer({
|
|
mode: "export",
|
|
defaultName: (info.name || "export") + ".dxf",
|
|
});
|
|
if (!choice || !choice.name) return null; // cancelled
|
|
name = choice.name;
|
|
folderId = choice.folderId;
|
|
}
|
|
const result = await this.saveExport({ name, dataB64, folderId });
|
|
return { id: result.id, name: result.name || name };
|
|
}
|
|
|
|
/** Import flow: file picker (or given bytes) -> write into Assets. */
|
|
async importAsset(options?: {
|
|
file?: File;
|
|
bytes?: Uint8Array | ArrayBuffer;
|
|
dataB64?: string;
|
|
name?: string;
|
|
}): Promise<SavedResult | null> {
|
|
const opts = options || {};
|
|
let dataB64 = typeof opts.dataB64 === "string" && opts.dataB64 ? opts.dataB64 : "";
|
|
let name = (opts.name || "").trim();
|
|
let folderId: number | undefined;
|
|
if (!dataB64 && !opts.file && !opts.bytes) {
|
|
const choice = await this.askExplorer({ mode: "asset" });
|
|
if (!choice || !(choice.file instanceof File)) return null; // cancelled
|
|
folderId = choice.folderId;
|
|
const raw = new Uint8Array(await choice.file.arrayBuffer());
|
|
guardCap(raw);
|
|
dataB64 = bytesToBase64(raw);
|
|
name = name || choice.file.name;
|
|
} else if (!dataB64) {
|
|
const raw = opts.bytes
|
|
? opts.bytes instanceof Uint8Array
|
|
? opts.bytes
|
|
: new Uint8Array(opts.bytes)
|
|
: new Uint8Array(await (opts.file as File).arrayBuffer());
|
|
guardCap(raw);
|
|
dataB64 = bytesToBase64(raw);
|
|
name = name || (opts.file ? opts.file.name : "asset");
|
|
}
|
|
if (!folderId) {
|
|
const app = (await this.ping()).app;
|
|
folderId = app.assets || app.root;
|
|
}
|
|
const result = await this.write({ name: name || "asset", dataB64, folderId });
|
|
return { id: result.id, name: result.name || name || "asset" };
|
|
}
|
|
|
|
/* ---------------- save air gap (window.openCadStudioSaveFile) ---------------- */
|
|
|
|
/**
|
|
* Install the page save hook the wasm app checks on every download
|
|
* (src/sys.rs::download_bytes -> save_hook()). While the hook exists the
|
|
* app routes EVERY saved/exported file here and never opens a file picker
|
|
* or a browser download — the bytes are consumed below or dropped with a
|
|
* warning, never handed back to the browser.
|
|
*/
|
|
private installSaveHook(): void {
|
|
try {
|
|
const holder = window as unknown as {
|
|
openCadStudioSaveFile?: (name: string, bytes: Uint8Array) => boolean;
|
|
};
|
|
holder.openCadStudioSaveFile = (name: unknown, bytes: unknown): boolean => {
|
|
this.routeSaveHook(name, bytes);
|
|
return true; // consumed — the app must never fall back to a download
|
|
};
|
|
} catch (error) {
|
|
console.warn("[onecaddraft-odoo] could not install openCadStudioSaveFile:", error);
|
|
}
|
|
}
|
|
|
|
/** Normalize the hook arguments and queue the save; never throws. */
|
|
private routeSaveHook(rawName: unknown, rawBytes: unknown): void {
|
|
const name = sanitizeName(typeof rawName === "string" ? rawName : "");
|
|
let bytes: Uint8Array | null = null;
|
|
if (rawBytes instanceof Uint8Array) bytes = rawBytes;
|
|
else if (rawBytes && typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView(rawBytes)) {
|
|
const view = rawBytes as ArrayBufferView;
|
|
bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
|
|
} else bytes = normalizeBytes(rawBytes);
|
|
if (!bytes || bytes.byteLength === 0) {
|
|
console.warn("[onecaddraft-odoo] save hook: \"" + name + "\" carried no bytes — dropped (air-gapped)");
|
|
return;
|
|
}
|
|
// One dialog at a time, arrival order; a failed save never breaks the chain.
|
|
this.saveHookChain = this.saveHookChain
|
|
.then(() => this.saveHookRoute(name, bytes as Uint8Array))
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
/** Hook routing: a/b/c per the header contract. Bytes are ALWAYS consumed. */
|
|
private async saveHookRoute(name: string, bytes: Uint8Array): Promise<void> {
|
|
const extension = extensionOf(name);
|
|
try {
|
|
guardCap(bytes);
|
|
const dataB64 = bytesToBase64(bytes);
|
|
if (OWNED_EXTENSIONS.has(extension)) {
|
|
const bind = this.bindLookup(name);
|
|
if (bind) {
|
|
// (a) in-place write-back to the bound record.
|
|
const result = await this.write({ recordId: bind.recordId, name, dataB64 });
|
|
this.bindSet(result.name || name, result.id);
|
|
console.info(
|
|
"[onecaddraft-odoo] wrote \"" + name + "\" back to Documents record " + result.id,
|
|
);
|
|
} else {
|
|
// (b) unbound drawing: explorer decides the folder (Projects by
|
|
// default), then create + bind — future saves take path (a).
|
|
const choice = await this.askExplorer({
|
|
mode: "save",
|
|
defaultName: ensureExtension(name, OWNED_EXTENSION),
|
|
});
|
|
if (!choice || !choice.name) {
|
|
console.info(
|
|
"[onecaddraft-odoo] save cancelled — \"" + name + "\" bytes discarded (no browser download; retry Save to store it)",
|
|
);
|
|
return;
|
|
}
|
|
const clean = ensureExtension(choice.name, OWNED_EXTENSION);
|
|
const result = await this.write({ name: clean, dataB64, folderId: choice.folderId });
|
|
this.bindSet(result.name || clean, result.id);
|
|
console.info(
|
|
"[onecaddraft-odoo] saved \"" + (result.name || clean) + "\" to Documents (record " + result.id + ") — future saves write back in place",
|
|
);
|
|
}
|
|
} else {
|
|
// (c) every other format (obj/stl/pdf/csv/png/zip/…) -> Exports.
|
|
const result = await this.saveExport({ name, dataB64 });
|
|
console.info(
|
|
"[onecaddraft-odoo] exported \"" + (result.name || name) + "\" to the Documents Exports folder (record " + result.id + ")",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.warn(
|
|
"[onecaddraft-odoo] save hook: \"" + name + "\" could not reach Odoo Documents (" +
|
|
messageOf(error) + ") — bytes discarded, no browser download (air gap)",
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ---------------- Rust event queue ---------------- */
|
|
|
|
private enqueue(entry: QueueEntry): void {
|
|
this.queue.push(entry);
|
|
void this.drainQueue();
|
|
}
|
|
|
|
/** Strictly FIFO, one request at a time; every id answered exactly once. */
|
|
private async drainQueue(): Promise<void> {
|
|
if (this.draining) return;
|
|
this.draining = true;
|
|
while (this.queue.length > 0) {
|
|
const entry = this.queue.shift() as QueueEntry;
|
|
try {
|
|
const result = await this.runRequest(entry.method, entry.payload);
|
|
this.respond(entry.id, true, result, null);
|
|
} catch (error) {
|
|
this.respond(entry.id, false, null, { message: messageOf(error) });
|
|
}
|
|
}
|
|
this.draining = false;
|
|
}
|
|
|
|
private respond(id: number, ok: boolean, result: unknown, error: { message: string } | null): void {
|
|
const detail: HostResponseDetail = { id, ok, result, error };
|
|
window.dispatchEvent(new CustomEvent<HostResponseDetail>(EVT_HOST_RESPONSE, { detail }));
|
|
}
|
|
|
|
private async runRequest(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
if (method === "odoo.pickOpen" || method === "odoo.pickProgram") return this.pickForRust(method, payload);
|
|
if (method === "odoo.pickSave") return this.pickSaveForRust(payload);
|
|
if (method === "odoo.write") return this.rustWrite(payload);
|
|
if (method === "odoo.saveExport") return this.rustSaveExport(payload);
|
|
if (method === "odoo.import") return this.rustImport(payload);
|
|
if (method === "saveAs") {
|
|
return this.saveAs({
|
|
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
|
name: typeof payload.name === "string" ? payload.name : undefined,
|
|
recordId: typeof payload.recordId === "number" ? payload.recordId : undefined,
|
|
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
|
});
|
|
}
|
|
if (method === "open") return this.openFromDocuments();
|
|
if (method === "export") {
|
|
return this.exportTo({
|
|
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
|
name: typeof payload.name === "string" ? payload.name : undefined,
|
|
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
|
});
|
|
}
|
|
if (method === "importAsset") {
|
|
return this.importAsset({
|
|
dataB64: typeof payload.dataB64 === "string" ? payload.dataB64 : undefined,
|
|
name: typeof payload.name === "string" ? payload.name : undefined,
|
|
bytes: (payload.bytes instanceof Uint8Array ? payload.bytes : undefined) as Uint8Array | undefined,
|
|
file: payload.file instanceof File ? payload.file : undefined,
|
|
});
|
|
}
|
|
if (HOST_METHODS.has(method)) {
|
|
const timeoutMs = BULK_METHODS.has(method) ? WRITE_TIMEOUT_MS : undefined;
|
|
return this.call<unknown>(method, payload, { timeoutMs });
|
|
}
|
|
throw new Error("OneCAD Draft odoo bridge: unknown host-request method \"" + method + "\"");
|
|
}
|
|
|
|
/* ------- Rust odoo bridge wire protocol (CustomEvent round trip) ------- */
|
|
/** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */
|
|
private async pickForRust(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
const requested = Array.isArray(payload.extensions) && payload.extensions.length
|
|
? (payload.extensions as string[])
|
|
: null;
|
|
const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["dxf", "dwg"]);
|
|
const choice = await this.askExplorer({ mode: "open", extensions });
|
|
if (!choice || typeof choice.recordId !== "number") return null;
|
|
const opened = await this.readDocument(choice.recordId);
|
|
return {
|
|
recordId: choice.recordId,
|
|
name: opened.name || choice.recordName || "document",
|
|
bytes: base64ToBytes(opened.dataB64),
|
|
};
|
|
}
|
|
/** odoo.pickSave -> {token, name}; the token binds the later write-back. */
|
|
private async pickSaveForRust(payload: Record<string, unknown>): Promise<unknown> {
|
|
const design = payload.design !== false;
|
|
const suggested = typeof payload.name === "string" && payload.name.trim() ? payload.name.trim() : "untitled";
|
|
const choice = await this.askExplorer({
|
|
mode: "save",
|
|
defaultName: design ? ensureExtension(suggested, OWNED_EXTENSION) : suggested,
|
|
});
|
|
if (!choice || !choice.name) return null;
|
|
const token = newToken();
|
|
const name = sanitizeName(choice.name);
|
|
this.pendingTokens.set(token, { recordId: null, name, folderId: choice.folderId || null });
|
|
return { token, name };
|
|
}
|
|
/** odoo.write: /odoo/doc/<rid>/<name> in place, /odoo/pending/<token>/<name> first save. */
|
|
private async rustWrite(payload: Record<string, unknown>): Promise<unknown> {
|
|
const bytes = bytesOf(payload);
|
|
if (!bytes) throw new Error("odoo.write: no bytes given");
|
|
guardCap(bytes);
|
|
const dataB64 = bytesToBase64(bytes);
|
|
const path = typeof payload.path === "string" ? payload.path : "";
|
|
if (path.startsWith("/odoo/pending/")) {
|
|
const rest = path.slice("/odoo/pending/".length);
|
|
const slash = rest.indexOf("/");
|
|
const token = slash > 0 ? rest.slice(0, slash) : "";
|
|
const pathName = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
|
|
const bind = token ? this.pendingTokens.get(token) : undefined;
|
|
const name = (bind ? bind.name : "") || pathName;
|
|
let recordId = bind ? bind.recordId : null;
|
|
if (!recordId) {
|
|
const remembered = this.bindLookup(name);
|
|
recordId = remembered ? remembered.recordId : null;
|
|
}
|
|
const result = await this.write({ recordId, name, dataB64, folderId: bind ? bind.folderId : undefined });
|
|
if (bind) bind.recordId = result.id;
|
|
else if (token) this.pendingTokens.set(token, { recordId: result.id, name, folderId: null });
|
|
this.bindSet(result.name || name, result.id);
|
|
return { id: result.id, name: result.name || name };
|
|
}
|
|
if (path.startsWith("/odoo/doc/")) {
|
|
const rest = path.slice("/odoo/doc/".length);
|
|
const slash = rest.indexOf("/");
|
|
const recordId = slash > 0 ? Number(rest.slice(0, slash)) : 0;
|
|
const name = slash > 0 ? sanitizeName(rest.slice(slash + 1)) : "document";
|
|
if (!Number.isInteger(recordId) || recordId <= 0) {
|
|
throw new Error("odoo.write: bad record path \"" + path + "\"");
|
|
}
|
|
const result = await this.write({ recordId, name, dataB64 });
|
|
this.bindSet(result.name || name, recordId);
|
|
return { id: result.id, name: result.name || name };
|
|
}
|
|
throw new Error("odoo.write: unsupported path \"" + path + "\"");
|
|
}
|
|
/** odoo.saveExport: {name, bytes} -> Documents "Exports" folder. */
|
|
private async rustSaveExport(payload: Record<string, unknown>): Promise<unknown> {
|
|
const bytes = bytesOf(payload);
|
|
if (!bytes) throw new Error("odoo.saveExport: no bytes given");
|
|
guardCap(bytes);
|
|
const result = await this.saveExport({ name: sanitizeName(payload.name) || "export", dataB64: bytesToBase64(bytes) });
|
|
return { id: result.id, name: result.name };
|
|
}
|
|
/** odoo.import: mirror a computer import into the Documents "Assets" folder. */
|
|
private async rustImport(payload: Record<string, unknown>): Promise<unknown> {
|
|
const bytes = bytesOf(payload);
|
|
if (!bytes) throw new Error("odoo.import: no bytes given");
|
|
guardCap(bytes);
|
|
const app = (await this.ping()).app;
|
|
const name = typeof payload.name === "string" && payload.name ? payload.name : "asset";
|
|
const result = await this.write({ name, dataB64: bytesToBase64(bytes), folderId: app.assets || app.root });
|
|
return { id: result.id, name: result.name };
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Control-channel helpers (src/app/control/mod.rs via wasmBindings) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
/** The Trunk loader publishes the wasm-bindgen module as window.wasmBindings
|
|
* (docs/automation/web.md); the control channel is ready once its
|
|
* ocs_control_submit/ocs_control_take pair is defined. */
|
|
interface WasmControlApi {
|
|
ocs_control_submit: (request: string) => string;
|
|
ocs_control_take: (ticket: string) => string | undefined;
|
|
}
|
|
|
|
function wasmControlApi(): WasmControlApi | null {
|
|
const bindings = (window as unknown as { wasmBindings?: Partial<WasmControlApi> }).wasmBindings;
|
|
return bindings &&
|
|
typeof bindings.ocs_control_submit === "function" &&
|
|
typeof bindings.ocs_control_take === "function"
|
|
? (bindings as WasmControlApi)
|
|
: null;
|
|
}
|
|
|
|
/** Submit one control request and follow it to the final reply: the app
|
|
* answers async work with ok:true accepted/running + request_id (polled
|
|
* through {"op":"operation"}) and stores the reply under the LAST ticket. */
|
|
async function controlExchange(
|
|
api: WasmControlApi,
|
|
request: Record<string, unknown>,
|
|
timeoutMs: number,
|
|
): Promise<Record<string, unknown>> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let ticket = api.ocs_control_submit(JSON.stringify(request));
|
|
while (Date.now() < deadline) {
|
|
const raw = api.ocs_control_take(ticket);
|
|
if (raw === undefined) {
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
continue;
|
|
}
|
|
let reply: Record<string, unknown>;
|
|
try {
|
|
reply = JSON.parse(raw) as Record<string, unknown>;
|
|
} catch {
|
|
throw new Error("OneCAD Draft odoo bridge: control channel returned unparsable JSON");
|
|
}
|
|
const status = typeof reply.status === "string" ? reply.status : "";
|
|
if (reply.ok === true && (status === "accepted" || status === "running")) {
|
|
const requestId = typeof reply.request_id === "string" ? reply.request_id : "";
|
|
if (!requestId) {
|
|
throw new Error("OneCAD Draft odoo bridge: control reply carries no request_id");
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
ticket = api.ocs_control_submit(JSON.stringify({ op: "operation", request_id: requestId }));
|
|
continue;
|
|
}
|
|
return reply;
|
|
}
|
|
throw new Error("OneCAD Draft odoo bridge: control request timed out after " + timeoutMs + " ms");
|
|
}
|
|
|
|
/** The process-wide singleton (one bridge per app window). */
|
|
export const bridge = new OneCadBridge();
|
|
|
|
/** Family export: the app glue and dialogs always share this instance. */
|
|
export function exportForHost(): OneCadBridge {
|
|
return bridge;
|
|
}
|