CMMS embedding: support creating a brand-new file, not just editing

cad-edit.php?new=1 launches the editor with no attachment_id/load_url yet -
CmmsConfig's load_url/attachment_id/original_name become Optional (only
save_url/job_id/csrf_token are always required). load_from_cmms_web() and
boot_web()'s auto-load task both skip cleanly when load_url is absent (no
error, just the normal blank Start tab). post_bytes_to_host() now takes the
filename and picks cad_save (update, has attachment_id) vs cad_save_new
(create); on a successful create it writes the new id/loadUrl/originalName
back into window.CAD_EDITOR_CONFIG in place, so the tab's *next* save
updates that row instead of creating a duplicate each time.
This commit is contained in:
AI Assistant 2026-08-23 15:54:52 +10:00
commit 64d0230952
4 changed files with 86 additions and 25 deletions

View file

@ -3673,13 +3673,16 @@ impl OpenCADStudio {
); );
let thumbs_fetch = s.refresh_recent_thumbs(); let thumbs_fetch = s.refresh_recent_thumbs();
// CMMS embedding (not upstream): if launched from cad-edit.php, // CMMS embedding (not upstream): if launched from cad-edit.php to
// window.CAD_EDITOR_CONFIG is set and the host has already validated // *edit* an existing attachment, window.CAD_EDITOR_CONFIG carries a
// the attachment belongs to the job the editor was opened from - load // load_url the host has already validated - load it immediately
// it immediately instead of waiting for File > Open. Standalone use // instead of waiting for File > Open, mirrors on_open_file's wasm
// (opencadstudio.com, no config present) is unaffected; the Start tab // branch. Gated on load_url specifically, not just cmms_config()'s
// just shows as normal. Mirrors on_open_file's wasm branch. // presence: a "new file" launch (cad-edit.php?new=1) sets the config
let cmms_load = if crate::sys::cmms_config().is_some() { // but omits load_url on purpose (nothing exists yet to load), and
// should boot to the normal blank Start tab, not show a load error.
// Standalone use (opencadstudio.com, no config at all) is unaffected.
let cmms_load = if crate::sys::cmms_config().is_some_and(|c| c.load_url.is_some()) {
let state = std::sync::Arc::new(crate::io::OpenProgressState::new( let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
crate::app::OPEN_PHASE_READING, crate::app::OPEN_PHASE_READING,
)); ));

View file

