Automatically link a note with a tag to a note that has the same name as that tag?

Let’s say I have a structure like this:

ContainerFoo
  Foo1
  Foo2
  Foo3
ContainerBar
  Bar

What I’m wanting to do is automatically link, say, Foo2 to the note Bar when I input the tag Bar into Foo2’s $Tags attribute.

I had wanted to figure it out myself (I’m thinking it probably involves collect and obviously one of the link operators), but I’m stuck with how to make an agent that 1) interrogates each note’s tags and 2) then if it finds a match with a note’s name links to that note.

Thanks in advance. The answer will illuminate a technique that will be useful in a few ideas I have for evolving documents.

1 Like

Briefly, as I’m just headed out, this agent works:

Query:

$Tags.contains("Bar")

Action:

linkToOriginal("Bar");

This assumes the not ‘Bar’ is a unique $Name in the document. Otherwise use the $Path instead of the $Name in the above code.

As contains is a regex-based action if this pattern is used at scale, I’d try to find a method such as a prototype match that winnows down the number of notes whose $Tags we test. For example if all Foo notes used prototype ‘pFoo’, the we could use this more computationally effective query:

$Prototype="pFoo" & $Tags.contains("Bar")

This is still ineffective as the query will keep finding notes with a $Tags value of ‘Bar’. Although the action doesn’t make a new link if one exists, you really want to find a means to filter already linked notes.

1 Like

Thanks, Mark. I didn’t explain myself well enough.

I would like to do it somehow without specifying the name of the note to be linked to in advance, i.e. programmatically scan all the notes in two containers (in the example above, ContainerFoo and ContainerBar), and if a tag of a note in ContainerFoo is found to match the name of a note in ContainerBar, establish a link between them.

I can generate a dynamic list of tag and note names easily enough via collect – I just can’t figure out how to hook up found pairs dynamically.

Does that make things clearer?

Thanks again in advance.

OK, the query is now whatever finds only the notes you need check and the action is now this:

linkToOriginal(find($Tags(that).contains($Name)));

If you were to do the same in a stamp or edict, the code would be:

linkTo(find($Tags(that).contains($Name)));

The difference is because in an agent you are processing an alias yet the aim is to link with the original of the note thus the newer actions linkToOriginal() and linkFromOriginal() specifically address this issue of linking from within an agent.

1 Like

That’s it! Now, I’m going to spend some time stepping through it and understanding why it works. I had convinced myself I’d need to use collect for some reason.

Many thanks!

OK, still trying to puzzle through why this (welcomingly) simple bit of action code actually works. I had thought some kind of intermediate lookup table would need to be created and then an if used somewhere, but this does it all in one fell swoop.

My understanding is this:

  1. The agent collects the notes to be interrogated.
  2. It then loops over each note 1 by 1, passing the note in focus the action code which has a find at its core. This find is inspecting the note’s $Tags, and looking for $Names that match any of them – and, if it hits on a match, linking to the original as per the operator. (The that takes care of letting the action know that it’s the note running the code whose $Tags we want to scan.)

So far so good (I think).

What’s confusing me is why it’s so simple that the contains is just qualified with $Name. Is it that find is (as described here) fundamentally a global operation – and so effectively it’s scanning over every object with a $Name in the document and firing the linkToOriginal when a match is made? That’s what I’m telling myself for the moment, but I’m just seeking clarification.

Thanks in advance.

Absolutely. It’s not so odd. Whether you use an agent alias or a rule/edict/stamp, that part only identifies one end of the link you wish to create. Then for that object (or each of them if more than one) you need to re-search the whole document via a find()-based query. In a small doc the query doesn’t matter, but in a bigger, longer-lived doc you want to give thought to scoping the query, for a reason I’ll return to in a moment.

Why is .contains()? $Tags is a Set-type attribute containing zero or more values. By comparison, a String-type can only have one value. The == equality test, when used on on a list (which is stored as a single string with semi-colons delimiting the discrete values) tests the whole list as opposed to discrete list items. Consider:

  • ""=="Bar". No match, so correct.
  • "Bar"=="Bar". Match, so correct.
  • "Foo;Bar;Baz"=="Bar". No match, so false negative if trying to test for ‘Bar’ in a list.

Again, using a string literal instead of an attribute, the need for .contains() becomes clearer:

  • $MyBoolean = "Foo;Bar;Baz".contains("Bar"). True!

But, although we’re using a literal test string ‘Bar’ rather than a regular expression (a ‘regex’), it is the case that .contains() is a regex-based and comparatively computationally expensive compared to other actions. Now in a small test doc, the difference is moot. In a mature doc with thousands of notes, you may see some effect.

So, looping back to my first point, try to scope your queries—both in agents and within a find()—so you test as few notes as possible. So if your doc has 500 notes, 400 use $Tags and ContainerFoo contains only 20 notes, this is a wasteful query:

$Tags.contains("Bar")

