forked from 1b-agent/opencadstudio-upstream
[ADD] OneCAD Draft Odoo integration: Rust odoo_bridge (File>Open via Documents explorer, PollWebControl drain), web/odoo glue trio (save air-gap via openCadStudioSaveFile hook, deep-link via ocs_web_drop_file), CustomEvent web-sys features, BUILD-WASM.md build guide; sandbox-constrained opt-level=0 note in release profile
This commit is contained in:
1 parent
193f2ae9ab
commit
62babcb71e
11 files changed
+2823
-6
No files matched your search
@@ -0,0 +1,77 @@
|
|||||||
|
# Building the OneCAD Draft wasm bundle (`static/onecaddraft`)
|
||||||
|
|
||||||
|
The complete source state of this repository (rebrand + Odoo glue + Rust
|
||||||
|
bridge) is ready to build. **The wasm compile of the app crate (640k lines of
|
||||||
|
Rust across 789 files) needs ~4.5 GB RAM for rustc alone** — it OOM-kills in
|
||||||
|
a 4 GB cgroup (verified twice with a memory monitor: rustc peaks at
|
||||||
|
~3.7 GB and grows past the ceiling during metadata encoding). Any machine
|
||||||
|
with **8 GB+ free RAM** and ~3 GB free disk completes it.
|
||||||
|
|
||||||
|
## Build on a roomy machine (the one path that works end-to-end)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 0) deps: rustup target add wasm32-unknown-unknown; cargo install --locked trunk
|
||||||
|
# wasm-bindgen-cli MUST match Cargo.lock (grep wasm-bindgen Cargo.lock)
|
||||||
|
|
||||||
|
# 1) OPTIONAL (recommended for production quality): restore opt level
|
||||||
|
# In Cargo.toml [profile.release] remove the opt-level = 0 line added for
|
||||||
|
# the constrained sandbox build. Upstream default (opt 3) is best for CAD.
|
||||||
|
|
||||||
|
# 2) build (trunk reads Trunk.toml: target=web-app.html, dist=dist/app,
|
||||||
|
# and runs the [[hooks]] post_build script that builds + wasm-binds
|
||||||
|
# the ocs_web_worker cdylib automatically)
|
||||||
|
trunk build --release
|
||||||
|
|
||||||
|
# 3) assemble + deploy + version lockstep (compiles web/odoo/*.ts with
|
||||||
|
# esbuild, strips sw.js/app.webmanifest, injects the odoo boot snippet,
|
||||||
|
# deploys to core-components/one_cad_draft/static/onecaddraft/)
|
||||||
|
python3 scripts/assemble_onecaddraft_dist.py # on the one-os-dev sandbox
|
||||||
|
|
||||||
|
# 4) install/upgrade the module
|
||||||
|
python3 scripts/onecaddraft-install-rpc.py # XML-RPC 127.0.0.1:18070 db=oneos
|
||||||
|
```
|
||||||
|
|
||||||
|
`assemble_onecaddraft_dist.py` gates: REQUIRED `index.html`,
|
||||||
|
`OpenCADStudio.js`, `OpenCADStudio_bg.wasm`, `worker_pkg/ocs_web_worker.js`,
|
||||||
|
`worker_pkg/ocs_web_worker_bg.wasm`, `ocs-parse-worker.js`, `ocs-control.js`,
|
||||||
|
`locale-labels.json`, non-empty `fonts/`; FORBIDDEN `sw.js`,
|
||||||
|
`app.webmanifest` (stripped with a loud log); no absolute `/app/`-prefixed
|
||||||
|
asset refs; version lockstep across `bundleVersion`, `__manifest__.py` and
|
||||||
|
the glue `?v=` literals.
|
||||||
|
|
||||||
|
## Sandbox lessons that cost us hours (do not re-learn)
|
||||||
|
|
||||||
|
1. **`?v=` bump per build.** Odoo serves statics with 7-day max-age; the
|
||||||
|
assembler bumps everything in lockstep. Hard-refresh the browser once.
|
||||||
|
2. **wasm-bindgen stack OOM** when the Odoo stack is up: `oneos-dev down`
|
||||||
|
before bindgen, `up` after (the assembler does not build, so this only
|
||||||
|
matters for manual bindgen runs).
|
||||||
|
3. **NEVER `wasm-tools strip -a`** (destroys `__wasm_bindgen_unstable`).
|
||||||
|
Use `wasm-opt --strip-dwarf` then `-O1`/`-O2` if hand-optimizing; trunk
|
||||||
|
already runs the wasm-opt pass requested in the HTML tag.
|
||||||
|
4. **Overlay rootfs**: only runtime-layer deletions free space; build
|
||||||
|
scratch must be deleted eagerly (`target/release/deps` re-fills on
|
||||||
|
resume — deleting it buys nothing net).
|
||||||
|
5. **`NO_COLOR=1` breaks trunk's clap parser** (`--no-color` expects
|
||||||
|
true/false): run trunk with `env -u NO_COLOR`.
|
||||||
|
6. **`cargo check` creates a full second (dev-profile) tree** — on a
|
||||||
|
constrained disk it IS the disk-full event. Don't check; build.
|
||||||
|
7. **Orphan sweep**: profile-era changes leave stale-hash artifacts forever;
|
||||||
|
`scripts/prune-orphans.py` (in the one-os-dev sandbox) reclaims them.
|
||||||
|
8. **Background processes die between tool calls** in the z.ai sandbox —
|
||||||
|
run long builds in ≤9-minute foreground chunks; cargo resumes
|
||||||
|
incrementally.
|
||||||
|
|
||||||
|
## What is already wired (no further source work needed)
|
||||||
|
|
||||||
|
- `web/odoo/{index.ts,bridge.ts,explorer.js}` — the Documents glue (save
|
||||||
|
air-gap via `window.openCadStudioSaveFile`, deep-link opens via
|
||||||
|
`wasmBindings.ocs_web_drop_file`, explorer dialogs, bind storage).
|
||||||
|
- `src/odoo_bridge.rs` — Rust bridge: `File > Open` in the embedded shell
|
||||||
|
dispatches `onecaddraft:host-request {method:"odoo.pickOpen"}` and drains
|
||||||
|
completions in `Message::PollWebControl` (50 ms tick) into
|
||||||
|
`open_web_bytes`. Never touches `window.onecaddraftHostReady`.
|
||||||
|
- `src/app/update/file.rs` (intercept) + `src/app/update/mod.rs` (drain) +
|
||||||
|
`src/lib.rs` (module decl) + `Cargo.toml` (CustomEvent features).
|
||||||
|
- `core-components/one_cad_draft/` in one-os-dev: the 5-file addon
|
||||||
|
(storage seq 52, menu seq 37, extensions dxf/dwg, fileExtension .dxf).
|
||||||
@@ -191,6 +191,9 @@ web-sys = { version = "=0.3.85", features = [
|
|||||||
"DataTransfer",
|
"DataTransfer",
|
||||||
"FileList",
|
"FileList",
|
||||||
"Event",
|
"Event",
|
||||||
|
# OneBSpace (OneCAD Draft): Odoo bridge CustomEvents (web/odoo/ glue).
|
||||||
|
"CustomEvent",
|
||||||
|
"CustomEventInit",
|
||||||
"ReadableStreamDefaultReader",
|
"ReadableStreamDefaultReader",
|
||||||
"ReadableStream",
|
"ReadableStream",
|
||||||
# The ?file= boot deep link reads location.search and compares the link's
|
# The ?file= boot deep link reads location.search and compares the link's
|
||||||
@@ -209,6 +212,8 @@ opt-level = 2
|
|||||||
debug = 1
|
debug = 1
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
|
# OneBSpace (OneCAD Draft): disk-constrained sandbox build; rebuild at opt=3 in a roomy env.
|
||||||
|
opt-level = 0
|
||||||
strip = true
|
strip = true
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
|
|||||||
@@ -70,14 +70,14 @@ impl OpenCADStudio {
|
|||||||
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||||
match cmd {
|
match cmd {
|
||||||
// OneBSpace (OneCAD Draft): donations disabled — air-gapped build.
|
// OneBSpace (OneCAD Draft): donations disabled — air-gapped build.
|
||||||
"DONATE" => Some(Task::none()),
|
"DONATE" => (),
|
||||||
|
|
||||||
// OneBSpace (OneCAD Draft): the upstream web-app link is gone.
|
// OneBSpace (OneCAD Draft): the upstream web-app link is gone.
|
||||||
"WEBVERSION" => Some(Task::none()),
|
"WEBVERSION" => (),
|
||||||
|
|
||||||
// OneBSpace (OneCAD Draft): external help/discussions link removed —
|
// OneBSpace (OneCAD Draft): external help/discussions link removed —
|
||||||
// air-gapped build. Use the in-app help instead.
|
// air-gapped build. Use the in-app help instead.
|
||||||
"HELP" => Some(Task::none()),
|
"HELP" => (),
|
||||||
|
|
||||||
// ── DWGPROPS — print round-trip-only HeaderVariables ─────────
|
// ── DWGPROPS — print round-trip-only HeaderVariables ─────────
|
||||||
// No UI dialog for these yet; the command surfaces them so
|
// No UI dialog for these yet; the command surfaces them so
|
||||||
@@ -301,7 +301,7 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
// OneBSpace (OneCAD Draft): the upstream GitHub issue tracker is
|
// OneBSpace (OneCAD Draft): the upstream GitHub issue tracker is
|
||||||
// unreachable from an air-gapped build; feedback is a no-op.
|
// unreachable from an air-gapped build; feedback is a no-op.
|
||||||
"REPORT" => Some(Task::none()),
|
"REPORT" => (),
|
||||||
|
|
||||||
"ABOUT" => {
|
"ABOUT" => {
|
||||||
return Some(Task::done(Message::AboutOpen));
|
return Some(Task::done(Message::AboutOpen));
|
||||||
@@ -313,7 +313,7 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
// OneBSpace (OneCAD Draft): upstream release notes are unreachable
|
// OneBSpace (OneCAD Draft): upstream release notes are unreachable
|
||||||
// from an air-gapped build.
|
// from an air-gapped build.
|
||||||
"CHANGELOG" => Some(Task::none()),
|
"CHANGELOG" => (),
|
||||||
|
|
||||||
// ── ALIASEDIT — command-alias editor ───────────────────────────
|
// ── ALIASEDIT — command-alias editor ───────────────────────────
|
||||||
// Opens the command-alias table editor (ocad.pgp). Keyboard
|
// Opens the command-alias table editor (ocad.pgp). Keyboard
|
||||||
|
|||||||
@@ -1619,6 +1619,12 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
{
|
{
|
||||||
|
// OneBSpace (OneCAD Draft): embedded in the Odoo Documents shell,
|
||||||
|
// File > Open is served by the host's Documents explorer
|
||||||
|
// (`odoo.pickOpen`, web/odoo/ glue) instead of the browser picker.
|
||||||
|
if crate::odoo_bridge::intercept_open() {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
// The browser picker + parse happen inside `pick_and_load_web`;
|
// The browser picker + parse happen inside `pick_and_load_web`;
|
||||||
// the real name is unknown until then, so show a generic label
|
// the real name is unknown until then, so show a generic label
|
||||||
// meanwhile.
|
// meanwhile.
|
||||||
|
|||||||
@@ -954,7 +954,13 @@ impl OpenCADStudio {
|
|||||||
for note in crate::scene::model::image_model::take_decode_refusals() {
|
for note in crate::scene::model::image_model::take_decode_refusals() {
|
||||||
self.command_line.push_error(¬e);
|
self.command_line.push_error(¬e);
|
||||||
}
|
}
|
||||||
Task::batch([control, dropped])
|
// OneBSpace (OneCAD Draft): drawings picked in the Odoo
|
||||||
|
// Documents explorer land in the bridge queue; deliver them
|
||||||
|
// through the same open path as a browser drop.
|
||||||
|
let odoo_open = crate::odoo_bridge::take_completed_open()
|
||||||
|
.map(|(name, bytes)| self.open_web_bytes(name, bytes))
|
||||||
|
.unwrap_or_else(Task::none);
|
||||||
|
Task::batch([control, dropped, odoo_open])
|
||||||
}
|
}
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Message::PollWebControl => Task::none(),
|
Message::PollWebControl => Task::none(),
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ pub mod mcp_ops;
|
|||||||
pub mod modules;
|
pub mod modules;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub(crate) mod network;
|
pub(crate) mod network;
|
||||||
|
// OneBSpace (OneCAD Draft): Odoo Documents bridge (web/odoo/ glue, wasm only).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub mod odoo_bridge;
|
||||||
pub mod par;
|
pub mod par;
|
||||||
pub mod patreon;
|
pub mod patreon;
|
||||||
pub mod perf;
|
pub mod perf;
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
//! OneBSpace (OneCAD Draft): Odoo Documents bridge — Rust side.
|
||||||
|
//!
|
||||||
|
//! Wires the app's `File > Open` flow to the OneBSpace Documents explorer
|
||||||
|
//! while the studio runs embedded in the `one_cad_draft` Odoo addon iframe
|
||||||
|
//! (wasm_app_framework). The JS half of this bridge lives in `web/odoo/`
|
||||||
|
//! (see `web/odoo/README-odoo.md` for the full wire contract).
|
||||||
|
//!
|
||||||
|
//! Contract summary (README-odoo.md §3.2 / §8.5):
|
||||||
|
//! - This module DISPATCHES `onecaddraft:host-request` CustomEvents
|
||||||
|
//! `{id, method, payload}` on the window and LISTENS for
|
||||||
|
//! `onecaddraft:host-response` `{id, ok, result?, error?}`.
|
||||||
|
//! - The glue sets `window.onecaddraftHostReady = true` once its response
|
||||||
|
//! listener is attached. This side polls/retires until the flag shows up;
|
||||||
|
//! it NEVER sets or clears the flag itself.
|
||||||
|
//! - Saves/exports are air-gapped WITHOUT this module: `sys.rs::download_bytes`
|
||||||
|
//! already prefers the page hook `window.openCadStudioSaveFile(name, bytes)`
|
||||||
|
//! registered by the glue, so bytes can never reach a browser download.
|
||||||
|
//! Deep-link opens (`?document=<recordId>`) are staged by the glue through
|
||||||
|
//! `wasmBindings.ocs_web_drop_file` and drained by `Message::PollWebControl`
|
||||||
|
//! like any other drop. The ONLY thing Rust must provide is routing the
|
||||||
|
//! in-app `File > Open` through the host explorer (`odoo.pickOpen`).
|
||||||
|
//!
|
||||||
|
//! Call sites (both `#[cfg(target_arch = "wasm32")]`):
|
||||||
|
//! - `app::update::file::on_open_file` -> [`intercept_open`]
|
||||||
|
//! - `app::update::Message::PollWebControl` -> [`take_completed_open`]
|
||||||
|
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use wasm_bindgen::prelude::*;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
|
||||||
|
/// CustomEvent the glue listens on (Rust -> glue requests).
|
||||||
|
const REQUEST_EVENT: &str = "onecaddraft:host-request";
|
||||||
|
/// CustomEvent this module listens on (glue -> Rust responses).
|
||||||
|
const RESPONSE_EVENT: &str = "onecaddraft:host-response";
|
||||||
|
/// Host method used for `File > Open` (answered by the Documents explorer).
|
||||||
|
const PICK_METHOD: &str = "odoo.pickOpen";
|
||||||
|
/// Extensions offered to the host explorer for open (owned drawing types).
|
||||||
|
const PICK_EXTENSIONS: &[&str] = &["dxf", "dwg"];
|
||||||
|
/// The poll tick (`Message::PollWebControl`) fires every 50 ms; re-dispatch
|
||||||
|
/// a still-pending pick request every 5th tick (250 ms) until the glue is up.
|
||||||
|
const RETRY_EVERY_TICKS: u32 = 5;
|
||||||
|
/// Give the glue ~30 s to appear (600 x 50 ms) before dropping the request.
|
||||||
|
const HOST_DEADLINE_TICKS: u32 = 600;
|
||||||
|
|
||||||
|
/// A drawing the host explorer handed back, ready to be opened.
|
||||||
|
struct OpenCompletion {
|
||||||
|
name: String,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `File > Open` request waiting for the glue (and the host) to answer.
|
||||||
|
struct PendingPick {
|
||||||
|
id: u64,
|
||||||
|
ticks: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static EMBEDDED: Cell<Option<bool>> = const { Cell::new(None) };
|
||||||
|
static NEXT_ID: Cell<u64> = const { Cell::new(1) };
|
||||||
|
static PENDING: RefCell<Option<PendingPick>> = const { RefCell::new(None) };
|
||||||
|
static COMPLETED: RefCell<Vec<OpenCompletion>> = const { RefCell::new(Vec::new()) };
|
||||||
|
static LISTENER_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||||
|
static LISTENER_CLOSURE: RefCell<Option<Closure<dyn FnMut(JsValue)>>> =
|
||||||
|
const { RefCell::new(None) };
|
||||||
|
static ANNOUNCED: Cell<bool> = const { Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the studio runs inside the OneCAD Draft Odoo iframe.
|
||||||
|
///
|
||||||
|
/// Same-origin frame whose parent lives under the Odoo shell paths, or a
|
||||||
|
/// `?odoo-host=1` escape hatch for testing. Cached after the first probe.
|
||||||
|
pub fn embedded() -> bool {
|
||||||
|
EMBEDDED.with(|cell| {
|
||||||
|
if let Some(v) = cell.get() {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
let v = detect_embedded();
|
||||||
|
cell.set(Some(v));
|
||||||
|
v
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_embedded() -> bool {
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Forced embedding (testing / unusual host layouts).
|
||||||
|
if let Ok(search) = window.location().search() {
|
||||||
|
if search.contains("odoo-host=1") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Framed? (a top-level window is its own parent)
|
||||||
|
let Ok(Some(parent)) = window.parent() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let parent_val: &JsValue = parent.as_ref();
|
||||||
|
let window_val: &JsValue = window.as_ref();
|
||||||
|
let framed = parent_val != window_val;
|
||||||
|
if !framed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Same-origin parent under the Odoo shell? A cross-origin parent throws
|
||||||
|
// on `location.pathname` access, which reads as "not our host".
|
||||||
|
let path = match parent.location().pathname() {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
path.starts_with("/odoo") || path.starts_with("/web")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route an in-app `File > Open` through the host's Documents explorer.
|
||||||
|
///
|
||||||
|
/// Called from `on_open_file`'s wasm32 arm instead of the browser picker.
|
||||||
|
/// Returns `true` when the request is handled by the host (the caller must
|
||||||
|
/// then not open the native browser picker), `false` when the app is not
|
||||||
|
/// embedded and the stock picker should run.
|
||||||
|
pub fn intercept_open() -> bool {
|
||||||
|
if !embedded() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
announce();
|
||||||
|
install_listener();
|
||||||
|
let already_pending = PENDING.with(|slot| {
|
||||||
|
let mut slot = slot.borrow_mut();
|
||||||
|
if slot.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let id = NEXT_ID.with(|n| {
|
||||||
|
let id = n.get();
|
||||||
|
n.set(id + 1);
|
||||||
|
id
|
||||||
|
});
|
||||||
|
*slot = Some(PendingPick { id, ticks: 0 });
|
||||||
|
false
|
||||||
|
});
|
||||||
|
if already_pending {
|
||||||
|
// A pick dialog is already open on the host; swallow the repeat.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id)));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll-side hook: retries pending pick requests while the glue boots and
|
||||||
|
/// pops the next completed open, if any. Runs once per `PollWebControl`
|
||||||
|
/// (every 50 ms on web).
|
||||||
|
pub fn take_completed_open() -> Option<(String, Vec<u8>)> {
|
||||||
|
if !embedded() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Retry / expire the pending pick request.
|
||||||
|
let fire = PENDING.with(|slot| {
|
||||||
|
let mut slot = slot.borrow_mut();
|
||||||
|
let Some(pending) = slot.as_mut() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
pending.ticks += 1;
|
||||||
|
if pending.ticks > HOST_DEADLINE_TICKS {
|
||||||
|
warn_log("OneCAD Draft: Odoo shell never answered the open request; giving up.");
|
||||||
|
*slot = None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
pending.ticks % RETRY_EVERY_TICKS == 0
|
||||||
|
});
|
||||||
|
if fire {
|
||||||
|
dispatch_pick_request(PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id)));
|
||||||
|
}
|
||||||
|
COMPLETED.with(|queue| queue.borrow_mut().pop()).map(|o| (o.name, o.bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch `odoo.pickOpen` to the glue (silently skipped until the glue
|
||||||
|
/// signals `window.onecaddraftHostReady`).
|
||||||
|
fn dispatch_pick_request(id: Option<u64>) {
|
||||||
|
let Some(id) = id else { return };
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !host_ready(&window) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let detail = js_sys::Object::new();
|
||||||
|
let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("id"), &JsValue::from(id));
|
||||||
|
let _ = js_sys::Reflect::set(
|
||||||
|
&detail,
|
||||||
|
&JsValue::from_str("method"),
|
||||||
|
&JsValue::from_str(PICK_METHOD),
|
||||||
|
);
|
||||||
|
let payload = js_sys::Object::new();
|
||||||
|
let extensions = js_sys::Array::new();
|
||||||
|
for ext in PICK_EXTENSIONS {
|
||||||
|
extensions.push(&JsValue::from_str(ext));
|
||||||
|
}
|
||||||
|
let _ = js_sys::Reflect::set(&payload, &JsValue::from_str("extensions"), &extensions.into());
|
||||||
|
let _ = js_sys::Reflect::set(&detail, &JsValue::from_str("payload"), &payload.into());
|
||||||
|
|
||||||
|
let init = web_sys::CustomEventInit::new();
|
||||||
|
init.set_detail(&detail.into());
|
||||||
|
if let Ok(event) = web_sys::CustomEvent::new_with_event_init_dict(REQUEST_EVENT, &init) {
|
||||||
|
let _ = window.dispatch_event(&event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach the `onecaddraft:host-response` listener exactly once.
|
||||||
|
fn install_listener() {
|
||||||
|
if LISTENER_INSTALLED.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(window) = web_sys::window() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let closure = Closure::<dyn FnMut(JsValue)>::new(|event: JsValue| handle_response(event));
|
||||||
|
let target: &web_sys::EventTarget = window.unchecked_ref();
|
||||||
|
let _ = target.add_event_listener_with_callback(
|
||||||
|
RESPONSE_EVENT,
|
||||||
|
closure.as_ref().unchecked_ref(),
|
||||||
|
);
|
||||||
|
LISTENER_CLOSURE.with(|cell| {
|
||||||
|
cell.borrow_mut().replace(closure);
|
||||||
|
});
|
||||||
|
LISTENER_INSTALLED.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume one glue response for an outstanding `odoo.pickOpen` request.
|
||||||
|
fn handle_response(event: JsValue) {
|
||||||
|
let Ok(event) = event.dyn_into::<web_sys::CustomEvent>() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let detail = event.detail();
|
||||||
|
let key = JsValue::from_str("id");
|
||||||
|
let Some(id) = js_sys::Reflect::get(&detail, &key)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_f64())
|
||||||
|
.map(|f| f as u64)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Only consume responses matching OUR pending request: the glue answers
|
||||||
|
// every request exactly once, and nothing else shares this channel.
|
||||||
|
let ours = PENDING.with(|slot| slot.borrow().as_ref().map(|p| p.id) == Some(id));
|
||||||
|
if !ours {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PENDING.with(|slot| *slot.borrow_mut() = None);
|
||||||
|
|
||||||
|
let ok = js_sys::Reflect::get(&detail, &JsValue::from_str("ok"))
|
||||||
|
.map(|v| v.is_truthy())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let result = js_sys::Reflect::get(&detail, &JsValue::from_str("result"))
|
||||||
|
.unwrap_or(JsValue::UNDEFINED);
|
||||||
|
if !ok || !result.is_object() {
|
||||||
|
// `null` result = user cancelled in the Documents explorer.
|
||||||
|
info_log("OneCAD Draft: open cancelled in the Documents explorer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let name = js_sys::Reflect::get(&result, &JsValue::from_str("name"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_string());
|
||||||
|
let bytes_value =
|
||||||
|
js_sys::Reflect::get(&result, &JsValue::from_str("bytes")).unwrap_or(JsValue::UNDEFINED);
|
||||||
|
let bytes: Option<Vec<u8>> = if let Ok(array) = bytes_value.dyn_into::<js_sys::Uint8Array>() {
|
||||||
|
Some(array.to_vec())
|
||||||
|
} else {
|
||||||
|
// Fallback: base64 `content` (kept from the family wire contract).
|
||||||
|
let content = js_sys::Reflect::get(&result, &JsValue::from_str("content"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_string());
|
||||||
|
content.and_then(|c| base64::engine::general_purpose::STANDARD.decode(c).ok())
|
||||||
|
};
|
||||||
|
match (name, bytes) {
|
||||||
|
(Some(name), Some(bytes)) => {
|
||||||
|
COMPLETED.with(|queue| {
|
||||||
|
queue.borrow_mut().push(OpenCompletion { name, bytes });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => warn_log("OneCAD Draft: malformed open response from the Odoo shell."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_ready(window: &web_sys::Window) -> bool {
|
||||||
|
js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("onecaddraftHostReady"))
|
||||||
|
.map(|v| v.is_truthy())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn announce() {
|
||||||
|
if ANNOUNCED.get() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ANNOUNCED.set(true);
|
||||||
|
info_log("OneCAD Draft: Odoo shell detected — File > Open is served by Documents.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_log(message: &str) {
|
||||||
|
web_sys::console::log_1(&JsValue::from_str(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_log(message: &str) {
|
||||||
|
web_sys::console::warn_1(&JsValue::from_str(message));
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# OneCAD Draft — Odoo Documents glue (`web/odoo/`)
|
||||||
|
|
||||||
|
Integration contract for the addon assembler and future maintainers. This trio
|
||||||
|
is the Odoo Documents host glue for the OpenCADStudio fork (`one_cad_draft`
|
||||||
|
addon, wasm_app_framework iframe). It is a structural 1:1 port of the proven
|
||||||
|
FieldCraft glue (`OneCAD-Simulation/apps/fieldcraft-web/src/odoo/` — see
|
||||||
|
worklog tasks 4-c / 6-b), adapted to this app's iced/wasm seams. The host wire
|
||||||
|
protocol is **unchanged** from the family contract.
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|---|---|
|
||||||
|
| `index.ts` | Boot glue: bridge singleton + explorer wiring, host ping gate, `?document=` deep link, wasm-module resolver, `host-ready` signalling. |
|
||||||
|
| `bridge.ts` | Bridge client (postMessage to the framework shell) + Rust facade (CustomEvents) + save air-gap hook + control-channel helpers. |
|
||||||
|
| `explorer.js` | Vanilla-JS Documents explorer dialogs (`save` / `open` / `export`; `asset` kept for the family contract, not surfaced in v1). Plain JS — never compiled. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Version constants (bump in LOCKSTEP)
|
||||||
|
|
||||||
|
```
|
||||||
|
ODOO_GLUE_VERSION (bridge.ts) = "20.0.1.0.0"
|
||||||
|
?v= literal in index.ts = "./bridge.js?v=20.0.1.0.0", "./explorer.js?v=20.0.1.0.0"
|
||||||
|
?v= literal in explorer.js = "./bridge.js?v=20.0.1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
Law (family precedent — the 7-day Odoo static max-age makes stale-bundle
|
||||||
|
failures real): every build bumps **all** of
|
||||||
|
|
||||||
|
1. `ODOO_GLUE_VERSION` and the `?v=` literals inside `index.ts` / `explorer.js`
|
||||||
|
(they cannot reference the const — import specifiers are static);
|
||||||
|
2. the `?v=<VERSION>` in the index.html injection snippet below;
|
||||||
|
3. the addon's `bundleVersion` (registration JS) and `__manifest__.py` version.
|
||||||
|
|
||||||
|
The assembler owns the substitution (sed-style, one value everywhere). `bridge.ts`
|
||||||
|
exports `ODOO_GLUE_VERSION` as the single source of truth for docs/tooling.
|
||||||
|
|
||||||
|
## 2. index.html injection snippet (assembler; exact)
|
||||||
|
|
||||||
|
Inject into the app's `index.html` (built from `web-app.html`) **after the
|
||||||
|
loading markup** (after the `#loading` div / splash scripts), inside `<body>`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="module">
|
||||||
|
try { if (window.parent !== window) { import("./odoo/index.js?v=<VERSION>").catch((e) => console.warn("OneCAD Draft odoo bridge:", e)); } } catch (e) { /* standalone */ }
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
* The `window.parent !== window` guard keeps standalone/local runs glue-free
|
||||||
|
(native downloads, native pickers — the glue is inert by design there too,
|
||||||
|
but not loading it at all is cheaper).
|
||||||
|
* Replace `<VERSION>` with the current lockstep version (§1).
|
||||||
|
* The assembler also rewrites the built page for the addon bundle (strip the
|
||||||
|
service-worker registration, rewrite `/app/`-absolute asset links to
|
||||||
|
`/one_cad_draft/static/onecaddraft/`); the snippet must survive that rewrite
|
||||||
|
as a relative `./odoo/index.js` import.
|
||||||
|
* Compile step: `npx esbuild web/odoo/index.ts web/odoo/bridge.ts --format=esm
|
||||||
|
--target=es2022` (erasable-syntax-only TS; no tsc needed) → ship
|
||||||
|
`odoo/index.js`, `odoo/bridge.js`, `odoo/explorer.js` verbatim.
|
||||||
|
|
||||||
|
**Mount-order tolerance (required):** the glue may load after the wasm boots
|
||||||
|
(iced boots fast) or before it. The bridge handles glue-first (Rust polls
|
||||||
|
`window.onecaddraftHostReady`); the deep-link resolver in `index.ts` handles
|
||||||
|
wasm-first/late by polling the wasm entry points for up to 30 s (§5).
|
||||||
|
|
||||||
|
## 3. Window API surface
|
||||||
|
|
||||||
|
### 3.1 Glue ⇄ Odoo shell (postMessage, same-origin parent)
|
||||||
|
|
||||||
|
```
|
||||||
|
request : {source:"one-cad-draft", kind:"odoo-documents", id, method, payload}
|
||||||
|
reply : {source:"one-cad-draft-host", kind:"odoo-documents", id, ok, result|error:{message}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Host methods (implemented by `wasm_app_action.js`): `ping list read write
|
||||||
|
delete usage folders createFolder listFolder saveExport stat`.
|
||||||
|
Bulk methods (`read write saveExport`) use a 300 s timeout; all others 60 s.
|
||||||
|
128 MB raw-byte cap enforced client-side before base64 (host mirrors it).
|
||||||
|
|
||||||
|
### 3.2 Rust facade (window CustomEvents — the Rust odoo bridge module)
|
||||||
|
|
||||||
|
| Event | Direction | Detail |
|
||||||
|
|---|---|---|
|
||||||
|
| `onecaddraft:host-request` | Rust → glue | `{id:number, method:string, payload?:object}` — the glue also accepts `params` (alias, kept from the FieldCraft wire). Known host methods pass through; orchestration methods: `saveAs`, `open`, `export`, `importAsset`; Rust-only methods: `odoo.pickOpen`, `odoo.pickProgram`, `odoo.pickSave`, `odoo.write` (`/odoo/doc/<rid>/<name>` in place, `/odoo/pending/<token>/<name>` first save), `odoo.saveExport`, `odoo.import`. FIFO, one at a time, every id answered exactly once. |
|
||||||
|
| `onecaddraft:host-response` | glue → Rust | `{id, ok, result?, error?{message}}` |
|
||||||
|
| `onecaddraft:host-ready` | glue → Rust | `{host:boolean, documentId, name}` — fired once after the ping gate and the deep-link read. |
|
||||||
|
| `onecaddraft:document-opened` | glue → Rust | `{documentId, name, dataB64}` — deep-link record read. |
|
||||||
|
|
||||||
|
**Ready flag law (keep exactly):** the GLUE sets
|
||||||
|
`window.onecaddraftHostReady = true` once its `host-response` listener is
|
||||||
|
attached and the boot attempt finished (host answered or timed out) — BEFORE
|
||||||
|
deep-link consumption. The Rust side POLLS/RETRIES until the flag is set; it
|
||||||
|
must never set or clear it. Late-booting Rust reads the same state
|
||||||
|
synchronously at `window.onecaddraftHost.{ready, host, documentId, name}` plus
|
||||||
|
`consumePendingOpen()` (deep-link payload, once → null).
|
||||||
|
|
||||||
|
`window.onecaddraft` (reserved): `request("odoo.document.bytes" |
|
||||||
|
"odoo.document.info")` facade the bridge calls when a flow arrives without
|
||||||
|
bytes. v1 does not install it — the bridge falls back to the control channel
|
||||||
|
(§5). `window.onecaddraftOdoo.bootFile` (reserved): FieldCraft's Rust staging
|
||||||
|
seam; not installed here, the resolver never depends on it.
|
||||||
|
|
||||||
|
### 3.3 Save air gap — `window.openCadStudioSaveFile(name, bytes)`
|
||||||
|
|
||||||
|
Registered by `bridge.ts` (constructor, in-frame only). The wasm app already
|
||||||
|
checks it first: `src/sys.rs::download_bytes` (line 723) calls
|
||||||
|
`save_hook().call2(&window, &name, &Uint8Array)` and returns before the
|
||||||
|
`<a download>` path, and `src/app/update/file.rs:4154` disables the
|
||||||
|
File System Access picker while the hook exists. So while the glue is loaded
|
||||||
|
in the iframe, **bytes can never reach a browser download** — Save, Save As,
|
||||||
|
save-before-close and every export (PDF/CSV/PNG/ZIP/…) arrive here.
|
||||||
|
|
||||||
|
Routing (bytes are ALWAYS consumed; failures are logged, never downloaded):
|
||||||
|
|
||||||
|
| Case | Action |
|
||||||
|
|---|---|
|
||||||
|
| `.dxf`/`.dwg` name, bound record (`localStorage` bind) | in-place write-back `write {recordId, name}`; bind refreshed. |
|
||||||
|
| `.dxf`/`.dwg` name, unbound | explorer SAVE dialog → `write {name, folderId}` (Projects default via the explorer) → bind → future saves take the write-back path. Cancel = bytes dropped with a console note. |
|
||||||
|
| anything else (obj/stl/pdf/csv/png/zip/…) | `saveExport` → Documents **Exports** folder. |
|
||||||
|
|
||||||
|
Hook saves are serialized through one promise chain (no stacked dialogs).
|
||||||
|
Over-cap (>128 MB raw) saves are dropped with a warning (family cap law).
|
||||||
|
|
||||||
|
### 3.4 App entry points used by the glue (verified in src)
|
||||||
|
|
||||||
|
| Seam | Where | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| `wasmBindings.ocs_web_drop_file(name, bytes)` — primary | `src/io/web_drop.rs:116` (`#[wasm_bindgen]`); Trunk publishes the module as `window.wasmBindings` (`docs/automation/web.md`) | deep-link open staging. Binary-safe; drained by the 50 ms `Message::PollWebControl` poll guarded by `opening.is_none()` (`src/app/update/mod.rs:877`). A plain `window.ocs_web_drop_file` global is accepted as an alias. |
|
||||||
|
| `wasmBindings.ocs_control_submit/take` — fallback | `src/app/control/mod.rs:72/89` | deep-link fallback `{"op":"open","name","data_base64"}` (follows `accepted`/`running` via `{"op":"operation","request_id"}`, retries `code:"busy"`; 60 s) and the no-bytes serialization fallback `{"op":"save","allow_lossy":true}` → reply `data_base64` (`mod.rs:1243`). |
|
||||||
|
| `window.openCadStudioSaveFile(name, bytes)` | `src/sys.rs:757 save_hook()` | save air gap (§3.3). |
|
||||||
|
|
||||||
|
## 4. Deep link
|
||||||
|
|
||||||
|
The framework opens the iframe with `?document=<recordId>` (plus its `?v=`
|
||||||
|
cache buster). Boot order (ordering law, unchanged from FieldCraft):
|
||||||
|
|
||||||
|
1. `waitForHost()` (ping ×100 ms, 20 s) → host up or standalone;
|
||||||
|
2. `markHostReady()` — unblocks the Rust side first;
|
||||||
|
3. `bridge.openDeepLink()` reads the record (300 s), stores `pendingOpen`,
|
||||||
|
fires `onecaddraft:document-opened`, refreshes the binding;
|
||||||
|
4. `host-ready` event + `window.onecaddraftHost` state;
|
||||||
|
5. the staged bytes go through the resolver (§5: drop entry, else control
|
||||||
|
channel), then `?document=` is cleared from the iframe URL
|
||||||
|
(`history.replaceState`; `?v=` and any other params survive).
|
||||||
|
|
||||||
|
## 5. Resolver / mount-order tolerance
|
||||||
|
|
||||||
|
`stageDrawing(name, bytes)` polls for the wasm module (drop entry OR control
|
||||||
|
pair) every 100 ms up to **30 s** — covers wasm-before-glue and
|
||||||
|
glue-before-wasm — then:
|
||||||
|
|
||||||
|
1. `wasmBindings.ocs_web_drop_file(name, bytes)` (or the `window.` global
|
||||||
|
alias) — primary, binary-safe;
|
||||||
|
2. else control `{"op":"open", name, data_base64}` — base64 path, `busy`
|
||||||
|
retried until the deadline.
|
||||||
|
|
||||||
|
Bytes are consumed by whichever path wins; the glue logs and gives up (never
|
||||||
|
downloads) if neither appears within 30 s.
|
||||||
|
|
||||||
|
## 6. localStorage keys
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `one-cad-draft:draft:bind:<stem>` | document name (`.dxf`/`.dwg` stripped) → `{recordId, name, updatedAt}` — write-back binding. |
|
||||||
|
| `one-cad-draft:documents:last-folder:<mode>` | explorer last folder per mode (`save`/`open`/`asset`/`export`). |
|
||||||
|
|
||||||
|
## 7. Explorer
|
||||||
|
|
||||||
|
Injected one-`<style>` dark CAD palette (`ocd-explorer-style`, `--ocd-*`
|
||||||
|
vars, `ocd-*` classes), dialog over the canvas. Modes: `save` (tree + name,
|
||||||
|
`.dxf` chip), `open` (whole drive, files filtered to `dxf`/`dwg`),
|
||||||
|
`export` (name → Exports). `asset` stays in code for the family contract but
|
||||||
|
is not surfaced in v1 (its filter set mirrors the Rust asset exts:
|
||||||
|
`obj`, `landxml` — LANDXMLIMPORT is wired in `src/app/commands/display.rs`).
|
||||||
|
|
||||||
|
## 8. Assembler checklist
|
||||||
|
|
||||||
|
1. esbuild-compile `index.ts`/`bridge.ts` (esm, es2022) next to `explorer.js`.
|
||||||
|
2. Bump `ODOO_GLUE_VERSION` + `?v=` literals + injection snippet +
|
||||||
|
`bundleVersion` + manifest version (§1).
|
||||||
|
3. Rewrite the built `index.html` (strip SW registration, `/app/` links) and
|
||||||
|
inject the §2 snippet after the loading markup.
|
||||||
|
4. Verify gate: no absolute refs, no SW, `odoo/index.js` + `odoo/bridge.js` +
|
||||||
|
`odoo/explorer.js` present in `static/onecaddraft/odoo/`.
|
||||||
|
5. Rust side (main agent's odoo bridge module) must: dispatch
|
||||||
|
`onecaddraft:host-request` (payload or params), listen for
|
||||||
|
`onecaddraft:host-response`, poll `window.onecaddraftHostReady` until true
|
||||||
|
before dispatching `odoo.*` methods, and never set the flag itself.
|
||||||
|
|
||||||
|
## 9. Deviations from the FieldCraft glue (all intentional)
|
||||||
|
|
||||||
|
1. **Save flow replaces `services_odoo.rs`'s take_writes drain**: this app has
|
||||||
|
no Rust services layer; `sys.rs::download_bytes` already honors a page
|
||||||
|
hook, so the air gap is the `openCadStudioSaveFile` bridge hook (§3.3).
|
||||||
|
2. **Deep-link staging replaces `onecadsimOdoo.bootFile` + `ui.file`**: no
|
||||||
|
such Rust seams exist here; the resolver uses `ocs_web_drop_file`
|
||||||
|
(primary) / control `open` (fallback) and clears `?document=` afterwards.
|
||||||
|
3. **`appSerialize` control-channel fallback**: `window.onecaddraft` is not
|
||||||
|
installed in v1, so the no-bytes save/export path serializes via control
|
||||||
|
`{"op":"save","allow_lossy":true}` (reply `data_base64`).
|
||||||
|
4. **Extensions/identity**: `.fieldcraft`→`.dxf`/`.dwg` ownership,
|
||||||
|
`RUST_ASSET_EXTS = ["obj","landxml"]` (landxml import is wired in src),
|
||||||
|
bind prefix `one-cad-draft:draft:bind:`, tags `one-cad-draft(-host)`,
|
||||||
|
events `onecaddraft:*`, style prefix `ocd-`.
|
||||||
|
5. **`ODOO_GLUE_VERSION` const** added (docs/tooling anchor; import
|
||||||
|
specifiers stay literal for the assembler's lockstep sed).
|
||||||
+1181
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,720 @@
|
|||||||
|
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||||
|
// OneCAD Draft Odoo Documents glue (ported from the OneCAD Simulation glue —
|
||||||
|
// apps/fieldcraft-web/src/odoo/explorer.js; layout and interactions unchanged).
|
||||||
|
/**
|
||||||
|
* OneCAD Draft 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 iced WebGL2 canvas. Four dialogs, one explorer:
|
||||||
|
*
|
||||||
|
* save — "Save to Documents": folder tree + name input (.dxf chip)
|
||||||
|
* open — "Open from Documents": folder tree + files filtered to .dxf/.dwg
|
||||||
|
* asset — "Import asset": file picker -> Assets folder (+ retarget)
|
||||||
|
* (kept for the family contract; v1 flows never open it)
|
||||||
|
* export — "Export": name input -> Exports folder
|
||||||
|
*
|
||||||
|
* left — My Drive folder tree (recursive, expandable, usage footer;
|
||||||
|
* open mode lists every Documents folder — Projects, Assets,
|
||||||
|
* Exports — so the whole drive is browsable)
|
||||||
|
* 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.0";
|
||||||
|
|
||||||
|
const STYLE_ID = "ocd-explorer-style";
|
||||||
|
|
||||||
|
const MODES = {
|
||||||
|
save: {
|
||||||
|
heading: "Save to Documents",
|
||||||
|
description: "Choose a folder in your Odoo drive for this .dxf / .dwg drawing.",
|
||||||
|
action: "Save",
|
||||||
|
prefer: "root",
|
||||||
|
lastKey: "one-cad-draft:documents:last-folder:save",
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
heading: "Open from Documents",
|
||||||
|
description: "Browse your Odoo Documents drive and pick a .dxf or .dwg drawing.",
|
||||||
|
action: "Open",
|
||||||
|
prefer: "root",
|
||||||
|
lastKey: "one-cad-draft:documents:last-folder:open",
|
||||||
|
extensions: new Set(["dxf", "dwg"]),
|
||||||
|
},
|
||||||
|
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-draft:documents:last-folder:asset",
|
||||||
|
extensions: new Set(["obj", "landxml"]),
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
heading: "Export",
|
||||||
|
description: "Name the export — it lands in your Documents Exports folder.",
|
||||||
|
action: "Export",
|
||||||
|
prefer: "exports",
|
||||||
|
lastKey: "one-cad-draft: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{--ocd-bg:#1b1e24;--ocd-panel:#23272f;--ocd-panel2:#2a2f39;--ocd-hairline:#333945;",
|
||||||
|
"--ocd-text:#e8eaed;--ocd-dim:#9aa3ad;--ocd-accent:#4f8cff;--ocd-accent-press:#3f76e0;",
|
||||||
|
"--ocd-danger:#e5534b;--ocd-ok:#3fb26f}",
|
||||||
|
".ocd-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(--ocd-text)}",
|
||||||
|
".ocd-backdrop{position:absolute;inset:0;background:rgba(8,10,14,.62)}",
|
||||||
|
".ocd-dialog{position:relative;width:min(860px,calc(100vw - 48px));height:min(560px,calc(100vh - 64px));",
|
||||||
|
"background:var(--ocd-bg);border:1px solid var(--ocd-hairline);border-radius:10px;",
|
||||||
|
"box-shadow:0 24px 64px rgba(0,0,0,.55);display:flex;flex-direction:column;overflow:hidden}",
|
||||||
|
".ocd-header{padding:14px 18px 10px;border-bottom:1px solid var(--ocd-hairline);background:var(--ocd-panel)}",
|
||||||
|
".ocd-title{font-size:15px;font-weight:600}",
|
||||||
|
".ocd-desc{margin-top:2px;color:var(--ocd-dim);font-size:12px}",
|
||||||
|
".ocd-desc-error{color:var(--ocd-danger)}",
|
||||||
|
".ocd-body{flex:1;display:flex;min-height:0}",
|
||||||
|
".ocd-aside{width:250px;flex:none;display:flex;flex-direction:column;border-right:1px solid var(--ocd-hairline);background:var(--ocd-panel)}",
|
||||||
|
".ocd-tree-box{flex:1;overflow:auto;padding:8px 6px}",
|
||||||
|
".ocd-tree-label{margin:2px 8px 6px;color:var(--ocd-dim);font-size:11px;text-transform:uppercase;letter-spacing:.06em}",
|
||||||
|
".ocd-tree-error{padding:8px}",
|
||||||
|
".ocd-tree-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:6px;cursor:pointer;white-space:nowrap}",
|
||||||
|
".ocd-tree-row:hover{background:var(--ocd-panel2)}",
|
||||||
|
".ocd-tree-row.ocd-active{background:var(--ocd-panel2);color:#fff}",
|
||||||
|
".ocd-tree-toggle{width:14px;height:14px;flex:none;color:var(--ocd-dim);display:inline-flex;transition:transform .12s}",
|
||||||
|
".ocd-tree-toggle.ocd-open{transform:rotate(90deg)}",
|
||||||
|
".ocd-tree-icon{display:inline-flex;color:var(--ocd-accent)}",
|
||||||
|
".ocd-tree-name{overflow:hidden;text-overflow:ellipsis}",
|
||||||
|
".ocd-usage{padding:8px 14px;border-top:1px solid var(--ocd-hairline);color:var(--ocd-dim);font-size:11px}",
|
||||||
|
".ocd-main{flex:1;display:flex;flex-direction:column;min-width:0}",
|
||||||
|
".ocd-crumbs{display:flex;align-items:center;gap:2px;padding:8px 12px;border-bottom:1px solid var(--ocd-hairline);overflow:hidden}",
|
||||||
|
".ocd-crumb{display:inline-flex;align-items:center;gap:5px;background:none;border:0;color:var(--ocd-dim);",
|
||||||
|
"font:inherit;padding:4px 7px;border-radius:6px;cursor:pointer}",
|
||||||
|
".ocd-crumb:hover:not(:disabled){background:var(--ocd-panel2);color:var(--ocd-text)}",
|
||||||
|
".ocd-crumb:disabled{opacity:.45;cursor:default}",
|
||||||
|
".ocd-crumb-current{color:var(--ocd-text)}",
|
||||||
|
".ocd-crumb-sep{display:inline-flex;color:var(--ocd-dim)}",
|
||||||
|
".ocd-files{flex:1;overflow:auto;padding:6px 8px}",
|
||||||
|
".ocd-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}",
|
||||||
|
".ocd-file-row:hover{background:var(--ocd-panel2)}",
|
||||||
|
".ocd-file-row.ocd-selected{background:rgba(79,140,255,.18);outline:1px solid var(--ocd-accent)}",
|
||||||
|
".ocd-file-icon{display:inline-flex;color:var(--ocd-dim)}",
|
||||||
|
".ocd-file-icon.ocd-icon-owned{color:var(--ocd-accent)}",
|
||||||
|
".ocd-file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||||
|
".ocd-file-size{color:var(--ocd-dim);font-size:11px;flex:none}",
|
||||||
|
".ocd-empty{color:var(--ocd-dim);padding:18px 10px;text-align:center}",
|
||||||
|
".ocd-savebar{border-top:1px solid var(--ocd-hairline);padding:10px 14px;background:var(--ocd-panel)}",
|
||||||
|
".ocd-name-row{display:flex;align-items:flex-end;gap:10px}",
|
||||||
|
".ocd-name-wrap{flex:1;min-width:0}",
|
||||||
|
".ocd-name-label{display:block;color:var(--ocd-dim);font-size:11px;margin-bottom:4px}",
|
||||||
|
".ocd-name-field{display:flex;align-items:center;background:var(--ocd-bg);border:1px solid var(--ocd-hairline);border-radius:6px;padding:0 8px}",
|
||||||
|
".ocd-name-field:focus-within{border-color:var(--ocd-accent)}",
|
||||||
|
".ocd-name-input{flex:1;min-width:0;background:none;border:0;outline:0;color:var(--ocd-text);font:inherit;padding:7px 0}",
|
||||||
|
".ocd-suffix{color:var(--ocd-dim);font-size:12px;user-select:none}",
|
||||||
|
".ocd-create-row{display:flex;gap:8px;margin-top:8px}",
|
||||||
|
".ocd-newfolder-input{flex:1;background:var(--ocd-bg);border:1px solid var(--ocd-hairline);border-radius:6px;",
|
||||||
|
"color:var(--ocd-text);font:inherit;padding:6px 9px;outline:0}",
|
||||||
|
".ocd-newfolder-input:focus{border-color:var(--ocd-accent)}",
|
||||||
|
".ocd-picker{display:flex;flex-direction:column;gap:8px}",
|
||||||
|
".ocd-pick-btn{display:inline-flex;align-items:center;gap:8px}",
|
||||||
|
".ocd-picked{color:var(--ocd-dim);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
|
||||||
|
".ocd-footer{display:flex;justify-content:space-between;align-items:center;padding:10px 14px;",
|
||||||
|
"border-top:1px solid var(--ocd-hairline);background:var(--ocd-panel)}",
|
||||||
|
".ocd-btn{display:inline-flex;align-items:center;gap:6px;font:inherit;padding:7px 14px;border-radius:6px;",
|
||||||
|
"border:1px solid var(--ocd-hairline);background:var(--ocd-panel2);color:var(--ocd-text);cursor:pointer}",
|
||||||
|
".ocd-btn:hover:not(:disabled){border-color:var(--ocd-dim)}",
|
||||||
|
".ocd-btn:disabled{opacity:.45;cursor:default}",
|
||||||
|
".ocd-btn-accent{background:var(--ocd-accent);border-color:var(--ocd-accent);color:#fff}",
|
||||||
|
".ocd-btn-accent:hover:not(:disabled){background:var(--ocd-accent-press)}",
|
||||||
|
".ocd-btn-ghost{background:none;border-color:transparent;color:var(--ocd-dim)}",
|
||||||
|
".ocd-btn-ghost:hover:not(:disabled){color:var(--ocd-text)}",
|
||||||
|
".ocd-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="ocd-file-icon ocd-icon-owned">' + ICONS.cube + "</span>";
|
||||||
|
}
|
||||||
|
return '<span class="ocd-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.dxf")
|
||||||
|
* 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("[onecaddraft-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" ? ".dxf" : "";
|
||||||
|
let defaultName = typeof opts.defaultName === "string" && opts.defaultName.trim() !== ""
|
||||||
|
? opts.defaultName.trim()
|
||||||
|
: mode === "save"
|
||||||
|
? "untitled.dxf"
|
||||||
|
: "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: "ocd-backdrop" });
|
||||||
|
const dialog = h("div", { class: "ocd-dialog", role: "dialog", "aria-modal": "true" });
|
||||||
|
const root = h("div", { class: "ocd-explorer-root" }, backdrop, dialog);
|
||||||
|
|
||||||
|
const descEl = h("div", { class: "ocd-desc" }, config.description);
|
||||||
|
const header = h("div", { class: "ocd-header" },
|
||||||
|
h("div", { class: "ocd-title" }, config.heading), descEl);
|
||||||
|
|
||||||
|
const treeLabel = h("p", { class: "ocd-tree-label" }, "Loading drive…");
|
||||||
|
const treeBox = h("div", { class: "ocd-tree-box" }, treeLabel);
|
||||||
|
const usageEl = h("div", { class: "ocd-usage", style: "display:none" });
|
||||||
|
const aside = h("div", { class: "ocd-aside" }, treeBox, usageEl);
|
||||||
|
|
||||||
|
const crumbs = h("div", { class: "ocd-crumbs" });
|
||||||
|
const fileList = h("div", { class: "ocd-files" });
|
||||||
|
const main = h("div", { class: "ocd-main" }, crumbs, fileList);
|
||||||
|
const body = h("div", { class: "ocd-body" }, aside, main);
|
||||||
|
|
||||||
|
/* name row (save + export) */
|
||||||
|
const nameInput = h("input", {
|
||||||
|
class: "ocd-name-input",
|
||||||
|
id: "onecaddraft-explorer-name",
|
||||||
|
type: "text",
|
||||||
|
value: stem,
|
||||||
|
placeholder: mode === "save" ? "Untitled" : "export",
|
||||||
|
spellcheck: "false",
|
||||||
|
});
|
||||||
|
const suffixChip = h("span", { class: "ocd-suffix" }, suffix);
|
||||||
|
const nameField = h("div", { class: "ocd-name-field" }, nameInput, suffixChip);
|
||||||
|
const nameRow = h("div", { class: "ocd-name-row" },
|
||||||
|
h("div", { class: "ocd-name-wrap" },
|
||||||
|
h("label", { class: "ocd-name-label", for: "onecaddraft-explorer-name" }, "File name"),
|
||||||
|
nameField));
|
||||||
|
|
||||||
|
/* new-folder row (save + export; asset keeps it too — retargeting) */
|
||||||
|
const newNameInput = h("input", {
|
||||||
|
class: "ocd-newfolder-input",
|
||||||
|
type: "text",
|
||||||
|
placeholder: "Folder name…",
|
||||||
|
spellcheck: "false",
|
||||||
|
});
|
||||||
|
const createBtn = h("button", { class: "ocd-btn ocd-btn-accent", type: "button" }, "Create");
|
||||||
|
const cancelCreateBtn = h("button", { class: "ocd-btn ocd-btn-ghost", type: "button" }, "Cancel");
|
||||||
|
const createRow = h("div", { class: "ocd-create-row", style: "display:none" }, newNameInput, createBtn, cancelCreateBtn);
|
||||||
|
const newFolderBtn = h("button", { class: "ocd-btn", type: "button" },
|
||||||
|
h("span", { class: "ocd-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: "ocd-picked", text: "No file chosen yet." });
|
||||||
|
const pickBtn = h("button", { class: "ocd-btn ocd-btn-accent", type: "button" },
|
||||||
|
h("span", { class: "ocd-btn-icon", html: ICONS.upload }), "Choose file…");
|
||||||
|
const picker = h("div", { class: "ocd-picker" }, pickBtn, pickedLabel, fileInput);
|
||||||
|
|
||||||
|
const savebar = h("div", { class: "ocd-savebar" });
|
||||||
|
if (wantsName) savebar.appendChild(nameRow);
|
||||||
|
if (mode === "asset") savebar.appendChild(picker);
|
||||||
|
savebar.appendChild(newFolderBtn);
|
||||||
|
savebar.appendChild(createRow);
|
||||||
|
|
||||||
|
/* footer */
|
||||||
|
const cancelBtn = h("button", { class: "ocd-btn ocd-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: "ocd-btn ocd-btn-accent", type: "button" },
|
||||||
|
h("span", { class: "ocd-btn-icon", html: actionIcon }), config.action);
|
||||||
|
const footer = h("div", { class: "ocd-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: "ocd-tree-label" }, "Loading drive…"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (treeError) {
|
||||||
|
const retry = h("button", { class: "ocd-btn", type: "button" }, "Retry");
|
||||||
|
retry.addEventListener("click", () => boot());
|
||||||
|
treeBox.appendChild(h("div", { class: "ocd-tree-error" },
|
||||||
|
h("p", { class: "ocd-tree-label" }, treeError), retry));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
treeBox.appendChild(h("p", { class: "ocd-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: "ocd-tree-row" + (folder.id === currentFolderId ? " ocd-active" : ""),
|
||||||
|
style: "padding-left:" + (8 + depth * 14) + "px",
|
||||||
|
});
|
||||||
|
const toggle = h("span", { class: "ocd-tree-toggle" });
|
||||||
|
if (hasChildren) {
|
||||||
|
toggle.innerHTML = ICONS.chevronRight;
|
||||||
|
toggle.classList.add("ocd-has-children");
|
||||||
|
if (isOpen) toggle.classList.add("ocd-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: "ocd-tree-icon", html: ICONS.folder }));
|
||||||
|
row.appendChild(h("span", { class: "ocd-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: "ocd-crumb ocd-crumb-root", type: "button" });
|
||||||
|
driveBtn.appendChild(h("span", { class: "ocd-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: "ocd-crumb-sep", html: ICONS.chevronRight }));
|
||||||
|
const crumb = h("button", {
|
||||||
|
class: "ocd-crumb" + (folder.id === currentFolderId ? " ocd-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: "ocd-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: "ocd-file-row", type: "button" });
|
||||||
|
row.insertAdjacentHTML("beforeend", '<span class="ocd-file-icon ocd-icon-owned">' + ICONS.folder + "</span>");
|
||||||
|
row.appendChild(h("span", { class: "ocd-file-name" }, folder.name));
|
||||||
|
if (folder.system) {
|
||||||
|
row.insertAdjacentHTML("beforeend",
|
||||||
|
'<span class="ocd-file-size" title="Protected OneBSpace folder">' + ICONS.lock + "</span>");
|
||||||
|
}
|
||||||
|
row.appendChild(h("span", { class: "ocd-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: "ocd-empty" },
|
||||||
|
mode === "save" || mode === "export" ? "This folder is empty." : "No compatible drawings in this folder."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visibleFiles.length === 0) {
|
||||||
|
fileList.appendChild(h("p", { class: "ocd-empty" }, "No compatible drawings here yet."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const file of visibleFiles) {
|
||||||
|
const row = h("button", {
|
||||||
|
class: "ocd-file-row" + (selectedFileId === file.id ? " ocd-selected" : ""),
|
||||||
|
type: "button",
|
||||||
|
});
|
||||||
|
row.insertAdjacentHTML("beforeend", fileIconFor(file, extensions));
|
||||||
|
row.appendChild(h("span", { class: "ocd-file-name" }, file.name));
|
||||||
|
row.appendChild(h("span", { class: "ocd-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("ocd-desc-error");
|
||||||
|
if (errorTimer) clearTimeout(errorTimer);
|
||||||
|
errorTimer = setTimeout(() => {
|
||||||
|
descEl.textContent = config.description;
|
||||||
|
descEl.classList.remove("ocd-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,312 @@
|
|||||||
|
// (C) OneBSpace — https://1bspace.com — a 1B Global company. Proprietary.
|
||||||
|
// OneCAD Draft Odoo Documents glue (ported from the OneCAD Simulation glue —
|
||||||
|
// apps/fieldcraft-web/src/odoo/index.ts; boot ordering laws are unchanged).
|
||||||
|
/**
|
||||||
|
* OneCAD Draft odoo/index.ts — the boot glue (bridge <-> explorer <-> wasm app).
|
||||||
|
* ----------------------------------------------------------------------
|
||||||
|
* Entry module injected by the addon shell ONLY inside the
|
||||||
|
* wasm_app_framework iframe (standalone runs never import it — see the
|
||||||
|
* import guard documented in README-odoo.md). 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, "onecaddraft:document-opened");
|
||||||
|
* 3. signal "onecaddraft:host-ready" {host, documentId, name} exactly once.
|
||||||
|
* The same state stays readable synchronously at window.onecaddraftHost
|
||||||
|
* (late-booting Rust) together with consumePendingOpen(), which hands
|
||||||
|
* the deep-link payload {documentId, name, dataB64} over once.
|
||||||
|
*
|
||||||
|
* Deep-link staging (this app's adaptation): instead of a Rust-installed
|
||||||
|
* bootFile seam the record bytes are pushed into the app through its
|
||||||
|
* JS-accessible open entry points, whichever exists first —
|
||||||
|
* primary : wasmBindings.ocs_web_drop_file(name, bytes) (src/io/web_drop.rs;
|
||||||
|
* binary-safe, drained by the 50 ms Message::PollWebControl poll
|
||||||
|
* guarded by opening.is_none()) — with a window.ocs_web_drop_file
|
||||||
|
* global accepted as an alias;
|
||||||
|
* fallback: the control channel {"op":"open","name","data_base64"} via
|
||||||
|
* wasmBindings.ocs_control_submit/take (src/app/control/mod.rs),
|
||||||
|
* following accepted/running replies through op:"operation" and
|
||||||
|
* retrying the "busy" answer while another drawing opens.
|
||||||
|
* The wasm module can boot before OR after this glue (iced boots fast), so
|
||||||
|
* the resolver polls for it up to 30 s before giving up. On success the
|
||||||
|
* ?document= parameter is cleared from the iframe URL (the ?v= cache buster
|
||||||
|
* the framework adds is preserved).
|
||||||
|
*
|
||||||
|
* 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: erasable-syntax-only TS compiled by the OneCAD Draft addon
|
||||||
|
* build (esbuild) to odoo/index.js next to bridge.js; explorer.js is a
|
||||||
|
* plain-JS module copied verbatim. The "?v=" literals below are the glue
|
||||||
|
* version (ODOO_GLUE_VERSION in bridge.ts) and are bumped in lockstep with
|
||||||
|
* the addon's bundleVersion — see README-odoo.md. Exports nothing.
|
||||||
|
*/
|
||||||
|
// @ts-ignore — the ?v= cache-buster specifier is resolved by the esbuild
|
||||||
|
// build (and by the browser); tsc's bundler resolution cannot see it.
|
||||||
|
import { bytesToBase64, exportForHost, EVT_HOST_READY } from "./bridge.js?v=20.0.1.0.0";
|
||||||
|
// @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.0";
|
||||||
|
import type { DocumentOpenedDetail, ExplorerOpener, HostReadyDetail } from "./bridge.js";
|
||||||
|
|
||||||
|
const bridge = exportForHost();
|
||||||
|
bridge.setExplorerOpener(openDocumentsExplorer as ExplorerOpener);
|
||||||
|
|
||||||
|
/* Late-boot contract surface (Rust polls this synchronously-readable state
|
||||||
|
* until the glue flags itself ready; the glue — not Rust — owns the flag). */
|
||||||
|
interface OneCadHostWindow {
|
||||||
|
onecaddraftHostReady?: boolean;
|
||||||
|
onecaddraftHost?: {
|
||||||
|
ready: boolean;
|
||||||
|
host: boolean;
|
||||||
|
documentId: number | null;
|
||||||
|
name: string | null;
|
||||||
|
consumePendingOpen: () => DocumentOpenedDetail | null;
|
||||||
|
};
|
||||||
|
/** Reserved: the bridge's appSerialize/appInfo facade. OneCAD Draft v1
|
||||||
|
* does not install it on the wasm side (opens stage through the drop
|
||||||
|
* resolver below; saves flow through the bridge's save hook) — the
|
||||||
|
* bridge tolerates its absence. */
|
||||||
|
onecaddraft?: { request?: (method: string, params?: unknown) => Promise<unknown> };
|
||||||
|
/** Reserved: a future Rust-side staging seam (FieldCraft parity). Not
|
||||||
|
* installed by this app; the resolver never depends on it. */
|
||||||
|
onecaddraftOdoo?: {
|
||||||
|
bootFile?: (recordId: number, name: string, bytes: Uint8Array) => string | undefined;
|
||||||
|
};
|
||||||
|
/** Trunk publishes the wasm-bindgen module here (docs/automation/web.md). */
|
||||||
|
wasmBindings?: {
|
||||||
|
ocs_web_drop_file?: (name: string, bytes: Uint8Array) => void;
|
||||||
|
ocs_control_submit?: (request: string) => string;
|
||||||
|
ocs_control_take?: (ticket: string) => string | undefined;
|
||||||
|
};
|
||||||
|
/** Alias accepted by the resolver: a host page may expose the drop entry
|
||||||
|
* as a plain global instead of on the wasm-bindgen module object. */
|
||||||
|
ocs_web_drop_file?: (name: string, bytes: Uint8Array) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostWindow = window as unknown as OneCadHostWindow;
|
||||||
|
hostWindow.onecaddraftHost = {
|
||||||
|
ready: false,
|
||||||
|
host: false,
|
||||||
|
documentId: null,
|
||||||
|
name: null,
|
||||||
|
consumePendingOpen: () => bridge.consumePendingOpen(),
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The Rust odoo bridge polls window.onecaddraftHostReady (a plain boolean)
|
||||||
|
* before dispatching odoo.pickOpen / odoo.pickSave / odoo.write to this glue
|
||||||
|
* and retries until it is set. 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 would wedge the app's file flows for REQUEST_TIMEOUT
|
||||||
|
* (300 s) with no dialog on screen. */
|
||||||
|
function markHostReady(): void {
|
||||||
|
try {
|
||||||
|
hostWindow.onecaddraftHostReady = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[onecaddraft-odoo] could not set onecaddraftHostReady:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Wasm-module resolver (boots before or after this glue — both fine) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
const WASM_WAIT_MS = 30_000; // poll for the wasm module up to 30 s
|
||||||
|
const CONTROL_OPEN_TIMEOUT_MS = 60_000;
|
||||||
|
const POLL_INTERVAL_MS = 100;
|
||||||
|
|
||||||
|
interface WasmControlApi {
|
||||||
|
ocs_control_submit: (request: string) => string;
|
||||||
|
ocs_control_take: (ticket: string) => string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The JS-callable open entry points, in resolution order: the drop entry
|
||||||
|
* on the wasm-bindgen module object, then a plain window global. */
|
||||||
|
function findDropEntry(): ((name: string, bytes: Uint8Array) => void) | null {
|
||||||
|
const bindings = hostWindow.wasmBindings;
|
||||||
|
if (bindings && typeof bindings.ocs_web_drop_file === "function") {
|
||||||
|
return bindings.ocs_web_drop_file;
|
||||||
|
}
|
||||||
|
if (typeof hostWindow.ocs_web_drop_file === "function") {
|
||||||
|
return hostWindow.ocs_web_drop_file;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findControlApi(): WasmControlApi | null {
|
||||||
|
const bindings = hostWindow.wasmBindings;
|
||||||
|
return bindings &&
|
||||||
|
typeof bindings.ocs_control_submit === "function" &&
|
||||||
|
typeof bindings.ocs_control_take === "function"
|
||||||
|
? (bindings as WasmControlApi)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve once either open entry point exists (wasm early OR late). */
|
||||||
|
async function waitForWasmModule(timeoutMs: number = WASM_WAIT_MS): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (findDropEntry() || findControlApi()) return true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
}
|
||||||
|
return Boolean(findDropEntry() || findControlApi());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Control-channel open: submit {"op":"open","name","data_base64"} and poll
|
||||||
|
* take() for the reply, following accepted/running through op:"operation"
|
||||||
|
* (docs/automation/web.md). "busy" (another drawing opening) is retried
|
||||||
|
* until the deadline. Returns true only on a final ok reply. */
|
||||||
|
async function controlOpen(name: string, bytes: Uint8Array): Promise<boolean> {
|
||||||
|
const api = findControlApi();
|
||||||
|
if (!api) return false;
|
||||||
|
const submit = (payload: Record<string, unknown>): string =>
|
||||||
|
api.ocs_control_submit(JSON.stringify(payload));
|
||||||
|
const dataBase64 = bytesToBase64(bytes); // encode once; retried on busy
|
||||||
|
const deadline = Date.now() + CONTROL_OPEN_TIMEOUT_MS;
|
||||||
|
let ticket = submit({ op: "open", name, data_base64: dataBase64 });
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const raw = api.ocs_control_take(ticket);
|
||||||
|
if (raw === undefined) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let reply: { ok?: unknown; status?: unknown; code?: unknown; error?: unknown; request_id?: unknown };
|
||||||
|
try {
|
||||||
|
reply = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
console.warn("[onecaddraft-odoo] control open: unparsable reply");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const status = typeof reply.status === "string" ? reply.status : "";
|
||||||
|
if (reply.ok === false) {
|
||||||
|
if (reply.code === "busy") {
|
||||||
|
// Another drawing is still opening; retry once it settles.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
ticket = submit({ op: "open", name, data_base64: dataBase64 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
"[onecaddraft-odoo] control open failed:",
|
||||||
|
typeof reply.error === "string" ? reply.error : reply.code,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((status === "accepted" || status === "running") && typeof reply.request_id === "string") {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||||
|
ticket = api.ocs_control_submit(JSON.stringify({ op: "operation", request_id: reply.request_id }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return true; // final ok reply
|
||||||
|
}
|
||||||
|
console.warn("[onecaddraft-odoo] control open timed out after " + CONTROL_OPEN_TIMEOUT_MS + " ms");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stage one drawing's bytes into the running app. Tries the binary-safe
|
||||||
|
* drop entry first, then the control channel; consumes the bytes either
|
||||||
|
* way (the caller drops its copy once this resolves). */
|
||||||
|
async function stageDrawing(name: string, bytes: Uint8Array): Promise<boolean> {
|
||||||
|
if (!(await waitForWasmModule())) return false;
|
||||||
|
const drop = findDropEntry();
|
||||||
|
if (drop) {
|
||||||
|
try {
|
||||||
|
drop(name, bytes);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[onecaddraft-odoo] ocs_web_drop_file failed — trying the control channel:",
|
||||||
|
error instanceof Error ? error.message : error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return controlOpen(name, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Deep link */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Deep-link opener: stage the record bytes into the app through the wasm
|
||||||
|
* resolver above (the app API appears asynchronously after boot, so the
|
||||||
|
* resolver polls for it). */
|
||||||
|
async function openStagedDeepLink(entry: DocumentOpenedDetail | null): Promise<void> {
|
||||||
|
if (!entry || typeof entry.dataB64 !== "string") return;
|
||||||
|
const bytes = Uint8Array.from(atob(entry.dataB64), (char) => char.charCodeAt(0));
|
||||||
|
const staged = await stageDrawing(entry.name || "drawing.dxf", bytes);
|
||||||
|
if (!staged) {
|
||||||
|
console.warn("[onecaddraft-odoo] deep-link open skipped: the wasm module never appeared");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearDeepLinkQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop ?document= from the iframe URL once consumed (?v= and anything else
|
||||||
|
* the framework appended stays). Failure is non-fatal — a stale parameter
|
||||||
|
* would only re-trigger the read on a manual reload. */
|
||||||
|
function clearDeepLinkQuery(): void {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (!params.has("document")) return;
|
||||||
|
params.delete("document");
|
||||||
|
const query = params.toString();
|
||||||
|
const url = window.location.pathname + (query ? "?" + query : "") + window.location.hash;
|
||||||
|
window.history.replaceState(window.history.state, "", url);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[onecaddraft-odoo] could not clear the deep-link query:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Boot */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let host = false;
|
||||||
|
if (bridge.inFrame) {
|
||||||
|
try {
|
||||||
|
await bridge.waitForHost();
|
||||||
|
host = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[onecaddraft-odoo] host did not answer in time — continuing without Odoo Documents:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.info("[onecaddraft-odoo] standalone (no host frame) — Odoo Documents disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Unblock the Rust bridge first; the deep-link read continues in the
|
||||||
|
* background and must never keep the file flows hostage. */
|
||||||
|
markHostReady();
|
||||||
|
let deepLink = false;
|
||||||
|
if (host) {
|
||||||
|
try {
|
||||||
|
deepLink = await bridge.openDeepLink();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[onecaddraft-odoo] deep-link read failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const binding = bridge.getCurrentBinding();
|
||||||
|
|
||||||
|
const detail: HostReadyDetail = {
|
||||||
|
host,
|
||||||
|
documentId: binding.documentId,
|
||||||
|
name: binding.name,
|
||||||
|
};
|
||||||
|
const state = hostWindow.onecaddraftHost;
|
||||||
|
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 {};
|
||||||
Reference in new issue
Block a user