@ -2217,8 +2217,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
Ok(bytes) => { Ok(bytes) => {
if crate::sys::cmms_config().is_some() { if crate::sys::cmms_config().is_some() {
let post_bytes = bytes.clone(); let post_bytes = bytes.clone();
let post_filename = filename.clone();
cmms_save_task = Task::perform( cmms_save_task = Task::perform(
async move { crate::sys::post_bytes_to_host(&post_bytes).await }, async move { crate::sys::post_bytes_to_host(&post_bytes, &post_filename).await },
Message::CmmsSaveResult, Message::CmmsSaveResult,
); );
} else { } else {

View file

@ -525,21 +525,24 @@ pub async fn pick_and_load_web(
} }
/// CMMS embedding (not upstream): load the one file the host page already /// CMMS embedding (not upstream): load the one file the host page already
/// validated, instead of showing the browser picker. `crate::sys::cmms_config()` /// validated, instead of showing the browser picker. Returns `None` both
/// returning `None` means this build is running standalone (opencadstudio.com /// when this build is running standalone (no `cmms_config()` at all) and
/// or a plain local file), so callers should fall back to `pick_and_load_web` /// when it's embedded but launched to create a brand-new file (`cad-edit.php
/// in that case — this never shows a picker itself. /// ?new=1` - config present, but no `load_url` since there's nothing to
/// load yet) - callers treat both the same: don't auto-load, let the app
/// boot to its normal blank Start tab.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub async fn load_from_cmms_web(progress: Arc<OpenProgressState>) -> Option<WebOpenOutcome> { pub async fn load_from_cmms_web(progress: Arc<OpenProgressState>) -> Option<WebOpenOutcome> {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
let cfg = crate::sys::cmms_config()?; let cfg = crate::sys::cmms_config()?;
let load_url = cfg.load_url?;
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2); progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
let name = cfg.original_name.clone(); let name = cfg.original_name.clone().unwrap_or_else(|| "CMMS attachment".to_string());
let window = web_sys::window()?; let window = web_sys::window()?;
let response: web_sys::Response = JsFuture::from(window.fetch_with_str(&cfg.load_url)) let response: web_sys::Response = JsFuture::from(window.fetch_with_str(&load_url))
.await .await
.ok()? .ok()?
.dyn_into() .dyn_into()

View file

@ -311,12 +311,15 @@ pub fn download_bytes(name: &str, bytes: &[u8]) {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub struct CmmsConfig { pub struct CmmsConfig {
pub load_url: String,
pub save_url: String, pub save_url: String,
pub attachment_id: String,
pub job_id: String, pub job_id: String,
pub csrf_token: String, pub csrf_token: String,
pub original_name: String, // Absent when the editor was launched to create a brand-new file
// (public/cad-edit.php?job_id=&new=1) rather than to edit an existing
// attachment - there is nothing to auto-load and nothing to update yet.
pub load_url: Option<String>,
pub attachment_id: Option<String>,
pub original_name: Option<String>,
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
@ -334,30 +337,46 @@ pub fn cmms_config() -> Option<CmmsConfig> {
.as_string() .as_string()
}; };
Some(CmmsConfig { Some(CmmsConfig {
load_url: get_str("loadUrl")?,
save_url: get_str("saveUrl")?, save_url: get_str("saveUrl")?,
attachment_id: get_str("attachmentId")?,
job_id: get_str("jobId")?, job_id: get_str("jobId")?,
csrf_token: get_str("csrfToken")?, csrf_token: get_str("csrfToken")?,
original_name: get_str("originalName")?, load_url: get_str("loadUrl"),
attachment_id: get_str("attachmentId"),
original_name: get_str("originalName"),
}) })
} }
/// POST the current document's bytes back to the CMMS in place of a browser /// POST the current document's bytes back to the CMMS in place of a browser
/// download. Mirrors `download_bytes`'s Blob construction but sends it as a /// download. Mirrors `download_bytes`'s Blob construction but sends it as a
/// multipart file field instead of triggering an `<a download>` click. /// multipart file field instead of triggering an `<a download>` click.
///
/// `cfg.attachment_id` decides create vs. update: present -> `cad_save`
/// (overwrite that exact attachment); absent -> `cad_save_new` (this is the
/// first save of a brand-new, editor-created file) using `filename` as the
/// new attachment's name. On a successful create, the new id is written back
/// into `window.CAD_EDITOR_CONFIG` so the *next* save on this same tab
/// updates that row instead of creating a duplicate each time - there is no
/// other state to thread through the app's own Message/Task plumbing for
/// this, the host page's own JS global is the simplest shared place to park it.
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
pub async fn post_bytes_to_host(bytes: &[u8]) -> Result<(), String> { pub async fn post_bytes_to_host(bytes: &[u8], filename: &str) -> Result<(), String> {
use wasm_bindgen::JsCast; use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
let cfg = cmms_config().ok_or("not running inside the CMMS")?; let cfg = cmms_config().ok_or("not running inside the CMMS")?;
let is_new = cfg.attachment_id.is_none();
let form = web_sys::FormData::new().map_err(|_| "could not build form data")?; let form = web_sys::FormData::new().map_err(|_| "could not build form data")?;
form.append_with_str("action", "cad_save") form.append_with_str("action", if is_new { "cad_save_new" } else { "cad_save" })
.map_err(|_| "form append failed")?;
form.append_with_str("id", &cfg.attachment_id)
.map_err(|_| "form append failed")?; .map_err(|_| "form append failed")?;
if let Some(id) = &cfg.attachment_id {
form.append_with_str("id", id)
.map_err(|_| "form append failed")?;
}
if is_new {
form.append_with_str("original_name", filename)
.map_err(|_| "form append failed")?;
}
form.append_with_str("job_id", &cfg.job_id) form.append_with_str("job_id", &cfg.job_id)
.map_err(|_| "form append failed")?; .map_err(|_| "form append failed")?;
form.append_with_str("csrf_token", &cfg.csrf_token) form.append_with_str("csrf_token", &cfg.csrf_token)
@ -385,6 +404,41 @@ pub async fn post_bytes_to_host(bytes: &[u8]) -> Result<(), String> {
if !response.ok() { if !response.ok() {
return Err(format!("save failed: HTTP {}", response.status())); return Err(format!("save failed: HTTP {}", response.status()));
} }
if is_new {
let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?)
.await
.map_err(|_| "save response body read failed")?;
let body = text.as_string().ok_or("save response body is not a string")?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|_| "save response is not valid JSON")?;
let new_id = parsed
.get("id")
.and_then(|v| v.as_i64())
.ok_or("save response missing id")?;
let config_obj = js_sys::Reflect::get(&window, &JsValue::from_str("CAD_EDITOR_CONFIG"))
.map_err(|_| "could not re-read CAD_EDITOR_CONFIG")?;
js_sys::Reflect::set(
&config_obj,
&JsValue::from_str("attachmentId"),
&JsValue::from_str(&new_id.to_string()),
)
.map_err(|_| "could not update attachmentId")?;
js_sys::Reflect::set(
&config_obj,
&JsValue::from_str("loadUrl"),
&JsValue::from_str(&format!("/attachment.php?id={new_id}")),
)
.map_err(|_| "could not update loadUrl")?;
js_sys::Reflect::set(
&config_obj,
&JsValue::from_str("originalName"),
&JsValue::from_str(filename),
)
.map_err(|_| "could not update originalName")?;
}
Ok(()) Ok(())
} }