…because the query will test $Tags in 500 notes. Better might be something like:

inside("ContainerBar") & $Tags.contains("Bar")

Now, the first term finds 20 out of 500 notes with a simple text and only those 20 are tested for a $Tags value. Of course you are looking for a generalised pattern as the real use case is for items in more than one container. This is where prototypes are really useful. If you use a common prototype for all the notes needing this sort of test, then this is possible:

$Prototype=="pFoo" & $Tags.contains("Bar")

Now the first part of the query finds only those notes using that prototype, etc. See how this makes your queries/finds more efficient at scale.

Now, it may be the notes you want to test already use more than one type of prototype so you could check for the likes of:

($Prototype=="pFoo" | $Prototype=="pBaz") & $Tags.contains("Bar")

But with lots of prototypes in such a context you might do better to use a hierarchical approach, by making sure all your link-testing notes are descended from a single container, allowing us to use a descendedFrom() test:

descendedFrom("Annotations") & $Tags.contains("Bar")

or perhaps we might further filter only descendants that have a prototype set.

descendedFrom("Annotations") & $Prototype & $Tags.contains("Bar")

On a different tack, if we don’t use an agent to run our action but instead use an edict (easily set via a prototype) then we can make our use of find more efficient:

if(linkedTo("Bar")==false){
linkTo(find($Tags(that).contains($Name)));
}

Note we don’t need linkToOriginal as we are not acting on the original. In the if() test we ask the note if it is already linked to ‘Bar’. If it is we ignore the linking action with its associated find() and .contains() test. Hopefully you can see how we now avoid unnecessary code being run - and code which at scale can affect performance. If you’re reading along and just starting out with actions - don’t worry - these considerations are moot in small documents and only being to make sense once your TBXs grow. Plus, don’t worry if you’ve got a large doc and want to implement the ideas like above. Tinderbox makes that easy, though some careful work may be needed - i.e. work on a copy of important docs if doing major surgery!

Anyway, I hope that helps explain some of what’s going on and why I’ve taken the approach I have.

2 Likes

Great rundown – many thanks. I’m kicking myself that I thought my original requirement would be so much more complex than it actually is, but I hadn’t grasped the scope and mechanics of find properly. I’d read about it, but as is the case with most things in my life, it isn’t until I try to use something in the heat of battle that I really understand what’s going on. That’s probably not peculiar to just me, though.

And your suggestion about using an edict (via a prototype) is timely. As the lightbulb went off that there was going to be a lot of stuff grinding away unnecessarily in the background for what is something that is really a one-shot thing, I thought about putting it into an edict (and I’ve discovered the enable toggle so I know how to not have the proto itself trigger the edict). The doc that I’m developing this for will end up with potentially 1,000s of notes, so performance will be a factor.

Hopefully some innocent bystanders might have got a bit out of this. Thanks!

Note that the ‘enabled’ settings $RuleDisabled and $EdictDisabled don’t inherit so if you disable the prototype edict it remain enabled in notes using the prototype. This is deliberate as the original purpose of these ‘enable’ setting was precisely to stop an action running in prototypes whilst still working in inheriting notes. As long as running the edict in the prototype doesn’t matter, you can use the following method to toggle the inheriting note’s edict via the prototype. In the edict, surround your normal code with this if() statement:

if($EdictDisabled($Prototype)==false){
...other code here...
};

Now each edict, as it fires, first checks the value of $EdictDisabled in its prototype. If setting is false, i.e. that edict is not disabled, the full edict code is run. Otherwise, nothing further occurs. Sure, each inheriting not still has to run the if() test but that’s a lot less computation than running the full edict.

1 Like

This is good advice for any software – especially complex products like Tinderbox. Dive in and swim.

4 Likes

This is great. I’ve just used it and its working. How would I go about unlinking automatically though? At the moment, the number of links keeps growing as I change the $Tag attribute. I’ve tried

unlinkTo(“all”);

In an edict and a rule and an action and under “remove”, but it’s just not working. My thinking is that it should remove the links first, then scan the attribute and create the link based on the single existing attribute. Does this make sense? Thanks.

Nevermind. Got it working via a Stamp. Just discovered how to use them. :slightly_smiling_face:

Ah, if using an agent and doing linking work, be aware that you are acting on aliases. Whilst aliases and originals always share text links (as they have the same $text!), they don’t share basic links.

Reflecting that, for linkTo() there is a companion operator linkToOriginal() and the same for the rest of the link/unlink operators. The ‘Original’ variants are for just this scenario where we act on an alias but want to affect the alias’ original note.

You stamp likely worked because you srtamped the original note, and in the light of the explanation just above, hopefully you now see why that worked and an agent failed.

HTH, and do ask again if I’ve misunderstood the issue.

†. Um, except where they do share (or act as if they do) the basic links of the original. However, I won’t go down that rabbit hole here as it’s not pertinent here.

Yes it helps. Thanks once more, Mark.

1 Like