initial work

This commit is contained in:
Joe 2026-02-14 10:56:25 -06:00
parent 252325a7de
commit bf65d8bfa8
7 changed files with 143 additions and 20 deletions

View file

@ -1,5 +1,6 @@
use std::{fmt, sync::Arc};
use rbx_dom_weak::{types::Ref, WeakDom};
use serde::{Deserialize, Serialize};
/// Legacy: ID attribute on target instances (kept for backwards compatibility)
@ -11,6 +12,21 @@ pub const REF_POINTER_ATTRIBUTE_PREFIX: &str = "Rojo_Target_";
/// No modification to the target instance is needed.
pub const REF_PATH_ATTRIBUTE_PREFIX: &str = "Rojo_Ref_";
/// Compute the `Rojo_Ref_*` attribute name for a given property name.
///
/// Example: `ref_attribute_name("PrimaryPart")` returns `"Rojo_Ref_PrimaryPart"`.
pub fn ref_attribute_name(prop_name: &str) -> String {
format!("{REF_PATH_ATTRIBUTE_PREFIX}{prop_name}")
}
/// Compute the path to a target instance for use in a `Rojo_Ref_*` attribute.
/// Returns the slash-separated path from the root (root name excluded).
///
/// Example: an instance at `DataModel > Workspace > Part1` returns `"Workspace/Part1"`.
pub fn ref_target_path(dom: &WeakDom, target_ref: Ref) -> String {
dom.full_path_of(target_ref, "/")
}
// TODO add an internment strategy for RojoRefs
// Something like what rbx-dom does for SharedStrings probably works

View file

@ -10,8 +10,8 @@ use rbx_dom_weak::{
};
use crate::{
syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME, REF_PATH_ATTRIBUTE_PREFIX,
REF_POINTER_ATTRIBUTE_PREFIX,
ref_attribute_name, syncback::snapshot::inst_path, REF_ID_ATTRIBUTE_NAME,
REF_PATH_ATTRIBUTE_PREFIX, REF_POINTER_ATTRIBUTE_PREFIX,
};
pub struct RefLinks {
@ -232,7 +232,7 @@ pub fn link_referents(links: RefLinks, dom: &mut WeakDom) -> anyhow::Result<()>
if let Some(path_refs) = links.path_links.get(&inst_id) {
for link in path_refs {
path_attrs.push((
format!("{REF_PATH_ATTRIBUTE_PREFIX}{}", link.name),
ref_attribute_name(&link.name),
Variant::String(link.path.clone()),
));
}

View file

@ -2285,8 +2285,10 @@ impl ApiService {
continue;
}
// Skip Ref and UniqueId - they don't serialize to JSON (matches dedicated syncback)
if matches!(value, Variant::Ref(_) | Variant::UniqueId(_)) {
// Skip UniqueId - internal Roblox identifiers, not meaningful for syncback.
// Note: Ref properties are handled upstream in syncback_updated_properties()
// where they are converted to Rojo_Ref_* path-based attributes.
if matches!(value, Variant::UniqueId(_)) {
continue;
}
@ -2537,27 +2539,58 @@ impl ApiService {
"ModuleScript" | "Script" | "LocalScript"
);
// Collect the non-Source properties as a HashMap<String, Variant>
// for reuse with filter_properties_for_meta
// Extract Ref properties and convert to Rojo_Ref_* attributes.
// Non-Ref properties are collected separately for filter_properties_for_meta.
let mut ref_attributes: indexmap::IndexMap<String, serde_json::Value> =
indexmap::IndexMap::new();
let mut remove_attributes: Vec<String> = Vec::new();
let mut props: HashMap<String, Variant> = HashMap::new();
for (key, value) in &update.changed_properties {
if key == "Source" {
continue;
}
if let Some(v) = value {
props.insert(key.to_string(), v.clone());
match value {
Some(Variant::Ref(target_ref)) => {
let attr_name = crate::ref_attribute_name(key);
if target_ref.is_none() {
// Null ref: mark attribute for removal
remove_attributes.push(attr_name);
} else if tree.get_instance(*target_ref).is_some() {
// Valid target: compute path and add as Rojo_Ref_* attribute
let path = crate::ref_target_path(tree.inner(), *target_ref);
ref_attributes.insert(attr_name, serde_json::Value::String(path));
} else {
log::warn!(
"Cannot persist Ref property '{}' for instance {:?}: \
target {:?} not found in tree",
key,
update.id,
target_ref
);
}
}
Some(v) => {
props.insert(key.to_string(), v.clone());
}
None => {}
}
}
if props.is_empty() {
if props.is_empty() && ref_attributes.is_empty() && remove_attributes.is_empty() {
return Ok(());
}
let skip_prop = if is_script { Some("Source") } else { None };
let (properties, attributes) =
let (properties, mut attributes) =
self.filter_properties_for_meta(class_name.as_str(), &props, skip_prop);
if properties.is_empty() && attributes.is_empty() {
// Merge Rojo_Ref_* attributes into the attributes map
for (k, v) in ref_attributes {
attributes.insert(k, v);
}
if properties.is_empty() && attributes.is_empty() && remove_attributes.is_empty() {
return Ok(());
}
@ -2567,7 +2600,13 @@ impl ApiService {
let meta_path = inst_path.join("init.meta.json5");
// Read existing meta if present, merge with new properties
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize init.meta.json5")?;
self.suppress_path(&meta_path);
@ -2594,7 +2633,13 @@ impl ApiService {
.unwrap_or(file_stem);
let meta_path = parent_dir.join(format!("{}.meta.json5", base_name));
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize meta.json5")?;
self.suppress_path(&meta_path);
@ -2620,6 +2665,7 @@ impl ApiService {
Some(class_name.as_str()),
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize model file")?;
@ -2637,7 +2683,13 @@ impl ApiService {
let file_stem = inst_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let meta_path = parent_dir.join(format!("{}.meta.json5", file_stem));
let meta = self.merge_or_build_meta(&meta_path, None, properties, attributes)?;
let meta = self.merge_or_build_meta(
&meta_path,
None,
properties,
attributes,
&remove_attributes,
)?;
let content = crate::json::to_vec_pretty_sorted(&meta)
.context("Failed to serialize meta.json5")?;
self.suppress_path(&meta_path);
@ -2656,12 +2708,17 @@ impl ApiService {
/// Reads an existing meta/model JSON5 file and merges new properties into it.
/// If the file doesn't exist, builds a fresh meta object.
///
/// `remove_attributes` is a list of attribute keys to remove from the existing
/// file (used for nil Ref cleanup, e.g., removing a stale `Rojo_Ref_PrimaryPart`
/// when PrimaryPart is set to nil).
fn merge_or_build_meta(
&self,
existing_path: &std::path::Path,
class_name: Option<&str>,
new_properties: indexmap::IndexMap<String, serde_json::Value>,
new_attributes: indexmap::IndexMap<String, serde_json::Value>,
remove_attributes: &[String],
) -> anyhow::Result<serde_json::Value> {
use anyhow::Context;
@ -2691,6 +2748,17 @@ impl ApiService {
}
}
// Remove attributes marked for deletion (e.g., stale Rojo_Ref_* entries)
if !remove_attributes.is_empty() {
if let Some(attrs) = obj.get_mut("attributes") {
if let Some(attrs_obj) = attrs.as_object_mut() {
for key in remove_attributes {
attrs_obj.remove(key);
}
}
}
}
// Merge attributes
if !new_attributes.is_empty() {
let attrs = obj