[FIX] OneCAD web: Odoo Documents integration sources (services_odoo.rs, odoo glue, build script); wasm32 picker freeze fixed with web_time::Instant
This commit is contained in:
1 parent
4e0601611c
commit
ac05e26934
9 files changed
+3028
No files matched your search
Generated
+1
@@ -4278,6 +4278,7 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -295,6 +295,12 @@ pub fn start(files: &Rc<Files>) {
|
||||
/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
|
||||
pub fn flush(files: &Rc<Files>) {
|
||||
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();
|
||||
{
|
||||
|
||||
@@ -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<Uint8Array | ArrayBuffer | {dataB64}> — 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<ExplorerChoice | null>;
|
||||
|
||||
export interface HostRequestDetail {
|
||||
id: number;
|
||||
method: string;
|
||||
payload?: Record<string, unknown> | null;
|
||||
/** services_odoo.rs names 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 "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<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 PendingTokenBinding {
|
||||
recordId: number | null;
|
||||
name: string;
|
||||
folderId: number | null;
|
||||
}
|
||||
|
||||
interface SolidcraftApi {
|
||||
request?: (method: string, params?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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 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<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 (services_odoo.rs 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>();
|
||||
|
||||
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<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 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 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 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; name?: string; dataB64: string; folderId?: number }, 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 "onecad: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("[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<DocumentOpenedDetail>(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<ExplorerChoice | null> {
|
||||
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<Uint8Array> {
|
||||
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<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 (.solidcraft filter) -> read 300 s. */
|
||||
async openFromDocuments(): Promise<OpenResult | null> {
|
||||
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<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") + ".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<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" };
|
||||
}
|
||||
|
||||
/* ---------------- 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 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<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 : ["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<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 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;
|
||||
}
|
||||
@@ -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 <style> tag) so it reads native over
|
||||
* the egui canvas. Four dialogs, one explorer:
|
||||
*
|
||||
* save — "Save to Documents": folder tree + name input (.solidcraft chip)
|
||||
* open — "Open from Documents": folder tree + files filtered to .solidcraft
|
||||
* asset — "Import asset": file picker -> Assets folder (+ retarget)
|
||||
* export — "Export": name input -> Exports folder
|
||||
*
|
||||
* left — My Drive folder tree (recursive, expandable, usage footer)
|
||||
* right — breadcrumb + folder/file rows (icon, name, size)
|
||||
* footer — Cancel left, primary action right, Esc / backdrop cancels
|
||||
*
|
||||
* Everything runs through the bridge (odoo/bridge.js — compiled from
|
||||
* bridge.ts): listFolder / createFolder / folders / usage. No framework.
|
||||
*
|
||||
* @returns per mode (resolve value, null = cancelled):
|
||||
* save -> {name, folderId}
|
||||
* open -> {recordId, recordName}
|
||||
* asset -> {file, folderId, name}
|
||||
* export -> {name, folderId}
|
||||
*/
|
||||
import { bridge } from "./bridge.js?v=20.0.1.0.6";
|
||||
|
||||
const STYLE_ID = "oca-explorer-style";
|
||||
|
||||
const MODES = {
|
||||
save: {
|
||||
heading: "Save to Documents",
|
||||
description: "Choose a folder in your Odoo drive for this .solidcraft document.",
|
||||
action: "Save",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:save",
|
||||
},
|
||||
open: {
|
||||
heading: "Open from Documents",
|
||||
description: "Browse your Odoo Documents drive and pick a .solidcraft document.",
|
||||
action: "Open",
|
||||
prefer: "root",
|
||||
lastKey: "one-cad:documents:last-folder:open",
|
||||
extensions: new Set(["solidcraft", "solvecraft"]),
|
||||
},
|
||||
asset: {
|
||||
heading: "Import asset",
|
||||
description: "Pick a CAD asset from your computer to copy into the Assets folder.",
|
||||
action: "Import",
|
||||
prefer: "assets",
|
||||
lastKey: "one-cad:documents:last-folder:asset",
|
||||
extensions: new Set([
|
||||
"step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf",
|
||||
"dxf", "svg", "gltf", "glb", "ply", "urdf",
|
||||
]),
|
||||
},
|
||||
export: {
|
||||
heading: "Export",
|
||||
description: "Name the export — it lands in your Documents Exports folder.",
|
||||
action: "Export",
|
||||
prefer: "exports",
|
||||
lastKey: "one-cad:documents:last-folder:export",
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small SVG icon set (lucide-style, stroke = currentColor) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function svg(paths, size) {
|
||||
return (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" ' +
|
||||
'style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex:none">' +
|
||||
paths + "</svg>"
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
folder: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>'),
|
||||
lock: svg('<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>', 11),
|
||||
chevronRight: svg('<path d="m9 18 6-6-6-6"/>', 12),
|
||||
hardDrive: svg('<line x1="22" x2="2" y1="12" y2="12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/><line x1="6" x2="6.01" y1="16" y2="16"/><line x1="10" x2="10.01" y1="16" y2="16"/>'),
|
||||
file: svg('<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>'),
|
||||
cube: svg('<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>'),
|
||||
save: svg('<path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 1-1 1H7"/>'),
|
||||
folderPlus: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="M12 10v6"/><path d="M9 13h6"/>'),
|
||||
upload: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>'),
|
||||
open: svg('<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/><path d="m2 21 5.1-5.1a2 2 0 0 1 2.8 0l1.1 1.1a2 2 0 0 0 2.8 0l4.2-4.2a2 2 0 0 1 2.8 0L22 14"/>'),
|
||||
download: svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>'),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Injected dark CAD styles (the only CSS this module ever adds) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const STYLE_CSS = [
|
||||
":root{--oca-bg:#1b1e24;--oca-panel:#23272f;--oca-panel2:#2a2f39;--oca-hairline:#333945;",
|
||||
"--oca-text:#e8eaed;--oca-dim:#9aa3ad;--oca-accent:#4f8cff;--oca-accent-press:#3f76e0;",
|
||||
"--oca-danger:#e5534b;--oca-ok:#3fb26f}",
|
||||
".oca-explorer-root{position:fixed;inset:0;z-index:10000;display:flex;align-items:center;justify-content:center;",
|
||||
"font:13px/1.45 'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--oca-text)}",
|
||||
".oca-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||
".oca-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||
"background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:10px;",
|
||||
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||
".oca-header{padding:14px 18px 10px;border-bottom:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-title{font-size:15px;font-weight:600}",
|
||||
".oca-desc{margin-top:2px;color:var(--oca-dim);font-size:12px}",
|
||||
".oca-desc-error{color:var(--oca-danger)}",
|
||||
".oca-body{flex:1;display:flex;min-height:0}",
|
||||
".oca-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||
".oca-tree-label{margin:2px 8px 6px;color:var(--oca-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||
".oca-tree-error{padding:8px}",
|
||||
".oca-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||
".oca-tree-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-tree-row.oca-active{background:var(--oca-panel2);color:#fff}",
|
||||
".oca-tree-toggle{width:14px;height:14px;flex:none;color:var(--oca-dim);display:inline-flex;transition:transform .12s}",
|
||||
".oca-tree-toggle.oca-open{transform:rotate(90deg)}",
|
||||
".oca-tree-icon{display:inline-flex;color:var(--oca-accent)}",
|
||||
".oca-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||
".oca-usage{padding:8px 14px;border-top:1px solid var(--oca-hairline);color:var(--oca-dim);font-size:11px}",
|
||||
".oca-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||
".oca-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--oca-hairline);overflow:hidden}",
|
||||
".oca-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--oca-dim);",
|
||||
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||
".oca-crumb:hover:not(:disabled){background:var(--oca-panel2);color:var(--oca-text)}",
|
||||
".oca-crumb:disabled{opacity:.45;cursor:default}",
|
||||
".oca-crumb-current{color:var(--oca-text)}",
|
||||
".oca-crumb-sep{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||
".oca-file-row{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:0;",
|
||||
"color:inherit;font:inherit;padding:7px 9px;border-radius:6px;cursor:pointer}",
|
||||
".oca-file-row:hover{background:var(--oca-panel2)}",
|
||||
".oca-file-row.oca-selected{background:rgba(79,140,255,.18);outline:1px solid var(--oca-accent)}",
|
||||
".oca-file-icon{display:inline-flex;color:var(--oca-dim)}",
|
||||
".oca-file-icon.oca-icon-owned{color:var(--oca-accent)}",
|
||||
".oca-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-file-size{color:var(--oca-dim);font-size:11px;flex:none}",
|
||||
".oca-empty{color:var(--oca-dim);padding:18px 10px;text-align:center}",
|
||||
".oca-savebar{border-top:1px solid var(--oca-hairline);padding:10px 14px;background:var(--oca-panel)}",
|
||||
".oca-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||
".oca-name-wrap{flex:1;min-width:0}",
|
||||
".oca-name-label{display:block;color:var(--oca-dim);font-size:11px;margin-bottom:4px}",
|
||||
".oca-name-field{display:flex;align-items:center;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;padding:0 8px}",
|
||||
".oca-name-field:focus-within{border-color:var(--oca-accent)}",
|
||||
".oca-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--oca-text);font:inherit;padding:7px 0}",
|
||||
".oca-suffix{color:var(--oca-dim);font-size:12px;user-select:none}",
|
||||
".oca-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||
".oca-newfolder-input{flex:1;background:var(--oca-bg);border:1px solid var(--oca-hairline);border-radius:6px;",
|
||||
"color:var(--oca-text);font:inherit;padding:6px 9px;outline:0}",
|
||||
".oca-newfolder-input:focus{border-color:var(--oca-accent)}",
|
||||
".oca-picker{display:flex;flex-direction:column;gap:8px}",
|
||||
".oca-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||
".oca-picked{color:var(--oca-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||
".oca-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||
"border-top:1px solid var(--oca-hairline);background:var(--oca-panel)}",
|
||||
".oca-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||
"border:1px solid var(--oca-hairline);background:var(--oca-panel2);color:var(--oca-text);cursor:pointer}",
|
||||
".oca-btn:hover:not(:disabled){border-color:var(--oca-dim)}",
|
||||
".oca-btn:disabled{opacity:.45;cursor:default}",
|
||||
".oca-btn-accent{background:var(--oca-accent);border-color:var(--oca-accent);color:#fff}",
|
||||
".oca-btn-accent:hover:not(:disabled){background:var(--oca-accent-press)}",
|
||||
".oca-btn-ghost{background:none;border-color:transparent;color:var(--oca-dim)}",
|
||||
".oca-btn-ghost:hover:not(:disabled){color:var(--oca-text)}",
|
||||
".oca-btn-icon{display:inline-flex}",
|
||||
].join("\n");
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = STYLE_CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function h(tag, attrs) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const key in attrs) {
|
||||
if (!Object.prototype.hasOwnProperty.call(attrs, key)) continue;
|
||||
const value = attrs[key];
|
||||
if (key === "class") el.className = value;
|
||||
else if (key === "html") el.innerHTML = value;
|
||||
else if (key === "text") el.textContent = value;
|
||||
else if (key.slice(0, 2) === "on" && typeof value === "function")
|
||||
el.addEventListener(key.slice(2), value);
|
||||
else if (value !== null && value !== undefined) el.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
for (let i = 2; i < arguments.length; i++) {
|
||||
const child = arguments[i];
|
||||
if (child === null || child === undefined) continue;
|
||||
el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function formatSize(size) {
|
||||
size = Number(size) || 0;
|
||||
if (size < 1024) return size + " B";
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + " KB";
|
||||
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (size / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
}
|
||||
|
||||
function extensionOf(name) {
|
||||
const dot = name.lastIndexOf(".");
|
||||
return dot <= 0 ? "" : name.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function fileIconFor(file, extensions) {
|
||||
const ext = extensionOf(file.name || "");
|
||||
if (extensions && extensions.has(ext)) {
|
||||
return '<span class="oca-file-icon oca-icon-owned">' + ICONS.cube + "</span>";
|
||||
}
|
||||
return '<span class="oca-file-icon">' + ICONS.file + "</span>";
|
||||
}
|
||||
|
||||
function rememberLastFolder(folderId, key) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(folderId));
|
||||
} catch (e) {
|
||||
/* private mode — memory only */
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLastFolder(key) {
|
||||
try {
|
||||
const id = Number(window.localStorage.getItem(key));
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* The explorer */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Open one Documents explorer dialog.
|
||||
*
|
||||
* @param {object} opts
|
||||
* mode "save" | "open" | "asset" | "export"
|
||||
* defaultName save/export mode: suggested file name ("part.solidcraft")
|
||||
* extensions open/asset mode: restrict file rows to these extensions
|
||||
* @returns {Promise<{name?, folderId?, recordId?, recordName?, file?} | null>}
|
||||
* null = cancelled (Esc, backdrop, Cancel button).
|
||||
*/
|
||||
export function openDocumentsExplorer(opts) {
|
||||
opts = opts || {};
|
||||
const mode = MODES[opts.mode] ? opts.mode : "save";
|
||||
const config = MODES[mode];
|
||||
const extensions = opts.extensions && opts.extensions.length
|
||||
? new Set(opts.extensions.map((ext) => String(ext).toLowerCase().replace(/^\./, "")))
|
||||
: config.extensions || null;
|
||||
|
||||
/* Standalone dev mode: no host, no dialog — the caller treats null as
|
||||
* "nothing chosen", so the app keeps running untouched. */
|
||||
if (typeof bridge.isHostAvailable === "function" && !bridge.isHostAvailable()) {
|
||||
console.info("[onecad-odoo] explorer: no Odoo host (standalone) — dialog unavailable");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
ensureStyle();
|
||||
|
||||
/* Save/export name split: stem in the field, extension as the chip. */
|
||||
const wantsName = mode === "save" || mode === "export";
|
||||
const defaultExtension = mode === "save" ? ".solidcraft" : "";
|
||||
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||
? opts.defaultName.trim()
|
||||
: mode === "save"
|
||||
? "untitled.solidcraft"
|
||||
: "export";
|
||||
let stem = defaultName;
|
||||
let suffix = "";
|
||||
const dotIdx = defaultName.lastIndexOf(".");
|
||||
if (dotIdx > 0) {
|
||||
stem = defaultName.slice(0, dotIdx);
|
||||
suffix = defaultName.slice(dotIdx);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener("keydown", onDocKeyDown, true);
|
||||
if (root && root.parentNode) root.parentNode.removeChild(root);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
let tree = null;
|
||||
let treeError = null;
|
||||
let appTree = {};
|
||||
let appRootId = null;
|
||||
let currentFolderId = null;
|
||||
let folderFolders = [];
|
||||
let folderFiles = [];
|
||||
let loadingFiles = false;
|
||||
let selectedFileId = null;
|
||||
let pickedFile = null;
|
||||
let createBusy = false;
|
||||
let usage = null;
|
||||
const expandedSet = new Set();
|
||||
|
||||
/* ---------------- chrome ---------------- */
|
||||
const backdrop = h("div", { class: "oca-backdrop" });
|
||||
const dialog = h("div", { class: "oca-dialog", role: "dialog", "aria-modal": "true" });
|
||||
const root = h("div", { class: "oca-explorer-root" }, backdrop, dialog);
|
||||
|
||||
const descEl = h("div", { class: "oca-desc" }, config.description);
|
||||
const header = h("div", { class: "oca-header" },
|
||||
h("div", { class: "oca-title" }, config.heading), descEl);
|
||||
|
||||
const treeLabel = h("p", { class: "oca-tree-label" }, "Loading drive…");
|
||||
const treeBox = h("div", { class: "oca-tree-box" }, treeLabel);
|
||||
const usageEl = h("div", { class: "oca-usage", style: "display:none" });
|
||||
const aside = h("div", { class: "oca-aside" }, treeBox, usageEl);
|
||||
|
||||
const crumbs = h("div", { class: "oca-crumbs" });
|
||||
const fileList = h("div", { class: "oca-files" });
|
||||
const main = h("div", { class: "oca-main" }, crumbs, fileList);
|
||||
const body = h("div", { class: "oca-body" }, aside, main);
|
||||
|
||||
/* name row (save + export) */
|
||||
const nameInput = h("input", {
|
||||
class: "oca-name-input",
|
||||
id: "onecad-explorer-name",
|
||||
type: "text",
|
||||
value: stem,
|
||||
placeholder: mode === "save" ? "Untitled" : "export",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const suffixChip = h("span", { class: "oca-suffix" }, suffix);
|
||||
const nameField = h("div", { class: "oca-name-field" }, nameInput, suffixChip);
|
||||
const nameRow = h("div", { class: "oca-name-row" },
|
||||
h("div", { class: "oca-name-wrap" },
|
||||
h("label", { class: "oca-name-label", for: "onecad-explorer-name" }, "File name"),
|
||||
nameField));
|
||||
|
||||
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||
const newNameInput = h("input", {
|
||||
class: "oca-newfolder-input",
|
||||
type: "text",
|
||||
placeholder: "Folder name…",
|
||||
spellcheck: "false",
|
||||
});
|
||||
const createBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" }, "Create");
|
||||
const cancelCreateBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const createRow = h("div", { class: "oca-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||
const newFolderBtn = h("button", { class: "oca-btn", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.folderPlus }), "New folder");
|
||||
|
||||
/* asset file picker */
|
||||
const fileInput = h("input", {
|
||||
type: "file",
|
||||
style: "display:none",
|
||||
...(extensions ? { accept: Array.from(extensions).map((ext) => "." + ext).join(",") } : {}),
|
||||
});
|
||||
const pickedLabel = h("div", { class: "oca-picked", text: "No file chosen yet." });
|
||||
const pickBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||
const picker = h("div", { class: "oca-picker" }, pickBtn, pickedLabel, fileInput);
|
||||
|
||||
const savebar = h("div", { class: "oca-savebar" });
|
||||
if (wantsName) savebar.appendChild(nameRow);
|
||||
if (mode === "asset") savebar.appendChild(picker);
|
||||
savebar.appendChild(newFolderBtn);
|
||||
savebar.appendChild(createRow);
|
||||
|
||||
/* footer */
|
||||
const cancelBtn = h("button", { class: "oca-btn oca-btn-ghost", type: "button" }, "Cancel");
|
||||
const actionIcon = mode === "save" ? ICONS.save : mode === "open" ? ICONS.open : mode === "asset" ? ICONS.upload : ICONS.download;
|
||||
const actionBtn = h("button", { class: "oca-btn oca-btn-accent", type: "button" },
|
||||
h("span", { class: "oca-btn-icon", html: actionIcon }), config.action);
|
||||
const footer = h("div", { class: "oca-footer" }, cancelBtn, actionBtn);
|
||||
|
||||
dialog.appendChild(header);
|
||||
dialog.appendChild(body);
|
||||
if (mode !== "open") dialog.appendChild(savebar);
|
||||
dialog.appendChild(footer);
|
||||
|
||||
const redraw = () => {
|
||||
if (mode === "save") {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
} else if (mode === "open") {
|
||||
actionBtn.disabled = selectedFileId === null;
|
||||
} else if (mode === "asset") {
|
||||
actionBtn.disabled = !pickedFile || currentFolderId === null;
|
||||
} else {
|
||||
actionBtn.disabled = !nameInput.value.trim() || currentFolderId === null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------------- tree rendering ---------------- */
|
||||
function renderTree() {
|
||||
treeBox.innerHTML = "";
|
||||
if (tree === null && !treeError) {
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "Loading drive…"));
|
||||
return;
|
||||
}
|
||||
if (treeError) {
|
||||
const retry = h("button", { class: "oca-btn", type: "button" }, "Retry");
|
||||
retry.addEventListener("click", () => boot());
|
||||
treeBox.appendChild(h("div", { class: "oca-tree-error" },
|
||||
h("p", { class: "oca-tree-label" }, treeError), retry));
|
||||
return;
|
||||
}
|
||||
treeBox.appendChild(h("p", { class: "oca-tree-label" }, "My Drive"));
|
||||
const childrenOf = new Map();
|
||||
for (const folder of tree) {
|
||||
const key = folder.parentId === null ? "root" : String(folder.parentId);
|
||||
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
||||
childrenOf.get(key).push(folder);
|
||||
}
|
||||
const renderNodes = (parentId, depth) => {
|
||||
const key = parentId === null ? "root" : String(parentId);
|
||||
const children = childrenOf.get(key) || [];
|
||||
for (const folder of children) {
|
||||
const hasChildren = (childrenOf.get(String(folder.id)) || []).length > 0;
|
||||
const isOpen = expandedSet.has(folder.id);
|
||||
const row = h("div", {
|
||||
class: "oca-tree-row" + (folder.id === currentFolderId ? " oca-active" : ""),
|
||||
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||
});
|
||||
const toggle = h("span", { class: "oca-tree-toggle" });
|
||||
if (hasChildren) {
|
||||
toggle.innerHTML = ICONS.chevronRight;
|
||||
toggle.classList.add("oca-has-children");
|
||||
if (isOpen) toggle.classList.add("oca-open");
|
||||
}
|
||||
toggle.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (expandedSet.has(folder.id)) expandedSet.delete(folder.id);
|
||||
else expandedSet.add(folder.id);
|
||||
renderTree();
|
||||
});
|
||||
row.appendChild(toggle);
|
||||
row.appendChild(h("span", { class: "oca-tree-icon", html: ICONS.folder }));
|
||||
row.appendChild(h("span", { class: "oca-tree-name" }, folder.name));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
treeBox.appendChild(row);
|
||||
if (hasChildren && isOpen) renderNodes(folder.id, depth + 1);
|
||||
}
|
||||
};
|
||||
renderNodes(null, 0);
|
||||
usageEl.style.display = usage ? "" : "none";
|
||||
if (usage) {
|
||||
usageEl.textContent = usage.documentCount + " file" + (usage.documentCount === 1 ? "" : "s") +
|
||||
" · " + formatSize(usage.bytesUsed) + " used";
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- breadcrumb ---------------- */
|
||||
function renderCrumbs() {
|
||||
crumbs.innerHTML = "";
|
||||
const driveBtn = h("button", { class: "oca-crumb oca-crumb-root", type: "button" });
|
||||
driveBtn.appendChild(h("span", { class: "oca-btn-icon", html: ICONS.hardDrive }));
|
||||
driveBtn.appendChild(document.createTextNode("My Drive"));
|
||||
driveBtn.disabled = appRootId === null;
|
||||
driveBtn.addEventListener("click", () => appRootId !== null && enterFolder(appRootId));
|
||||
crumbs.appendChild(driveBtn);
|
||||
const path = [];
|
||||
let cursor = currentFolderId;
|
||||
const byId = new Map(tree ? tree.map((row) => [row.id, row]) : []);
|
||||
while (cursor !== null && byId.has(cursor)) {
|
||||
path.unshift(byId.get(cursor));
|
||||
cursor = byId.get(cursor).parentId;
|
||||
}
|
||||
for (const folder of path) {
|
||||
crumbs.appendChild(h("span", { class: "oca-crumb-sep", html: ICONS.chevronRight }));
|
||||
const crumb = h("button", {
|
||||
class: "oca-crumb" + (folder.id === currentFolderId ? " oca-crumb-current" : ""),
|
||||
type: "button",
|
||||
}, folder.name);
|
||||
crumb.addEventListener("click", () => enterFolder(folder.id));
|
||||
crumbs.appendChild(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- files ---------------- */
|
||||
function renderFiles() {
|
||||
fileList.innerHTML = "";
|
||||
if (loadingFiles) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
/* Desktop file-manager behavior: subfolders first (click = enter),
|
||||
* then files — open mode filtered to the requested extensions. */
|
||||
for (const folder of folderFolders) {
|
||||
const row = h("button", { class: "oca-file-row", type: "button" });
|
||||
row.insertAdjacentHTML("beforeend", '<span class="oca-file-icon oca-icon-owned">' + ICONS.folder + "</span>");
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, folder.name));
|
||||
if (folder.system) {
|
||||
row.insertAdjacentHTML("beforeend",
|
||||
'<span class="oca-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||
}
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, "Folder"));
|
||||
row.addEventListener("click", () => enterFolder(folder.id));
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
const visibleFiles = extensions
|
||||
? folderFiles.filter((file) => extensions.has(extensionOf(file.name || "")))
|
||||
: folderFiles;
|
||||
if (folderFolders.length === 0 && visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" },
|
||||
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible files in this folder."));
|
||||
return;
|
||||
}
|
||||
if (visibleFiles.length === 0) {
|
||||
fileList.appendChild(h("p", { class: "oca-empty" }, "No compatible files here yet."));
|
||||
return;
|
||||
}
|
||||
for (const file of visibleFiles) {
|
||||
const row = h("button", {
|
||||
class: "oca-file-row" + (selectedFileId === file.id ? " oca-selected" : ""),
|
||||
type: "button",
|
||||
});
|
||||
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||
row.appendChild(h("span", { class: "oca-file-name" }, file.name));
|
||||
row.appendChild(h("span", { class: "oca-file-size" }, formatSize(file.size || 0)));
|
||||
row.addEventListener("click", () => {
|
||||
selectedFileId = file.id;
|
||||
renderFiles();
|
||||
redraw();
|
||||
});
|
||||
row.addEventListener("dblclick", () => {
|
||||
if (mode === "open") settle({ recordId: file.id, recordName: file.name });
|
||||
});
|
||||
fileList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- actions ---------------- */
|
||||
function enterFolder(folderId) {
|
||||
currentFolderId = folderId;
|
||||
selectedFileId = null;
|
||||
rememberLastFolder(folderId, config.lastKey);
|
||||
loadFiles(folderId);
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
redraw();
|
||||
}
|
||||
|
||||
function loadFiles(folderId) {
|
||||
loadingFiles = true;
|
||||
renderFiles();
|
||||
bridge
|
||||
.listFolder(folderId)
|
||||
.then((listing) => {
|
||||
folderFiles = listing.files || [];
|
||||
folderFolders = listing.folders || [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
})
|
||||
.catch((error) => {
|
||||
folderFiles = [];
|
||||
folderFolders = [];
|
||||
loadingFiles = false;
|
||||
renderFiles();
|
||||
flashError("Could not list this folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
});
|
||||
}
|
||||
|
||||
function onCreateFolder() {
|
||||
const trimmed = newNameInput.value.trim();
|
||||
if (!trimmed || currentFolderId === null || createBusy) return;
|
||||
createBusy = true;
|
||||
createBtn.disabled = true;
|
||||
bridge
|
||||
.createFolder({ name: trimmed, parentId: currentFolderId })
|
||||
.then((created) => {
|
||||
tree = (tree || []).concat([{ id: created.id, name: trimmed, parentId: currentFolderId }]);
|
||||
expandedSet.add(currentFolderId);
|
||||
expandedSet.add(created.id);
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
enterFolder(created.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
flashError("Could not create folder — " + (error && error.message ? error.message : "unknown error"));
|
||||
})
|
||||
.finally(() => {
|
||||
createBusy = false;
|
||||
createBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
if (mode === "save") {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
} else if (mode === "open") {
|
||||
const file = folderFiles.find((candidate) => candidate.id === selectedFileId);
|
||||
if (file) settle({ recordId: file.id, recordName: file.name });
|
||||
} else if (mode === "asset") {
|
||||
if (pickedFile && currentFolderId !== null) {
|
||||
settle({ file: pickedFile, folderId: currentFolderId, name: pickedFile.name });
|
||||
}
|
||||
} else {
|
||||
const name = nameInput.value.trim();
|
||||
if (!name || currentFolderId === null) return;
|
||||
settle({ name, folderId: currentFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
pickBtn.addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", () => {
|
||||
pickedFile = fileInput.files && fileInput.files[0] ? fileInput.files[0] : null;
|
||||
pickedLabel.textContent = pickedFile
|
||||
? pickedFile.name + " · " + formatSize(pickedFile.size)
|
||||
: "No file chosen yet.";
|
||||
redraw();
|
||||
});
|
||||
|
||||
newFolderBtn.addEventListener("click", () => {
|
||||
newFolderBtn.style.display = "none";
|
||||
createRow.style.display = "";
|
||||
newNameInput.focus();
|
||||
});
|
||||
cancelCreateBtn.addEventListener("click", () => {
|
||||
newNameInput.value = "";
|
||||
createRow.style.display = "none";
|
||||
newFolderBtn.style.display = "";
|
||||
});
|
||||
createBtn.addEventListener("click", onCreateFolder);
|
||||
newNameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") onCreateFolder();
|
||||
});
|
||||
nameInput.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") confirmAction();
|
||||
});
|
||||
nameInput.addEventListener("input", redraw);
|
||||
cancelBtn.addEventListener("click", () => settle(null));
|
||||
actionBtn.addEventListener("click", confirmAction);
|
||||
backdrop.addEventListener("mousedown", () => settle(null));
|
||||
dialog.addEventListener("mousedown", (ev) => ev.stopPropagation());
|
||||
|
||||
function onDocKeyDown(ev) {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation();
|
||||
settle(null);
|
||||
}
|
||||
document.addEventListener("keydown", onDocKeyDown, true);
|
||||
|
||||
let errorTimer = null;
|
||||
function flashError(message) {
|
||||
descEl.textContent = message;
|
||||
descEl.classList.add("oca-desc-error");
|
||||
if (errorTimer) clearTimeout(errorTimer);
|
||||
errorTimer = setTimeout(() => {
|
||||
descEl.textContent = config.description;
|
||||
descEl.classList.remove("oca-desc-error");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
function boot() {
|
||||
tree = null;
|
||||
treeError = null;
|
||||
renderTree();
|
||||
void (async () => {
|
||||
try {
|
||||
const folders = await bridge.folders();
|
||||
tree = folders.tree || [];
|
||||
appTree = folders.app || {};
|
||||
appRootId = typeof appTree.root === "number" ? appTree.root : null;
|
||||
const preferred = typeof appTree[config.prefer] === "number"
|
||||
? appTree[config.prefer]
|
||||
: appTree.root;
|
||||
const restored = restoreLastFolder(config.lastKey);
|
||||
const known = new Set(tree.map((row) => row.id));
|
||||
const target = restored && known.has(restored) ? restored : preferred;
|
||||
currentFolderId = target;
|
||||
const byId = new Map(tree.map((row) => [row.id, row]));
|
||||
let cursor = target;
|
||||
while (cursor !== null) {
|
||||
expandedSet.add(cursor);
|
||||
cursor = byId.has(cursor) ? byId.get(cursor).parentId : null;
|
||||
}
|
||||
renderTree();
|
||||
renderCrumbs();
|
||||
loadFiles(target);
|
||||
bridge
|
||||
.usage()
|
||||
.then((info) => {
|
||||
usage = info;
|
||||
renderTree();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
} catch (error) {
|
||||
treeError = error && error.message ? error.message : "Could not load the drive";
|
||||
renderTree();
|
||||
}
|
||||
redraw();
|
||||
if (wantsName) {
|
||||
window.setTimeout(() => {
|
||||
nameInput.focus();
|
||||
nameInput.select();
|
||||
}, 50);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
document.body.appendChild(root);
|
||||
boot();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// OneBSpace (OneCAD) — odoo host bridge port
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — Proprietary.
|
||||
* OneCAD odoo/index.ts — the boot glue (bridge <-> explorer <-> Rust app).
|
||||
* ----------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside the wasm_app_framework
|
||||
* iframe (standalone runs never import it — see the import guard there).
|
||||
* Responsibilities:
|
||||
*
|
||||
* 1. instantiate the bridge singleton and wire the explorer dialogs
|
||||
* ("saveAs" / "open" / "export" / "importAsset" host-request methods);
|
||||
* 2. BOOT GATE: waitForHost() (ping every 100 ms, up to 20 s) — then, if
|
||||
* location.search carries ?document=<id>, read that record BEFORE the
|
||||
* app is signaled ready (deep-link open, "onecad:document-opened");
|
||||
* 3. signal "onecad:host-ready" {host, documentId, name} exactly once.
|
||||
* The same state stays readable synchronously at window.onecadHost
|
||||
* (late-booting Rust) together with consumePendingOpen(), which hands
|
||||
* the deep-link payload {documentId, name, dataB64} over once.
|
||||
*
|
||||
* Standalone dev mode: no host frame — no waiting, no deep link, the ready
|
||||
* signal still fires with host:false so the app boots untouched.
|
||||
*
|
||||
* Build note: scripts/build_onecad_addon.py compiles this file (erasable-
|
||||
* syntax-only TS) to odoo/index.js next to bridge.js; explorer.js is a
|
||||
* plain-JS module copied verbatim. Exports nothing.
|
||||
*/
|
||||
import { exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.6";
|
||||
// @ts-ignore — explorer.js is a sibling plain-JS ES module (copied verbatim
|
||||
// into dist; it imports the compiled bridge.js at runtime).
|
||||
import { openDocumentsExplorer } from "./explorer.js?v=20.0.1.0.6";
|
||||
import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js";
|
||||
|
||||
const bridge = exportForHost();
|
||||
bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener);
|
||||
|
||||
/* Late-boot contract surface (Rust registers listeners after this module
|
||||
* has run, so it polls this synchronously-readable state on init). */
|
||||
interface OneCadHostWindow {
|
||||
onecadHostReady?: boolean;
|
||||
onecadHost?: {
|
||||
ready: boolean;
|
||||
host: boolean;
|
||||
documentId: number | null;
|
||||
name: string | null;
|
||||
consumePendingOpen: () => DocumentOpenedDetail | null;
|
||||
};
|
||||
solidcraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
|
||||
onecadOdoo?: {
|
||||
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const hostWindow = window as unknown as OneCadHostWindow;
|
||||
hostWindow.onecadHost = {
|
||||
ready: false,
|
||||
host: false,
|
||||
documentId: null,
|
||||
name: null,
|
||||
consumePendingOpen: () => bridge.consumePendingOpen(),
|
||||
};
|
||||
|
||||
/* services_odoo.rs polls window.onecadHostReady (a plain boolean) before
|
||||
* dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue.
|
||||
* Set it as soon as the boot attempt finishes (host answered or not): every
|
||||
* Rust picker parks its slot behind this flag, so an unset flag wedges the
|
||||
* app's file dialogs for REQUEST_TIMEOUT (120 s) with no dialog on screen. */
|
||||
function markHostReady(): void {
|
||||
try {
|
||||
hostWindow.onecadHostReady = true;
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] could not set onecadHostReady:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* Deep-link opener: stage the record bytes into the engine VFS via the
|
||||
* window.onecadOdoo.bootFile seam (installed by services_odoo.rs), then ask
|
||||
* the app to open the staged path through its control API. The wasm app API
|
||||
* appears asynchronously after boot, so poll briefly for it. */
|
||||
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
|
||||
if (!entry || typeof entry.dataB64 !== "string") return;
|
||||
const deadline = Date.now() + 60000;
|
||||
while (Date.now() < deadline) {
|
||||
const api = hostWindow.solidcraft;
|
||||
const seam = hostWindow.onecadOdoo;
|
||||
if (api && typeof api.request === "function" && seam && typeof seam.bootFile === "function") {
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
|
||||
const path = seam.bootFile(entry.documentId, entry.name, bytes);
|
||||
if (typeof path === "string" && path) {
|
||||
await api.request("ui.file", { path });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecad-odoo] deep-link open failed:",
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
console.warn("[onecad-odoo] deep-link open skipped: the app API never appeared");
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
let host = false;
|
||||
if (bridge.inFrame) {
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
host = true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[onecad-odoo] host did not answer in time — continuing without Odoo Documents:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.info("[onecad-odoo] standalone (no host frame) — Odoo Documents disabled");
|
||||
}
|
||||
|
||||
/* Unblock the Rust pickers first; the deep-link read continues in the
|
||||
* background and must never keep the file dialogs hostage. */
|
||||
markHostReady();
|
||||
let deepLink = false;
|
||||
if (host) {
|
||||
try {
|
||||
deepLink = await bridge.openDeepLink();
|
||||
} catch (error) {
|
||||
console.warn("[onecad-odoo] deep-link read failed:", error);
|
||||
}
|
||||
}
|
||||
const binding = bridge.getCurrentBinding();
|
||||
|
||||
const detail: HostReadyDetail = {
|
||||
host,
|
||||
documentId: binding.documentId,
|
||||
name: binding.name,
|
||||
};
|
||||
const state = hostWindow.onecadHost;
|
||||
if (state) {
|
||||
state.ready = true;
|
||||
state.host = host;
|
||||
state.documentId = detail.documentId;
|
||||
state.name = detail.name;
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<HostReadyDetail>(EVT_HOST_READY, { detail }));
|
||||
if (deepLink) {
|
||||
void openStagedDeepLink(bridge.consumePendingOpen());
|
||||
}
|
||||
})();
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||
* OneBSpace (OneCAD): odoo/onecad-odoo.js — the app-side host glue.
|
||||
* ---------------------------------------------------------------------
|
||||
* Entry module loaded by index.html ONLY inside a frame (standalone runs
|
||||
* never import it). It is the middle layer between the Rust app
|
||||
* (apps/solidcraft-web/src/services_odoo.rs, which installs
|
||||
* `window.onecadOdoo` — the resolve/bootFile glue) and the wasm_app_
|
||||
* framework Documents bridge (odoo/bridge.ts compiled to bridge.js):
|
||||
*
|
||||
* request(id, kind, payloadJson) Rust picker → explorer → resolve
|
||||
* kind "open" → Documents explorer → bridge.read → Rust inserts
|
||||
* bytes at /odoo/doc/<recordId>/<name>
|
||||
* kind "save" → Documents explorer (Save) → token → Rust returns
|
||||
* /odoo/pending/<token>/<name>
|
||||
* kind "program" → asset explorer (Assets folder) → like "open"
|
||||
* write(path, bytes) VFS drain → bridge write / saveExport
|
||||
* /odoo/doc/<recordId>/<name> → bridge.write({recordId}) in place
|
||||
* /odoo/pending/<token>/<name> → token meta (localStorage) → first
|
||||
* write creates the record, later
|
||||
* writes hit the same record (Ctrl+S)
|
||||
* /downloads/<name> → bridge.saveExport into "Exports"
|
||||
* import(name, bytes) computer-imported CAD assets mirrored
|
||||
* into the "Assets" folder (sig-deduped)
|
||||
*
|
||||
* Path schemes on the emulated filesystem (the one-photo conventions):
|
||||
* /odoo/doc/<recordId>/<name> an existing Documents record
|
||||
* /odoo/pending/<token>/<name> a chosen destination not yet written
|
||||
* /downloads/<name> an export → Documents "Exports"
|
||||
*
|
||||
* Deep-link boot gate: the shell's iframe URL carries ?document=<id> when
|
||||
* the app was opened by clicking an owned file in Documents. The record is
|
||||
* read, handed to Rust (bootFile inserts it into the engine's VFS at
|
||||
* /odoo/doc/...) and opened like any opened file (doc.open / inserts).
|
||||
*/
|
||||
import {
|
||||
bridge,
|
||||
base64ToBytes,
|
||||
bytesToBase64,
|
||||
guardCap,
|
||||
extensionOf,
|
||||
sanitizeStem,
|
||||
withExtension,
|
||||
} from "./bridge.js";
|
||||
import { openDocumentsExplorer } from "./explorer.js";
|
||||
|
||||
/* localStorage stores (the binding cache; Documents stays the truth) */
|
||||
const PENDING_KEY = "one-cad:odoo:pending-tokens";
|
||||
const IMPORT_SIGS_KEY = "one-cad:odoo:import-sigs";
|
||||
const SAVE_FOLDER_KEY = "one-cad:documents:last-folder:save";
|
||||
const EXPORT_FOLDER_KEY = "one-cad:documents:last-folder:export";
|
||||
const OPEN_FOLDER_KEY = "one-cad:documents:last-folder:open";
|
||||
|
||||
/* Commands the app understands on an emulated path (io crate naming). */
|
||||
const DESIGN_EXTENSIONS = new Set(["solidcraft", "solvecraft", "json"]);
|
||||
const STEP_EXTENSIONS = new Set(["step", "stp", "iges", "igs", "brep"]);
|
||||
const MESH_EXTENSIONS = new Set(["stl", "obj", "3mf", "gltf", "glb", "ply"]);
|
||||
const ASSET_EXTENSIONS = new Set([
|
||||
...STEP_EXTENSIONS,
|
||||
...MESH_EXTENSIONS,
|
||||
"dxf", "svg", "urdf",
|
||||
]);
|
||||
|
||||
function embedded() {
|
||||
try {
|
||||
if (typeof window === "undefined" || !window.parent || window.parent === window) return false;
|
||||
// Same-origin shell under the Odoo web client (family env.ts rule).
|
||||
const path = window.parent.location.pathname;
|
||||
return path.startsWith("/odoo") || path.startsWith("/web") || /[\?&]odoo-host=1/.test(window.location.search);
|
||||
} catch (e) {
|
||||
// Cross-origin parent cannot happen in the same-origin shell; treat any
|
||||
// throw (parent probe blocked) as framed.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* localStorage stores */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function loadMap(key) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveMap(key, value) {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
/* private mode / quota — memory only for this session */
|
||||
}
|
||||
}
|
||||
|
||||
let tokens = loadMap(PENDING_KEY);
|
||||
function saveTokens() {
|
||||
saveMap(PENDING_KEY, tokens);
|
||||
}
|
||||
|
||||
function newToken() {
|
||||
return "t" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
/** Pending-save tokens survive reloads so Ctrl+S keeps hitting the same
|
||||
* record even after the token map left memory (one-photo convention). */
|
||||
function tokenOf(path) {
|
||||
const rest = path.slice("/odoo/pending/".length);
|
||||
const slash = rest.indexOf("/");
|
||||
return slash === -1 ? rest : rest.slice(0, slash);
|
||||
}
|
||||
|
||||
function baseName(path) {
|
||||
const withoutSlash = path.replace(/\/+$/, "");
|
||||
const slash = withoutSlash.lastIndexOf("/");
|
||||
return slash === -1 ? withoutSlash : withoutSlash.slice(slash + 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Rust glue plumbing */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** The Rust side installs window.onecadOdoo (services_odoo.rs). Requests
|
||||
* arriving before it are queued — boot-order races are impossible. */
|
||||
function rustGlue() {
|
||||
return window.onecadOdoo || null;
|
||||
}
|
||||
|
||||
const requestQueue = [];
|
||||
|
||||
function dispatchRequest(id, kind, payloadJson) {
|
||||
if (!rustGlue()) {
|
||||
// The Rust glue is not installed yet (wasm still booting): queue it and
|
||||
// retry — boot-order races are impossible.
|
||||
requestQueue.push([id, kind, payloadJson]);
|
||||
window.setTimeout(pumpQueue, 250);
|
||||
return;
|
||||
}
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
|
||||
function pumpQueue() {
|
||||
if (!rustGlue()) return;
|
||||
while (requestQueue.length) {
|
||||
const [id, kind, payloadJson] = requestQueue.shift();
|
||||
void handleRequest(id, kind, payloadJson);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(id, kind, payloadJson) {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = payloadJson ? JSON.parse(payloadJson) : {};
|
||||
} catch (e) {
|
||||
payload = {};
|
||||
}
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
if (kind === "save") {
|
||||
const design = !!payload.design;
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "save",
|
||||
defaultName: typeof payload.name === "string" && payload.name ? payload.name : "part.solidcraft",
|
||||
preferAppKey: design ? "projects" : "exports",
|
||||
lastFolderKey: design ? SAVE_FOLDER_KEY : EXPORT_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.folderId !== "number") {
|
||||
rustGlue().resolve(id, null); // cancelled — not an error
|
||||
return;
|
||||
}
|
||||
const extension = extensionOf(payload.name || "");
|
||||
const name = withExtension(picked.name, extension);
|
||||
const token = newToken();
|
||||
tokens[token] = { folderId: picked.folderId, name };
|
||||
saveTokens();
|
||||
rustGlue().resolve(id, { token, name });
|
||||
return;
|
||||
}
|
||||
// "open" and "program": pick a record, read it, hand the bytes over.
|
||||
const preferAppKey = kind === "program" ? "assets" : "projects";
|
||||
const picked = await openDocumentsExplorer({
|
||||
mode: "open",
|
||||
extensions: Array.isArray(payload.extensions) ? payload.extensions : [],
|
||||
preferAppKey,
|
||||
lastFolderKey: OPEN_FOLDER_KEY,
|
||||
});
|
||||
if (!picked || typeof picked.recordId !== "number") {
|
||||
rustGlue().resolve(id, null);
|
||||
return;
|
||||
}
|
||||
const record = await bridge.read(picked.recordId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// Foreign assets (outside the app tree) are copied into "Assets" so
|
||||
// imports always land there, like the family storage rules require.
|
||||
void mirrorToAssets(picked.recordId, record.name, bytes);
|
||||
rustGlue().resolve(id, { recordId: picked.recordId, name: record.name, bytes });
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] request failed:", error);
|
||||
try {
|
||||
if (rustGlue()) rustGlue().resolve(id, null);
|
||||
} catch (e) {
|
||||
/* the Rust future times out on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* VFS writes → Documents (saves + exports) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function handleWrite(path, bytes) {
|
||||
await hostReady;
|
||||
const dataB64 = bytesToBase64(bytes);
|
||||
if (path.indexOf("/odoo/doc/") === 0) {
|
||||
// /odoo/doc/<recordId>/<name> — in-place write-back (Ctrl+S).
|
||||
const rest = path.slice("/odoo/doc/".length);
|
||||
const recordId = Number(rest.split("/")[0]);
|
||||
if (!Number.isInteger(recordId) || recordId <= 0) throw new Error("bad document path: " + path);
|
||||
await bridge.write({ recordId, dataB64 });
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/odoo/pending/") === 0) {
|
||||
// /odoo/pending/<token>/<name> — first write creates the record, then
|
||||
// the token binds to it forever (the document_id → recordId session
|
||||
// map, kept in localStorage so reloads keep writing in place).
|
||||
const token = tokenOf(path);
|
||||
const meta = tokens[token];
|
||||
if (!meta) throw new Error("unknown save destination (stale token): " + token);
|
||||
const bound = Number.isInteger(meta.recordId) && meta.recordId > 0;
|
||||
const written = await bridge.write({
|
||||
recordId: bound ? meta.recordId : undefined,
|
||||
name: meta.name,
|
||||
folderId: bound ? undefined : meta.folderId,
|
||||
dataB64,
|
||||
extension: bound ? undefined : extensionOf(meta.name),
|
||||
});
|
||||
if (!bound) {
|
||||
meta.recordId = written.id;
|
||||
saveTokens();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.indexOf("/downloads/") === 0) {
|
||||
// Exports: no local device writes in the Odoo shell — every download
|
||||
// path is rerouted into the app's "Exports" folder.
|
||||
await bridge.saveExport({ name: baseName(path), dataB64 });
|
||||
return;
|
||||
}
|
||||
console.warn("[one-cad odoo] ignoring VFS write outside the Odoo schemes:", path);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Asset imports (computer → "Assets" folder) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let appTreeCache = null;
|
||||
|
||||
async function appAssetsFolder() {
|
||||
if (!appTreeCache) {
|
||||
const ping = await bridge.waitForHost();
|
||||
appTreeCache = (ping && ping.app) || {};
|
||||
}
|
||||
return typeof appTreeCache.assets === "number" ? appTreeCache.assets : appTreeCache.root;
|
||||
}
|
||||
|
||||
/** Copy a foreign record (picked from outside the app tree) into Assets. */
|
||||
async function mirrorToAssets(recordId, name, bytes) {
|
||||
try {
|
||||
if (typeof recordId !== "number" || DESIGN_EXTENSIONS.has(extensionOf(name))) return;
|
||||
const documents = await bridge.list();
|
||||
if ((documents.documents || []).some((doc) => Number(doc.id) === Number(recordId))) return; // already ours
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] asset mirror skipped:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAsset(name, bytes) {
|
||||
guardCap(bytes.length, name);
|
||||
const folderId = await appAssetsFolder();
|
||||
const ext = extensionOf(name) || "bin";
|
||||
await bridge.write({
|
||||
name: sanitizeStem(name),
|
||||
dataB64: bytesToBase64(bytes),
|
||||
folderId,
|
||||
extension: ext,
|
||||
});
|
||||
}
|
||||
|
||||
/** Files brought in from the computer (files.rs incoming) land in Assets. */
|
||||
async function handleImport(name, bytes) {
|
||||
await hostReady;
|
||||
const sigs = loadMap(IMPORT_SIGS_KEY);
|
||||
const sig = name + ":" + bytes.length;
|
||||
if (sigs[sig]) return;
|
||||
sigs[sig] = 1;
|
||||
saveMap(IMPORT_SIGS_KEY, sigs);
|
||||
try {
|
||||
if (!ASSET_EXTENSIONS.has(extensionOf(name))) return;
|
||||
await writeAsset(name, bytes);
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] import into Assets failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Boot: handshake, deep link (?document=<id>), host-ready promise */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
let hostReadyResolve;
|
||||
const hostReady = new Promise((resolve) => {
|
||||
hostReadyResolve = resolve;
|
||||
});
|
||||
|
||||
/** Open command for an emulated path, mirroring the app's own Open logic:
|
||||
* designs open, CAD formats are inserted, everything else just waits in
|
||||
* the VFS (the bytes are at /odoo/doc/… and stay retrievable). */
|
||||
function openCommandFor(name) {
|
||||
const ext = extensionOf(name);
|
||||
if (DESIGN_EXTENSIONS.has(ext)) return { command: "doc.open" };
|
||||
if (STEP_EXTENSIONS.has(ext)) return { command: "file.insert_step" };
|
||||
if (MESH_EXTENSIONS.has(ext)) return { command: "file.insert_mesh" };
|
||||
if (ext === "dxf" || ext === "svg") return { command: "sketch.insert_" + ext };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
if (!embedded()) return;
|
||||
try {
|
||||
await bridge.waitForHost();
|
||||
} catch (error) {
|
||||
console.warn("[one-cad odoo] host handshake failed:", error);
|
||||
hostReadyResolve(false); // fail writes loudly instead of hanging
|
||||
return;
|
||||
}
|
||||
hostReadyResolve(true);
|
||||
pumpQueue();
|
||||
|
||||
const documentId = bridge.bootDocumentId();
|
||||
if (!documentId) return;
|
||||
try {
|
||||
const record = await bridge.read(documentId);
|
||||
const bytes = base64ToBytes(record.dataB64);
|
||||
guardCap(bytes.length, record.name);
|
||||
// bootFile inserts the bytes into the engine VFS and returns the path.
|
||||
const path = rustGlue().bootFile(documentId, record.name, bytes);
|
||||
const open = openCommandFor(record.name);
|
||||
if (open && window.solidcraft && typeof window.solidcraft.execute === "function") {
|
||||
await window.solidcraft.execute(open.command, { path });
|
||||
} else if (!open) {
|
||||
console.info("[one-cad odoo] deep-linked file staged at", path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[one-cad odoo] deep-link boot failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public surface (called from services_odoo.rs) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
window.onecadOdooJs = {
|
||||
/** True once this module decided the app runs in the Odoo shell. */
|
||||
embedded: embedded(),
|
||||
/** Rust picker request: kind "open" | "save" | "program". */
|
||||
request(id, kind, payloadJson) {
|
||||
dispatchRequest(Number(id), String(kind || ""), payloadJson ? String(payloadJson) : "{}");
|
||||
},
|
||||
/** VFS write drained by services_odoo.rs (fire-and-forget save). */
|
||||
write(path, bytes) {
|
||||
handleWrite(String(path || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.error("[one-cad odoo] save to Documents failed:", path, error);
|
||||
});
|
||||
},
|
||||
/** A CAD asset imported from the computer (mirrored into Assets). */
|
||||
import(name, bytes) {
|
||||
handleImport(String(name || ""), new Uint8Array(bytes)).catch((error) => {
|
||||
console.warn("[one-cad odoo] import failed:", name, error);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (window.onecadOdooJs.embedded) {
|
||||
void boot();
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
// OneBSpace (OneCAD): Odoo Documents host integration.
|
||||
//! OneBSpace (OneCAD): Odoo Documents host integration for the wasm32 build
|
||||
//! (the `mod services_odoo;` declaration in `main.rs` carries the
|
||||
//! `#[cfg(target_arch = "wasm32")]` gate).
|
||||
//!
|
||||
//! Inside the wasm_app_framework shell every host seam of
|
||||
//! [`solidcraft_ui_egui::Services`] is rerouted through the one_cad JS
|
||||
//! bridge instead of the browser/device:
|
||||
//!
|
||||
//! - `pick_open` / `pick_save` / `pick_program` ask the JS host glue to show
|
||||
//! the unified Documents explorer and resolve with an *emulated* VFS path:
|
||||
//! `/odoo/doc/<recordId>/<name>` (an opened Documents record, bytes staged
|
||||
//! in the engine VFS) or `/odoo/pending/<token>/<name>` (a Save-as
|
||||
//! destination; the JS side keeps the token → record binding so a later
|
||||
//! Ctrl+S writes back in place).
|
||||
//! - writes drained from the engine VFS under `/odoo/…` go to the bound
|
||||
//! Documents record and `/downloads/` exports go to the "Exports" folder —
|
||||
//! the air gap keeps bytes off the local device ([`handle_write`]).
|
||||
//! - `show_in_folder` is a no-op and `http_get` stays `None` (air gap: the
|
||||
//! shell has no local filesystem to reveal and no outbound HTTP).
|
||||
//!
|
||||
//! Everything is inert standalone: when the page is not framed by the Odoo
|
||||
//! shell ([`embedded`]) nothing is overridden and the upstream behavior is
|
||||
//! untouched.
|
||||
//!
|
||||
//! # WIRE PROTOCOL (matches the one_cad JS bridge: CustomEvents)
|
||||
//!
|
||||
//! Rust → JS: `window.dispatchEvent(new CustomEvent("onecad:host-request",
|
||||
//! { detail }))` with `detail = { id: number, method: string, params: object }`.
|
||||
//!
|
||||
//! JS → Rust: `window.dispatchEvent(new CustomEvent("onecad:host-response",
|
||||
//! { detail }))` with `detail = { id, ok: boolean, result?: any, error?: string }`.
|
||||
//!
|
||||
//! The glue must attach its `onecad:host-response` listener *before* the app
|
||||
//! boots (index.html loads the bridge module ahead of the wasm) and set
|
||||
//! `window.onecadHostReady = true` when listening; until the flag appears
|
||||
//! requests are retried every [`RETRY_MS`] for [`HOST_DEADLINE`].
|
||||
//!
|
||||
//! | method | params | result (on `ok`) |
|
||||
//! |--------------------|------------------------------------------|------------------|
|
||||
//! | `odoo.pickOpen` | `{ extensions: string[] }` | `{ recordId, name, bytes: Uint8Array }` or `{ content: <base64> }` → bytes staged at `/odoo/doc/<recordId>/<name>` |
|
||||
//! | `odoo.pickSave` | `{ name: string, design: boolean }` | `{ token, name }` → `/odoo/pending/<token>/<name>`, or `{ path: "/odoo/…" }` |
|
||||
//! | `odoo.pickProgram` | `{}` (asset pick from the "Assets" folder) | same shape as `odoo.pickOpen` |
|
||||
//! | `odoo.write` | `{ path: string, bytes: Uint8Array }` | ack only (`ok`) — Documents record write-back |
|
||||
//! | `odoo.saveExport` | `{ name: string, bytes: Uint8Array }` | ack only — lands in the "Exports" folder |
|
||||
//! | `odoo.import` | `{ name: string, bytes: Uint8Array }` | ack only — best-effort mirror into "Assets" |
|
||||
//!
|
||||
//! A `null`/absent `result` with `ok: true` means the user cancelled the
|
||||
//! explorer; the picker future resolves `None` (no error).
|
||||
//!
|
||||
//! For JS→Rust binary staging (deep links `?document=<id>`) the glue may
|
||||
//! call `window.onecadOdoo.bootFile(recordId, name, uint8array) -> path`,
|
||||
//! which inserts the bytes into the engine VFS at `/odoo/doc/<id>/<name>`.
|
||||
//!
|
||||
//! # HOW TO WIRE (exact locations in the current tree)
|
||||
//!
|
||||
//! 1. `apps/solidcraft-web/src/main.rs`, module block (tagged
|
||||
//! `OneBSpace (OneCAD)`): `#[cfg(target_arch = "wasm32")] mod services_odoo;`
|
||||
//! 2. `apps/solidcraft-web/src/main.rs`, inside `web::start()`'s
|
||||
//! `WebRunner::start` app-install closure — immediately after the
|
||||
//! `let services = Services { .. };` literal and **before**
|
||||
//! `SolveApp::new(.., services)` (the tagged line
|
||||
//! `// OneBSpace (OneCAD): install odoo services on wasm`,
|
||||
//! currently main.rs line 537):
|
||||
//! `let services = crate::services_odoo::install_odoo_services(services, cc.egui_ctx.clone());`
|
||||
//! 3. **Exact VFS write drain point**: `apps/solidcraft-web/src/files.rs`
|
||||
//! `flush()` — the `for (path, bytes) in vfs::take_writes()` loop
|
||||
//! (files.rs line 297) hands every write to
|
||||
//! `crate::services_odoo::handle_write(&path, &bytes)` *first*
|
||||
//! (files.rs line 301); `true` consumes it (never OPFS, never a device
|
||||
//! download). Writes are enqueued by `vfs::write()`
|
||||
//! (crates/io/src/vfs.rs, wasm32 `imp::write`, line 84, which pushes the
|
||||
//! path onto the `WRITES` queue) and drained by `imp::take_writes()`
|
||||
//! (vfs.rs line 109).
|
||||
//! 4. `apps/solidcraft-web/src/main.rs` line 130 (`Shell::files_tick`):
|
||||
//! `crate::services_odoo::mirror_imports(&self.files)` mirrors CAD
|
||||
//! imports from the computer into the Documents "Assets" folder.
|
||||
|
||||
// OneBSpace (OneCAD) — this entire file is downstream; keep it out of
|
||||
// upstream syncs (greppable tag above).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Wake, Waker};
|
||||
use std::time::Duration;
|
||||
// OneBSpace (OneCAD): std::time::Instant::now() PANICS on
|
||||
// wasm32-unknown-unknown ("time not implemented on this platform") — the
|
||||
// whole app loop died the moment a picker (Open/Save/Import/Export) ran.
|
||||
// web_time::Instant maps to performance.now() on wasm, std on native.
|
||||
use web_time::Instant;
|
||||
|
||||
use js_sys::{Array, Object, Reflect, Uint8Array};
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{FilePickerFuture, Services};
|
||||
use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use crate::files::{self, Files};
|
||||
|
||||
/// An existing Documents record inside the emulated filesystem.
|
||||
const ODOC: &str = "/odoo/doc/";
|
||||
/// A Save-as destination chosen in the explorer, not yet written.
|
||||
const OPENDING: &str = "/odoo/pending/";
|
||||
/// Rust → JS request event (see the WIRE PROTOCOL block above).
|
||||
const REQUEST_EVENT: &str = "onecad:host-request";
|
||||
/// JS → Rust response event (see the WIRE PROTOCOL block above).
|
||||
const RESPONSE_EVENT: &str = "onecad:host-response";
|
||||
const PICK_OPEN: &str = "odoo.pickOpen";
|
||||
const PICK_SAVE: &str = "odoo.pickSave";
|
||||
const PICK_PROGRAM: &str = "odoo.pickProgram";
|
||||
const WRITE: &str = "odoo.write";
|
||||
const SAVE_EXPORT: &str = "odoo.saveExport";
|
||||
const IMPORT: &str = "odoo.import";
|
||||
/// At most this many bridge requests parked at once (the api.rs convention).
|
||||
const MAX_PENDING: usize = 64;
|
||||
/// A stuck shell (bridge JS missing, explorer broken) must not wedge the
|
||||
/// app's file picker forever: the future cancels itself after this long.
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Retry cadence while `window.onecadHostReady` has not appeared yet.
|
||||
const RETRY_MS: i32 = 250;
|
||||
/// How long to wait for the JS bridge module to become ready per request.
|
||||
const HOST_DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// CAD formats the app ingests (mirrors files.rs's input picker accept list
|
||||
/// plus glTF/URDF). Imported copies land in the Documents "Assets" folder.
|
||||
const ASSET_EXTS: [&str; 12] = ["step", "stp", "iges", "igs", "brep", "stl", "obj", "3mf", "dxf", "gltf", "glb", "ply"];
|
||||
|
||||
thread_local! {
|
||||
/// Decided once: does this page run inside the Odoo shell?
|
||||
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||
/// Pending picker requests, keyed by request id (the api.rs pending
|
||||
/// queue, adapted to Rust futures instead of JS promises).
|
||||
static PENDING: RefCell<HashMap<u64, Arc<Slot>>> = RefCell::new(HashMap::new());
|
||||
/// The request-id counter.
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||
/// The `onecad:host-response` listener is installed exactly once.
|
||||
static LISTENER: Cell<bool> = const { Cell::new(false) };
|
||||
/// The `window.onecadOdoo` boot seam is installed exactly once.
|
||||
static BOOT: Cell<bool> = const { Cell::new(false) };
|
||||
/// Asset imports already mirrored this session (name, size).
|
||||
static MIRRORED: RefCell<HashSet<(String, usize)>> = RefCell::new(HashSet::new());
|
||||
}
|
||||
|
||||
/// A picker answer slot: the response listener fills it, the future drains it.
|
||||
struct Slot {
|
||||
done: Cell<bool>,
|
||||
value: RefCell<Option<String>>,
|
||||
waker: RefCell<Option<Waker>>,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
fn complete(&self, value: Option<String>) {
|
||||
if self.done.get() {
|
||||
return;
|
||||
}
|
||||
*self.value.borrow_mut() = value;
|
||||
self.done.set(true);
|
||||
if let Some(waker) = self.waker.borrow_mut().take() {
|
||||
waker.wake();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The egui repaint waker (same pattern as ui-egui's own file picker).
|
||||
struct Repaint(egui::Context);
|
||||
|
||||
impl Wake for Repaint {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// The future a `Services` picker closure returns; resolves with the
|
||||
/// emulated path once the shell answers, or `None` when cancelled/expired.
|
||||
struct PickerFuture {
|
||||
slot: Arc<Slot>,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl Future for PickerFuture {
|
||||
type Output = Option<String>;
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Self::Output> {
|
||||
let this = self.get_mut();
|
||||
if this.slot.done.get() {
|
||||
return Poll::Ready(this.slot.value.borrow_mut().take());
|
||||
}
|
||||
if Instant::now() >= this.deadline {
|
||||
this.slot.complete(None);
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
*this.slot.waker.borrow_mut() = Some(cx.waker().clone());
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this page running inside the wasm_app_framework shell? Decided once,
|
||||
/// the family rule (one-cut-pro env.ts): framed and the same-origin parent
|
||||
/// is the Odoo web client (`/odoo` or `/web`). `?odoo-host=1` forces it on
|
||||
/// for local testing against a stub shell.
|
||||
pub fn embedded() -> bool {
|
||||
if let Some(decided) = EMBEDDED.with(Cell::get) {
|
||||
return decided;
|
||||
}
|
||||
let decided = embedded_uncached();
|
||||
EMBEDDED.with(|e| e.set(Some(decided)));
|
||||
decided
|
||||
}
|
||||
|
||||
fn embedded_uncached() -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(search) = window.location().search() else { return false };
|
||||
if search.contains("odoo-host=1") {
|
||||
return true;
|
||||
}
|
||||
let Ok(parent) = window.parent() else { return false };
|
||||
// Top window: standalone run, upstream behavior everywhere.
|
||||
let parent_top = parent
|
||||
.as_ref()
|
||||
.map(|p| {
|
||||
AsRef::<JsValue>::as_ref(p) == AsRef::<JsValue>::as_ref(&window)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if parent_top {
|
||||
return false;
|
||||
}
|
||||
// Same-origin shell: the parent path is readable; anything else that is
|
||||
// framed counts as embedded (the shell never cross-origins the iframe).
|
||||
let pathname = parent
|
||||
.as_ref()
|
||||
.and_then(|p| p.location().pathname().ok());
|
||||
match pathname {
|
||||
Some(path) => path.starts_with("/odoo") || path.starts_with("/web"),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the integration: the boot seam and the response listener always
|
||||
/// (harmless standalone), the Services overrides only inside the shell.
|
||||
/// Call once at app start, right after the `Services` literal is built in
|
||||
/// `main.rs` and before `SolveApp::new` (see HOW TO WIRE above).
|
||||
pub fn install_odoo_services(services: Services, ctx: egui::Context) -> Services {
|
||||
install_boot_seam();
|
||||
install_response_listener(ctx.clone());
|
||||
if !embedded() {
|
||||
return services;
|
||||
}
|
||||
log::info!("OneCAD: Odoo shell detected — pickers and writes route through the Documents bridge");
|
||||
let open_ctx = ctx.clone();
|
||||
let save_ctx = ctx.clone();
|
||||
let program_ctx = ctx;
|
||||
Services {
|
||||
// Open/Insert pickers browse Odoo Documents (explorer, then a read).
|
||||
pick_open: Some(Box::new(move |extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let list = Array::new();
|
||||
for ext in extensions {
|
||||
list.push(&JsValue::from_str(ext));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"extensions".into(), &list.into());
|
||||
pick(PICK_OPEN, params, open_ctx.clone())
|
||||
})),
|
||||
// Saves ask the explorer once; the returned pending token binds the
|
||||
// design to its record (later Ctrl+S writes back in place).
|
||||
pick_save: Some(Box::new(move |name: &str, extensions: &[&str]| {
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
// Same design test as the upstream closure (main.rs).
|
||||
let design = extensions.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
let _ = Reflect::set(¶ms, &"design".into(), &JsValue::from_bool(design));
|
||||
pick(PICK_SAVE, params, save_ctx.clone())
|
||||
})),
|
||||
// The slicer program pick becomes an asset pick from the "Assets"
|
||||
// folder: the picked file is staged in the VFS (no host program
|
||||
// dialog exists in the shell).
|
||||
pick_program: Some(Box::new(move || pick(PICK_PROGRAM, Object::new(), program_ctx.clone()))),
|
||||
// Air gap: nothing local to reveal.
|
||||
show_in_folder: Some(Box::new(|_: &str| {})),
|
||||
// Air gap: no update checks over HTTP in the shell.
|
||||
http_get: None,
|
||||
..services
|
||||
}
|
||||
}
|
||||
|
||||
/// Park a picker slot and deliver the request to the shell; the returned
|
||||
/// future resolves with the emulated path (or `None` on cancel/timeout).
|
||||
fn pick(method: &'static str, params: Object, ctx: egui::Context) -> FilePickerFuture {
|
||||
if PENDING.with(|p| p.borrow().len()) >= MAX_PENDING {
|
||||
log::error!("OneCAD: too many pending bridge requests (maximum {MAX_PENDING})");
|
||||
return Box::pin(std::future::ready(None));
|
||||
}
|
||||
let id = next_id();
|
||||
let slot = Arc::new(Slot { done: Cell::new(false), value: RefCell::new(None), waker: RefCell::new(None) });
|
||||
PENDING.with(|p| p.borrow_mut().insert(id, slot.clone()));
|
||||
deliver(method, id, params, slot.clone());
|
||||
// The api.rs pattern: nudge the UI so the future is polled promptly.
|
||||
ctx.request_repaint();
|
||||
Box::pin(PickerFuture { slot, deadline: Instant::now() + REQUEST_TIMEOUT })
|
||||
}
|
||||
|
||||
fn next_id() -> u64 {
|
||||
NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
id
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliver a request once `window.onecadHostReady` appears (the glue sets it
|
||||
/// after attaching its response listener), then cancel the slot if it never
|
||||
/// does. Answers arrive out-of-band via `onecad:host-response`.
|
||||
fn deliver(method: &'static str, id: u64, params: Object, slot: Arc<Slot>) {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let deadline = Instant::now() + HOST_DEADLINE;
|
||||
loop {
|
||||
if slot.done.get() {
|
||||
return; // answered or cancelled while we were waiting
|
||||
}
|
||||
if host_ready() && dispatch_event(method, id, ¶ms) {
|
||||
return; // the shell owns the answer now
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
log::error!("OneCAD: the Odoo bridge never became ready; cancelling \"{method}\"");
|
||||
slot.complete(None);
|
||||
return;
|
||||
}
|
||||
sleep_ms(RETRY_MS).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn host_ready() -> bool {
|
||||
web_sys::window()
|
||||
.map(|w| Reflect::get(&w, &"onecadHostReady".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn sleep_ms(ms: i32) {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// `window.dispatchEvent(new CustomEvent(method, { detail: {id, method, params} }))`.
|
||||
/// Built through `js_sys` (no extra web-sys feature gates). `false` when the
|
||||
/// event could not be created or dispatched (no shell listening).
|
||||
fn dispatch_event(method: &str, id: u64, params: &Object) -> bool {
|
||||
let Some(window) = web_sys::window() else { return false };
|
||||
let Ok(ctor) = Reflect::get(&window, &"CustomEvent".into()) else { return false };
|
||||
let Ok(ctor) = ctor.dyn_into::<js_sys::Function>() else { return false };
|
||||
let detail = Object::new();
|
||||
let _ = Reflect::set(&detail, &"id".into(), &JsValue::from(id as f64));
|
||||
let _ = Reflect::set(&detail, &"method".into(), &JsValue::from_str(method));
|
||||
let _ = Reflect::set(&detail, &"params".into(), params);
|
||||
let init = Object::new();
|
||||
let _ = Reflect::set(&init, &"detail".into(), &detail.into());
|
||||
let args = Array::of2(&JsValue::from_str(REQUEST_EVENT), &init.into());
|
||||
let Ok(event) = Reflect::construct(&ctor, &args) else { return false };
|
||||
let Ok(dispatch) = Reflect::get(&window, &"dispatchEvent".into()) else { return false };
|
||||
let Ok(dispatch) = dispatch.dyn_into::<js_sys::Function>() else { return false };
|
||||
dispatch.call1(&window, &event).is_ok()
|
||||
}
|
||||
|
||||
/// Install the `onecad:host-response` listener once: responses complete the
|
||||
/// parked picker slots (or log write/import failures).
|
||||
fn install_response_listener(ctx: egui::Context) {
|
||||
if LISTENER.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let listener = Closure::<dyn Fn(JsValue)>::new(move |event: JsValue| {
|
||||
let detail = Reflect::get(&event, &"detail".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let id = field_u64(&detail, "id");
|
||||
let ok = Reflect::get(&detail, &"ok".into()).ok().and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let error = field_string(&detail, "error").unwrap_or_default();
|
||||
let result = Reflect::get(&detail, &"result".into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let slot = PENDING.with(|p| p.borrow_mut().remove(&id));
|
||||
match slot {
|
||||
Some(slot) => {
|
||||
if ok {
|
||||
slot.complete(path_from_result(&result));
|
||||
} else {
|
||||
log::warn!("OneCAD: the shell refused the request: {error}");
|
||||
slot.complete(None);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
// Writes, exports and imports are acknowledged without a parked
|
||||
// slot; only their failures are interesting here.
|
||||
None if !ok => log::warn!("OneCAD: the shell refused a write/export/import: {error}"),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
let Ok(add) = Reflect::get(&window, &"addEventListener".into()) else { return };
|
||||
let Ok(add) = add.dyn_into::<js_sys::Function>() else { return };
|
||||
if add.call2(&window, &JsValue::from_str(RESPONSE_EVENT), &listener.into_js_value()).is_ok() {
|
||||
LISTENER.with(|l| l.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `onecad:host-response` result onto the emulated path the picker
|
||||
/// future receives (see the WIRE PROTOCOL table above).
|
||||
fn path_from_result(result: &JsValue) -> Option<String> {
|
||||
if !result.is_object() {
|
||||
return None; // the user cancelled the explorer
|
||||
}
|
||||
let name = field_string(result, "name");
|
||||
// An opened record: bytes come as a Uint8Array (preferred) or base64.
|
||||
if let Ok(bytes) = Reflect::get(result, &"bytes".into())
|
||||
&& bytes.is_instance_of::<Uint8Array>()
|
||||
{
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return None };
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes.to_vec());
|
||||
}
|
||||
if let Some(content) = field_string(result, "content")
|
||||
&& let Some(bytes) = base64_decode(&content)
|
||||
{
|
||||
return stage_record(field_u64(result, "recordId"), name.as_deref(), bytes);
|
||||
}
|
||||
// A Save-as destination: the pending token names the path the engine
|
||||
// will write to (the glue binds the token to the record).
|
||||
let token = field_string(result, "token");
|
||||
if let (Some(token), Some(name)) = (token, name) {
|
||||
return Some(format!("{OPENDING}{token}/{}", sanitize(&name)));
|
||||
}
|
||||
// Or the glue resolved an explicit emulated path itself.
|
||||
if let Some(path) = field_string(result, "path")
|
||||
&& path.starts_with("/odoo/")
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Stage a picked record's bytes in the engine VFS at `/odoo/doc/<id>/<name>`.
|
||||
fn stage_record(record_id: u64, name: Option<&str>, bytes: Vec<u8>) -> Option<String> {
|
||||
if record_id == 0 {
|
||||
return None;
|
||||
}
|
||||
let path = format!("{ODOC}{record_id}/{}", sanitize(name.unwrap_or("document")));
|
||||
vfs::insert(&path, bytes);
|
||||
Some(path)
|
||||
}
|
||||
|
||||
fn field_u64(value: &JsValue, key: &str) -> u64 {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_f64()).map(|f| f as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn field_string(value: &JsValue, key: &str) -> Option<String> {
|
||||
Reflect::get(value, &key.into()).ok().and_then(|v| v.as_string())
|
||||
}
|
||||
|
||||
/// File names only key the VFS: keep them single-segment.
|
||||
fn sanitize(name: &str) -> String {
|
||||
let cleaned: String = name.chars().map(|c| if c == '/' || c == '\\' { '_' } else { c }).collect();
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.is_empty() {
|
||||
"file".into()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny base64 (standard alphabet, padding optional) decoder for bridge
|
||||
/// payloads that arrive as `content` strings instead of `Uint8Array`s.
|
||||
fn base64_decode(input: &str) -> Option<Vec<u8>> {
|
||||
fn value(byte: u8) -> Option<u32> {
|
||||
match byte {
|
||||
b'A'..=b'Z' => Some((byte - b'A') as u32),
|
||||
b'a'..=b'z' => Some((byte - b'a') as u32 + 26),
|
||||
b'0'..=b'9' => Some((byte - b'0') as u32 + 52),
|
||||
b'+' => Some(62),
|
||||
b'/' => Some(63),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
let clean: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace() && *b != b'=').collect();
|
||||
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
||||
for chunk in clean.chunks(4) {
|
||||
if chunk.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut acc: u32 = 0;
|
||||
for (i, byte) in chunk.iter().enumerate() {
|
||||
acc |= value(*byte)? << (18 - 6 * i);
|
||||
}
|
||||
out.push((acc >> 16) as u8);
|
||||
if chunk.len() > 2 {
|
||||
out.push((acc >> 8) as u8);
|
||||
}
|
||||
if chunk.len() > 3 {
|
||||
out.push(acc as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Drain hook (tagged call site in `files.rs::flush`, the exact
|
||||
/// `vfs::take_writes()` drain point): consume writes the shell must not turn
|
||||
/// into device IO. `/odoo/…` writes go to the bound Documents record (or the
|
||||
/// pending token's destination on first write), `/downloads/` exports go to
|
||||
/// the "Exports" folder via saveExport. Returns true when the write was
|
||||
/// consumed (standalone: always false, upstream behavior untouched).
|
||||
pub fn handle_write(path: &str, bytes: &[u8]) -> bool {
|
||||
if !embedded() {
|
||||
return false;
|
||||
}
|
||||
let export = path.strip_prefix(files::DOWNLOADS).map(str::to_string);
|
||||
let is_odoo = path.starts_with(ODOC) || path.starts_with(OPENDING);
|
||||
if !is_odoo && export.is_none() {
|
||||
return false;
|
||||
}
|
||||
let params = Object::new();
|
||||
if let Some(name) = &export {
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
} else {
|
||||
let _ = Reflect::set(¶ms, &"path".into(), &JsValue::from_str(path));
|
||||
}
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes).into());
|
||||
let method = if export.is_some() { SAVE_EXPORT } else { WRITE };
|
||||
if !dispatch_event(method, next_id(), ¶ms) {
|
||||
log::error!("OneCAD: the Odoo bridge is unavailable; \"{path}\" stays in the engine VFS (the Documents copy was not updated)");
|
||||
}
|
||||
// Consumed either way: the air gap wins even if the bridge module is
|
||||
// gone — bytes never reach the local device from the shell (a copy
|
||||
// survives in the engine VFS and the OPFS autosave until the save
|
||||
// succeeds).
|
||||
true
|
||||
}
|
||||
|
||||
/// Mirror hook (tagged call site in `main.rs::files_tick`): CAD assets
|
||||
/// brought in from the computer get a copy in the Documents "Assets" folder
|
||||
/// (family storage rule). Deduplicated per (name, size) this session; the
|
||||
/// JS side additionally dedupes by signature across reloads.
|
||||
pub fn mirror_imports(files: &Rc<Files>) {
|
||||
if !embedded() {
|
||||
return;
|
||||
}
|
||||
for (name, bytes, _) in files.incoming.borrow().iter() {
|
||||
if !is_asset_name(name) {
|
||||
continue;
|
||||
}
|
||||
if !MIRRORED.with(|m| m.borrow_mut().insert((name.clone(), bytes.len()))) {
|
||||
continue;
|
||||
}
|
||||
let params = Object::new();
|
||||
let _ = Reflect::set(¶ms, &"name".into(), &JsValue::from_str(name));
|
||||
let _ = Reflect::set(¶ms, &"bytes".into(), &Uint8Array::from(bytes.as_slice()).into());
|
||||
if !dispatch_event(IMPORT, next_id(), ¶ms) {
|
||||
log::warn!("OneCAD: could not mirror \"{name}\" into the Assets folder (bridge not ready)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_asset_name(name: &str) -> bool {
|
||||
std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ASSET_EXTS.iter().any(|candidate| ext.eq_ignore_ascii_case(candidate)))
|
||||
}
|
||||
|
||||
/// Install `window.onecadOdoo = { bootFile }` — the JS→Rust staging seam for
|
||||
/// deep links (`?document=<id>`): the glue inserts the record's bytes into
|
||||
/// the engine VFS, then opens the returned path via `window.solidcraft`.
|
||||
fn install_boot_seam() {
|
||||
if BOOT.with(Cell::get) {
|
||||
return;
|
||||
}
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
let boot_file = Closure::<dyn Fn(JsValue, JsValue, JsValue) -> JsValue>::new(
|
||||
|record_id: JsValue, name: JsValue, bytes: JsValue| {
|
||||
let id = record_id.as_f64().map(|f| f as u64).unwrap_or(0);
|
||||
if id == 0 {
|
||||
return JsValue::UNDEFINED;
|
||||
}
|
||||
let name = name.as_string().unwrap_or_else(|| "document".into());
|
||||
let Ok(bytes) = bytes.dyn_into::<Uint8Array>() else { return JsValue::UNDEFINED };
|
||||
match stage_record(id, Some(&name), bytes.to_vec()) {
|
||||
Some(path) => JsValue::from_str(&path),
|
||||
None => JsValue::UNDEFINED,
|
||||
}
|
||||
},
|
||||
);
|
||||
let glue = Object::new();
|
||||
let _ = Reflect::set(&glue, &"bootFile".into(), &boot_file.into_js_value());
|
||||
if Reflect::set(&window, &"onecadOdoo".into(), &glue).is_ok() {
|
||||
BOOT.with(|b| b.set(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OneBSpace (OneCAD) — build the SolidCraft wasm bundle into the one_cad Odoo addon.
|
||||
|
||||
Stdlib-only build chain (python3 scripts/build_onecad_addon.py [--dry-run]):
|
||||
|
||||
1. optional clean: ODA_CLEAN=1 wipes the dist target before building
|
||||
2. build: prefer trunk (apps/solidcraft-web/Trunk.toml); fallback is
|
||||
onepixel's tools/cargo-run chain verbatim:
|
||||
cargo build --release --target wasm32-unknown-unknown -p solidcraft-web
|
||||
wasm-bindgen --target web --out-dir <dist> --out-name onecad <wasm>
|
||||
wasm-opt -Oz (when on PATH)
|
||||
plus index.html + static assets copied into <dist>
|
||||
3. rebrand: <title>OneCAD</title> + data-app-name="OneCAD" in index.html;
|
||||
drop sw.js / manifest.webmanifest / build.json / icon files
|
||||
4. verify gate: FAIL on absolute /(assets|fonts|blobs)/ references in any text
|
||||
file of the dist; require index.html, a *_bg.wasm and a
|
||||
wasm-bindgen glue .js — exits non-zero on failure
|
||||
5. mirror-copy: dist -> /home/z/my-project/one-os-dev/core-components/one_cad/static/onecad/
|
||||
(rsync-like: stale files in the destination are removed)
|
||||
6. reminder: bump bundleVersion (static/src/one_cad_app.js) + addon manifest version
|
||||
|
||||
Exit codes: 0 ok, 1 gate failure, 2 build environment problem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
APP = REPO / "apps" / "solidcraft-web"
|
||||
DIST = REPO / "dist" / "web"
|
||||
DEST = Path("/home/z/my-project/one-os-dev/core-components/one_cad/static/onecad")
|
||||
WASM_TARGET = "wasm32-unknown-unknown"
|
||||
WASM_ARTIFACT = REPO / "target" / WASM_TARGET / "release" / "solidcraft_web.wasm"
|
||||
OUT_NAME = "onecad"
|
||||
|
||||
TEXT_SUFFIXES = {".html", ".js", ".mjs", ".css", ".json", ".svg", ".txt", ".webmanifest", ".map"}
|
||||
ABS_REF = re.compile(r"""["'=(]/(?:assets|fonts|blobs)/""")
|
||||
ICON_FILE = re.compile(r"^(icon|apple-touch-icon|favicon).*", re.I)
|
||||
DROP_FILES = {"sw.js", "manifest.webmanifest", "site.webmanifest", "build.json"}
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[onecad] {message}")
|
||||
|
||||
|
||||
def sh(cmd: list[str], cwd: Path | None = None) -> None:
|
||||
log("$ " + " ".join(cmd) + (f" (cwd={cwd})" if cwd else ""))
|
||||
try:
|
||||
subprocess.run([str(c) for c in cmd], cwd=str(cwd) if cwd else None, check=True)
|
||||
except FileNotFoundError as exc:
|
||||
sys.exit(f"[onecad] tool not found: {exc.filename}")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
sys.exit(f"[onecad] command failed with exit code {exc.returncode}: {' '.join(str(c) for c in cmd)}")
|
||||
|
||||
|
||||
def clean_dist() -> None:
|
||||
if DIST.exists():
|
||||
log(f"ODA_CLEAN=1: wiping {DIST}")
|
||||
shutil.rmtree(DIST, ignore_errors=True)
|
||||
|
||||
|
||||
def build() -> Path:
|
||||
"""Build the wasm bundle; return the dist directory that holds index.html."""
|
||||
if shutil.which("trunk"):
|
||||
sh(["trunk", "build", "--release", "--public-url", "./"], cwd=APP)
|
||||
else:
|
||||
log("trunk not on PATH — using the cargo + wasm-bindgen chain (onepixel convention)")
|
||||
sh(["cargo", "build", "--release", "--target", WASM_TARGET, "-p", "solidcraft-web"], cwd=REPO)
|
||||
if not WASM_ARTIFACT.exists():
|
||||
sys.exit(f"[onecad] expected wasm artifact missing: {WASM_ARTIFACT}")
|
||||
DIST.mkdir(parents=True, exist_ok=True)
|
||||
sh(["wasm-bindgen", "--target", "web", "--out-dir", DIST, "--out-name", OUT_NAME, WASM_ARTIFACT])
|
||||
if shutil.which("wasm-opt"):
|
||||
optimized = DIST / f"{OUT_NAME}_bg.wasm.opt"
|
||||
sh(["wasm-opt", "-Oz", DIST / f"{OUT_NAME}_bg.wasm", "-o", optimized])
|
||||
os.replace(optimized, DIST / f"{OUT_NAME}_bg.wasm")
|
||||
else:
|
||||
log("wasm-opt not on PATH — skipping -Oz (larger bundle, still correct)")
|
||||
# Static assets trunk would have copied: index.html + the assets tree.
|
||||
if (APP / "index.html").exists():
|
||||
shutil.copy2(APP / "index.html", DIST / "index.html")
|
||||
if (APP / "assets").is_dir():
|
||||
shutil.copytree(APP / "assets", DIST / "assets", dirs_exist_ok=True)
|
||||
for candidate in (DIST, APP / "dist"):
|
||||
if (candidate / "index.html").is_file():
|
||||
return candidate
|
||||
sys.exit("[onecad] build produced no index.html in dist/web or apps/solidcraft-web/dist")
|
||||
|
||||
|
||||
def rebrand(dist: Path) -> None:
|
||||
index = dist / "index.html"
|
||||
html = index.read_text(encoding="utf-8")
|
||||
html, n_title = re.subn(r"<title\b[^>]*>.*?</title>", "<title>OneCAD</title>", html, flags=re.S | re.I)
|
||||
if 'data-app-name="' in html:
|
||||
html = re.sub(r'data-app-name="[^"]*"', 'data-app-name="OneCAD"', html)
|
||||
else:
|
||||
html = re.sub(r"<html\b", '<html data-app-name="OneCAD"', html, count=1)
|
||||
# Service worker, PWA manifest and icon links have no meaning inside the
|
||||
# Odoo iframe — drop the tags along with the files.
|
||||
html = re.sub(r"<link[^>]+rel=[\"']manifest[\"'][^>]*>\s*", "", html, flags=re.I)
|
||||
html = re.sub(r"<link[^>]+rel=[\"'][^\"']*(icon|apple-touch)[^\"']*[\"'][^>]*>\s*", "", html, flags=re.I)
|
||||
index.write_text(html, encoding="utf-8")
|
||||
log(f"rebranded {index} (title replacements: {n_title})")
|
||||
dropped = 0
|
||||
for path in sorted(dist.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if path.name in DROP_FILES or ICON_FILE.match(path.name):
|
||||
path.unlink()
|
||||
dropped += 1
|
||||
log(f"dropped {dropped} sw/manifest/icon files from the bundle")
|
||||
|
||||
|
||||
def verify(dist: Path) -> list[str]:
|
||||
"""The gate: absolute /(assets|fonts|blobs)/ refs fail; glue + wasm must exist."""
|
||||
errors: list[str] = []
|
||||
if not (dist / "index.html").is_file():
|
||||
errors.append("missing index.html")
|
||||
wasm_files = [p for p in dist.rglob("*.wasm")]
|
||||
if not any(p.name.endswith("_bg.wasm") for p in wasm_files):
|
||||
errors.append("no *_bg.wasm (wasm-bindgen output) in dist")
|
||||
glue = [
|
||||
p
|
||||
for p in dist.rglob("*.js")
|
||||
if "wasm_bindgen" in p.read_text(encoding="utf-8", errors="ignore")
|
||||
or "wasm-bindgen" in p.read_text(encoding="utf-8", errors="ignore")
|
||||
]
|
||||
if not glue:
|
||||
errors.append("no wasm-bindgen glue .js in dist")
|
||||
offenders: list[str] = []
|
||||
for path in sorted(dist.rglob("*")):
|
||||
if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
if ABS_REF.search(text):
|
||||
offenders.append(str(path.relative_to(dist)))
|
||||
if offenders:
|
||||
errors.append(
|
||||
"absolute /(assets|fonts|blobs)/ references (must be relative — the bundle is served "
|
||||
f"from /one_cad/static/onecad/): {', '.join(offenders[:10])}"
|
||||
+ (f" … (+{len(offenders) - 10} more)" if len(offenders) > 10 else "")
|
||||
)
|
||||
if errors:
|
||||
for error in errors:
|
||||
log(f"GATE FAIL: {error}")
|
||||
else:
|
||||
log(f"GATE PASS: {len(wasm_files)} wasm, {len(glue)} glue js, all references relative")
|
||||
return errors
|
||||
|
||||
|
||||
def mirror(src: Path, dst: Path) -> None:
|
||||
"""rsync-like: make dst exactly src (extra files in dst are removed)."""
|
||||
src_files = {p.relative_to(src) for p in src.rglob("*") if p.is_file()}
|
||||
src_dirs = {p.relative_to(src) for p in src.rglob("*") if p.is_dir()}
|
||||
dst.mkdir(parents=True, exist_ok=True)
|
||||
removed = 0
|
||||
for path in sorted(dst.rglob("*"), key=lambda p: len(p.parts), reverse=True):
|
||||
rel = path.relative_to(dst)
|
||||
if path.is_dir() and rel not in src_dirs:
|
||||
shutil.rmtree(path)
|
||||
removed += 1
|
||||
elif path.is_file() and rel not in src_files:
|
||||
path.unlink()
|
||||
removed += 1
|
||||
for rel in sorted(src_dirs):
|
||||
(dst / rel).mkdir(parents=True, exist_ok=True)
|
||||
for rel in sorted(src_files):
|
||||
shutil.copy2(src / rel, dst / rel)
|
||||
log(f"mirrored {len(src_files)} files -> {dst} ({removed} stale entries removed)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--dry-run", action="store_true", help="print the plan, no builds/writes; gates run read-only against an existing dist")
|
||||
args = parser.parse_args()
|
||||
|
||||
tools = {name: bool(shutil.which(name)) for name in ("trunk", "cargo", "wasm-bindgen", "wasm-opt")}
|
||||
tool_report = ", ".join(f"{name}={'yes' if present else 'no'}" for name, present in tools.items())
|
||||
log(f"tools: {tool_report}")
|
||||
if not args.dry_run and not (tools["trunk"] or (tools["cargo"] and tools["wasm-bindgen"])):
|
||||
log("need trunk, or cargo + wasm-bindgen, on PATH")
|
||||
return 2
|
||||
|
||||
if args.dry_run:
|
||||
log(f"DRY RUN: clean={'yes' if os.environ.get('ODA_CLEAN') == '1' else 'no'} -> build ({'trunk' if tools['trunk'] else 'cargo chain'}) -> {DIST} -> rebrand -> gate -> mirror {DEST}")
|
||||
if (DIST / "index.html").is_file():
|
||||
log("an existing dist was found; running the verification gate read-only")
|
||||
return 1 if verify(DIST) else 0
|
||||
log("no dist yet — plan only")
|
||||
return 0
|
||||
|
||||
if os.environ.get("ODA_CLEAN") == "1":
|
||||
clean_dist()
|
||||
dist = build()
|
||||
rebrand(dist)
|
||||
if verify(dist):
|
||||
return 1
|
||||
mirror(dist, DEST)
|
||||
log("NEXT STEPS:")
|
||||
log(" 1. bump bundleVersion in one-os-dev/core-components/one_cad/static/src/one_cad_app.js (cache-busts the iframe bundle)")
|
||||
log(" 2. bump the version in one-os-dev/core-components/one_cad/__manifest__.py to match the addon release")
|
||||
log(" 3. upgrade the module (odoo -u one_cad) so the new static bundle is served")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user