Tag Sync Script

Introduction

This script synchronizes tags between a transaction and its single split. When a transaction is created with exactly one split (two entries: positive and negative), it copies tags bidirectionally:

  • If the transaction has tags but the split does not, copy them to the split
  • If the split has tags but the transaction does not, copy them to the transaction

Cargo configuration

[package]
name = "tag-sync"
version = "0.1.0"
edition = "2021"

[lib]
name = "tag_sync"
crate-type = ["cdylib"]

[dependencies]
scripting-sdk = { path = "../../scripting/sdk", default-features = false }
scripting-sdk-macro = { path = "../../scripting/sdk-macro" }

[profile.release]
opt-level = "s"
lto = true
panic = "abort"

[profile.dev]
panic = "abort"

[workspace]

Header

#![no_std]

use scripting_sdk::prelude::*;
use scripting_sdk_macro::script;

Checking the applicability

We only apply when the primary entity is a transaction with exactly two splits (one logical split entry = two accounting entries).

fn is_single_split_transaction(ctx: &Context) -> bool {
    if ctx.primary_entity_type().ok() != Some(EntityType::Transaction) {
        return false;
    }

    let primary_idx = ctx.primary_entity_idx();
    ctx.splits_for(primary_idx).flatten().count() == 2
}

Synchronizing the tags

#[script(trigger_fn = is_single_split_transaction)]
fn sync_tags(ctx: &mut Context) -> Result<()> {
    let primary_idx = ctx.primary_entity_idx();

Find the split entity indices by scanning all entities:

let mut split_indices = [0u32; 2];
let mut split_count = 0usize;

for idx in 0..ctx.entity_count() {
    let Ok(entity) = ctx.entity(idx) else {
        continue;
    };
    if entity.entity_type().ok() == Some(EntityType::Split)
        && entity.parent_idx() == primary_idx as i32
        && split_count < 2
    {
        split_indices[split_count] = idx;
        split_count += 1;
    }
}

if split_count != 2 {
    return Ok(());
}

Collect tag counts for the transaction and each split. The "note" tag is a system tag set by the transaction form and should not count as a user tag for synchronization purposes:

fn is_user_tag(ctx: &Context, idx: u32) -> bool {
    ctx.tag(idx)
        .ok()
        .and_then(|t| t.name().ok())
        .is_some_and(|n| n != "note")
}

let tx_tag_count = (0..ctx.entity_count())
    .filter(|&idx| {
        ctx.entity(idx).ok().is_some_and(|e| {
            e.entity_type().ok() == Some(EntityType::Tag)
                && e.parent_idx() == primary_idx as i32
                && is_user_tag(ctx, idx)
        })
    })
    .count();
let split0_tag_count = ctx.tags_for(split_indices[0]).flatten().count();
let split1_tag_count = ctx.tags_for(split_indices[1]).flatten().count();
let splits_have_tags = split0_tag_count > 0 || split1_tag_count > 0;

Collect tag entity indices into a fixed buffer. We collect indices first to avoid holding an immutable borrow on the context while calling create_tag:

fn collect_tag_indices(ctx: &Context, parent_idx: u32, buf: &mut [u32; 16]) -> usize {
    let mut count = 0usize;
    for idx in 0..ctx.entity_count() {
        let Ok(entity) = ctx.entity(idx) else { continue };
        if entity.entity_type().ok() == Some(EntityType::Tag)
            && entity.parent_idx() == parent_idx as i32
            && is_user_tag(ctx, idx)
            && count < 16
        {
            buf[count] = idx;
            count += 1;
        }
    }
    count
}

fn copy_tags(ctx: &mut Context, source_parent: u32, target: i32) -> Result<()> {
    let mut tag_indices = [0u32; 16];
    let count = collect_tag_indices(ctx, source_parent, &mut tag_indices);
    for i in 0..count {
        let mut name_buf = [0u8; 128];
        let mut value_buf = [0u8; 128];
        let (nlen, vlen) = {
            let tag = ctx.tag(tag_indices[i])?;
            let name = tag.name()?;
            let value = tag.value()?;
            let nl = name.len().min(128);
            let vl = value.len().min(128);
            name_buf[..nl].copy_from_slice(&name.as_bytes()[..nl]);
            value_buf[..vl].copy_from_slice(&value.as_bytes()[..vl]);
            (nl, vl)
        };
        let name = core::str::from_utf8(&name_buf[..nlen]).unwrap_or("");
        let value = core::str::from_utf8(&value_buf[..vlen]).unwrap_or("");
        ctx.create_tag(target, name, value)?;
    }
    Ok(())
}

Copy transaction tags to splits when splits have no tags:

if tx_tag_count > 0 && !splits_have_tags {
    copy_tags(ctx, primary_idx, split_indices[0] as i32)?;
    copy_tags(ctx, primary_idx, split_indices[1] as i32)?;

Copy split tags to transaction when transaction has no tags. We take tags from the first split that has any:

    } else if tx_tag_count == 0 && splits_have_tags {
        let source_idx = if split0_tag_count > 0 {
            split_indices[0]
        } else {
            split_indices[1]
        };
        copy_tags(ctx, source_idx, primary_idx as i32)?;
    }

    Ok(())
}

Building

Use org-babel-tangle on this file and then:

cargo build --release --target=wasm32-unknown-unknown

to build target/wasm32-unknown-unknown/release/tag_sync.wasm