From ac05e26934be1bb839e8e8bdf290ea5cbeb93d0a Mon Sep 17 00:00:00 2001 From: Z User Date: Sun, 18 Oct 2026 17:43:03 +0000 Subject: [PATCH] [FIX] OneCAD web: Odoo Documents integration sources (services_odoo.rs, odoo glue, build script); wasm32 picker freeze fixed with web_time::Instant --- Cargo.lock | 1 + apps/solidcraft-web/Cargo.toml | 4 + apps/solidcraft-web/src/files.rs | 6 + apps/solidcraft-web/src/odoo/bridge.ts | 947 ++++++++++++++++++++ apps/solidcraft-web/src/odoo/explorer.js | 719 +++++++++++++++ apps/solidcraft-web/src/odoo/index.ts | 153 ++++ apps/solidcraft-web/src/odoo/onecad-odoo.js | 389 ++++++++ apps/solidcraft-web/src/services_odoo.rs | 595 ++++++++++++ scripts/build_onecad_addon.py | 214 +++++ 9 files changed, 3028 insertions(+) create mode 100644 apps/solidcraft-web/src/odoo/bridge.ts create mode 100644 apps/solidcraft-web/src/odoo/explorer.js create mode 100644 apps/solidcraft-web/src/odoo/index.ts create mode 100644 apps/solidcraft-web/src/odoo/onecad-odoo.js create mode 100644 apps/solidcraft-web/src/services_odoo.rs create mode 100644 scripts/build_onecad_addon.py diff --git a/Cargo.lock b/Cargo.lock index f17283e1..f0698544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4278,6 +4278,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", ] [[package]] diff --git a/apps/solidcraft-web/Cargo.toml b/apps/solidcraft-web/Cargo.toml index 668fe086..311f79f0 100644 --- a/apps/solidcraft-web/Cargo.toml +++ b/apps/solidcraft-web/Cargo.toml @@ -24,6 +24,10 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" +# OneBSpace (OneCAD): std::time::Instant PANICS on wasm32-unknown-unknown +# ("time not implemented on this platform") — every picker deadline in +# services_odoo.rs must use web_time::Instant (performance.now() on wasm). +web-time = "1" serde_json = { workspace = true } web-sys = { version = "0.3", features = [ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager", diff --git a/apps/solidcraft-web/src/files.rs b/apps/solidcraft-web/src/files.rs index 126bede0..e026d857 100644 --- a/apps/solidcraft-web/src/files.rs +++ b/apps/solidcraft-web/src/files.rs @@ -295,6 +295,12 @@ pub fn start(files: &Rc) { /// Store what commands wrote: OPFS paths into OPFS, download paths as downloads. pub fn flush(files: &Rc) { for (path, bytes) in vfs::take_writes() { + // OneBSpace (OneCAD): in the Odoo shell, writes under /odoo/ go to + // the bound Documents record and /downloads/ exports go to the + // "Exports" folder (air-gapped: no device IO through the VFS). + if crate::services_odoo::handle_write(&path, &bytes) { + continue; + } if let Some(name) = path.strip_prefix(OPFS) { let name = name.to_string(); { diff --git a/apps/solidcraft-web/src/odoo/bridge.ts b/apps/solidcraft-web/src/odoo/bridge.ts new file mode 100644 index 00000000..fdfb3140 --- /dev/null +++ b/apps/solidcraft-web/src/odoo/bridge.ts @@ -0,0 +1,947 @@ +// OneBSpace (OneCAD) — odoo host bridge port +/** + * (C) OneBSpace — https://1bspace.com — Proprietary. + * OneCAD 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", kind: "odoo-documents", id, method, payload} + * posted on window.parent (same-origin Odoo shell iframe) + * reply : {source: "one-cad-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 side is built + * separately and consumes host responses through these): + * + * LISTEN "onecad: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.solidcraft (below). + * REQUEST QUEUE: entries are processed + * strictly FIFO, one at a time, and each + * id is answered exactly once. + * + * EMIT "onecad: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 "onecad: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 can read the same state sync at + * window.onecadHost.{ready,host,documentId,name}. + * + * EMIT "onecad: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.onecadHost.consumePendingOpen() + * (returns the detail once, then null). + * + * JS -> APP window.solidcraft.request("odoo.document.bytes") + * -> Promise — serialize + * the current workspace (used when a save/export carries no + * bytes). request("odoo.document.info") -> Promise<{name?}> — + * suggested document name; failures are ignored. + * + * 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 and host-request + * events are answered ok:false — the app still boots, nothing hangs. + * + * This file is erasable-syntax-only TypeScript with ZERO dependencies: the + * addon build (scripts/build_onecad_addon.py) compiles it to bridge.js next + * to this file (node stripTypeScriptTypes; tsc when available). + */ + +const DEFAULT_TIMEOUT_MS = 60_000; +const WRITE_TIMEOUT_MS = 300_000; // large .solidcraft containers 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"; +export const HOST_SOURCE_TAG = "one-cad-host"; +export const BRIDGE_KIND = "odoo-documents"; + +/** Window CustomEvent names of the Rust facade contract (see header). */ +export const EVT_HOST_REQUEST = "onecad:host-request"; +export const EVT_HOST_RESPONSE = "onecad:host-response"; +export const EVT_HOST_READY = "onecad:host-ready"; +export const EVT_DOCUMENT_OPENED = "onecad:document-opened"; + +/** window.solidcraft 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; + +export interface HostRequestDetail { + id: number; + method: string; + payload?: Record | null; + /** services_odoo.rs names the request object `params`; payload is the glue + * spelling. runRequest accepts either. */ + params?: Record | 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 "onecad: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:solidcraft:bind:"; +const OWNED_EXTENSION = ".solidcraft"; + +/** Rust services_odoo.rs ASSET_EXTS — pick_program offers these from Documents. */ +const RUST_ASSET_EXTS = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"]; + +/** Emulated pending paths must sanitize exactly like services_odoo.rs::sanitize. */ +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): 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 PendingTokenBinding { + recordId: number | null; + name: string; + folderId: number | null; +} + +interface SolidcraftApi { + request?: (method: string, params?: unknown) => Promise; +} + +interface PendingEntry { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface QueueEntry { + id: number; + method: string; + payload: Record; +} + +/* ------------------------------------------------------------------ */ +/* Codec + guard helpers (exported for the explorer and the app glue) */ +/* ------------------------------------------------------------------ */ + +/** Chunked btoa so multi-megabyte CAD containers 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 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; +} + +function stripOwnedExtension(name: string): string { + return (name || "").replace(/\.(solidcraft|solvecraft)$/i, ""); +} + +function detectInFrame(): boolean { + try { + return window.parent !== window; + } catch { + return false; + } +} + +function solidcraftApi(): SolidcraftApi | null { + const holder = window as unknown as { solidcraft?: SolidcraftApi }; + const api = holder.solidcraft; + 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 = 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 (services_odoo.rs writes to + * /odoo/pending//; the first write creates the record and + * every later Ctrl+S writes back in place). */ + pendingTokens = new Map(); + + constructor() { + this.inFrame = detectInFrame(); + 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; + // services_odoo.rs dispatches 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, + }); + }) 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(method: string, payload?: Record, options?: CallOptions): Promise { + if (!this.inFrame) { + this.standalone = true; + return Promise.reject( + new Error(`OneCAD 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((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`OneCAD 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 { + 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 odoo bridge: host did not answer ping within ${timeoutMs} ms (${messageOf(lastError)})`, + ); + } + + /* ---------------- raw host calls ---------------- */ + + ping(): Promise { + return this.call("ping"); + } + + list(): Promise { + return this.call("list"); + } + + stat(recordId: number): Promise { + return this.call("stat", { recordId }); + } + + read(recordId: number): Promise { + return this.call("read", { recordId }, { timeoutMs: WRITE_TIMEOUT_MS }); + } + + write(payload: { recordId?: number; name?: string; dataB64: string; folderId?: number }, timeoutMs?: number): Promise { + return this.call("write", payload, { timeoutMs: timeoutMs || WRITE_TIMEOUT_MS }); + } + + delete(recordId: number): Promise<{ deleted: boolean }> { + return this.call<{ deleted: boolean }>("delete", { recordId }); + } + + usage(): Promise { + return this.call("usage"); + } + + folders(): Promise { + return this.call("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 { + return this.call("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 { + 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 { + const result = await this.read(recordId); + this.bindSet(result.name, recordId); + return result; + } + + /** + * ?document= boot deep link. Never throws (boot must proceed); + * on success stores pendingOpen, fires "onecad:document-opened" and + * refreshes the binding so host-ready can carry the metadata. + */ + async openDeepLink(search?: string): Promise { + 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("[onecad-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(EVT_DOCUMENT_OPENED, { detail: this.pendingOpen }), + ); + return true; + } catch (error) { + console.warn("[onecad-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 { + if (!this.isHostAvailable()) { + throw new Error("OneCAD odoo bridge: standalone (no Odoo host) — explorer unavailable"); + } + if (!this.opener) { + throw new Error("OneCAD odoo bridge: no explorer dialog wired (setExplorerOpener)"); + } + return this.opener(options); + } + + private async appSerialize(): Promise { + const api = solidcraftApi(); + if (!api || !api.request) { + throw new Error( + "OneCAD odoo bridge: no document bytes given and window.solidcraft is unavailable", + ); + } + const out = await api.request(APP_SERIALIZE_METHOD); + const bytes = normalizeBytes(out); + if (!bytes) { + throw new Error("OneCAD odoo bridge: window.solidcraft did not return document bytes"); + } + return bytes; + } + + private async appInfo(): Promise<{ name?: string }> { + const api = solidcraftApi(); + 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 { + 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 (.solidcraft filter) -> read 300 s. */ + async openFromDocuments(): Promise { + const choice = await this.askExplorer({ mode: "open", extensions: ["solidcraft", "solvecraft"] }); + 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 { + 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") + ".step", + }); + 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 { + 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" }; + } + + /* ---------------- 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 { + 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(EVT_HOST_RESPONSE, { detail })); + } + + private async runRequest(method: string, payload: Record): Promise { + 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(method, payload, { timeoutMs }); + } + throw new Error("OneCAD odoo bridge: unknown host-request method \"" + method + "\""); + } + /* ------- services_odoo.rs wire protocol (CustomEvent round trip) ------- */ + /** odoo.pickOpen / odoo.pickProgram -> {recordId, name, bytes} | null. */ + private async pickForRust(method: string, payload: Record): Promise { + const requested = Array.isArray(payload.extensions) && payload.extensions.length + ? (payload.extensions as string[]) + : null; + const extensions = requested || (method === "odoo.pickProgram" ? RUST_ASSET_EXTS : ["solidcraft", "solvecraft"]); + 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): Promise { + 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// in place, /odoo/pending// first save. */ + private async rustWrite(payload: Record): Promise { + 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): Promise { + 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): Promise { + const bytes = bytesOf(payload); + if (!bytes) throw new Error("odoo.import: no bytes given"); + guardCap(bytes); + const app = (await this.ping()).app; + const result = await this.write({ name: payload.name || "asset", dataB64: bytesToBase64(bytes), folderId: app.assets || app.root }); + return { id: result.id, name: result.name }; + } +} + +/** 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; +} diff --git a/apps/solidcraft-web/src/odoo/explorer.js b/apps/solidcraft-web/src/odoo/explorer.js new file mode 100644 index 00000000..f1698619 --- /dev/null +++ b/apps/solidcraft-web/src/odoo/explorer.js @@ -0,0 +1,719 @@ +// OneBSpace (OneCAD) — odoo host bridge port +/** + * (C) OneBSpace — https://1bspace.com — Proprietary. + * OneCAD odoo/explorer.js — the unified Documents explorer dialogs. + * ---------------------------------------------------------------------- + * Vanilla-JS (ES module, zero deps) port of the family gold-standard + * explorer (one-photo src/odoo-host/explorer.js — same layout, same + * interactions, same last-folder memory), restyled with an injected dark + * CAD palette (no CSS imports — one