Scripting question: How to alter row text while keeping formatting?

Just jumping back into Bike after trying out some other tools (why did I do that?), and have started to really enjoy the custom formatting (links, bold, italic, code, etc). Most of my scripts used the name property, but that’s just returning back the text without any markup.

I see there’s a textContent, and you can access things like attributeRuns, but I’m striking out figure out how I might alter a row (think prepend/append some text) while keeping that formatting.

Cheers

It’s a good question : )

I personally tend to do this kind of thing by:

  • extracting the XML with the .export method,
  • using .import on XML updated by standard XML tools,
  • and deleting the source line,

but that may be a bit more than is needed for light edits.

Perhaps @jessegrosjean has found a neater and more direct way to do that through the AppleScript interface ?

That export/import option is interesting. How do you use it to replace a row though if you’re deleting?

You can specify an import destination with the import to parameter, and then delete the original lines.

Got it, so essentially to replace:

  1. Export row to xml
  2. Modify xml
  3. Import xml to the row’s parent
  4. Delete the row
  5. Somehow move the imported row back to where it was located in the parent so the ordering isn’t different

I usually work in JS, which lacks a full implementation of the OSA location specifiers, but if you are working in AppleScript, I think you may be able to use more delicate import targeting, with semantics like to after theRow

(followed by a delete of theRow)


Here, FWIW is a JS example of transforming and reintroducing an XML subset of the document.
(A script which toggles the AZ ⇄ ZA sorting of the children of the selected row)

Expand disclosure triangle to view JS source
(() => {
    "use strict";

    // Second version of a `Sort children of selected line`
    // for Bike. (Uses XQuery)

    // RobTrew @2022
    // Ver 0.14

    // Toggles between ascending and descending
    // alphabetic sort of the children of the
    // item selected in the front Bike document.

    // -------------- SORTED BIKE SIBLINGS ---------------

    // main :: IO ()
    const main = () => {
        const
            start = new Date(),
            bike = Application("Bike"),
            doc = bike.documents.at(0);

        return either(
            alert("Sorting children of selected row.")
        )(
            report => `${report} (${elapsed(start)}s)`
        )(
            doc.exists() ? (
                toggleSortedSelectionChildrenLR(bike)(doc)
            ) : Left("No documents open in Bike.")
        );
    };

    // --------------------- SORTING ---------------------

    // sortedChildNodesLR :: Bool -> Bike Document ->
    // Bike Row -> Either String [XMLNode]
    const sortedChildNodesLR = isZA =>
        // Either a message or a list of sorted nodes.
        doc => parentRow => {
            const
                direction = isZA ? (
                    "descending"
                ) : "ascending",
                xquery = [
                    "for $li in /html/body/ul/li/ul/li",
                    "let $pText := upper-case(string($li/p))",
                    `order by $pText ${direction}`,
                    "return $li"
                ].join("\n");

            return xQueryLR(xquery)(
                doc.export({
                    from: parentRow,
                    as: "bike format",
                    all: true
                })
            );
        };


    // toggleSortedSelectionChildrenLR :: Application ->
    // Bike Doc -> Either IO String IO String
    const toggleSortedSelectionChildrenLR = app =>
        // Either a message or an update to the
        // specfied document.
        // Children of selected row re-sorted (ASC or DESC).
        doc => {
            const
                selectedRows = doc.rows.where({
                    selected: true
                }),
                parentRow = selectedRows.at(0),
                hasChildren = parentRow.exists() && (
                    0 < parentRow.rows.length
                );

            return hasChildren ? (() => {
                const
                    children = parentRow.rows,
                    toZA = toUpper(children.at(0).name()) < (
                        toUpper(children.at(-1).name())
                    );

                return bindLR(
                    sortedChildNodesLR(toZA)(doc)(parentRow)
                )(
                    updatedDocumentLR(toZA)(app)(doc)(
                        parentRow
                    )(children)
                );
            })() : Left("No row with children selected.");
        };


    // updatedDocumentLR :: Bike Application ->
    // Bike Document -> Bike Row ->
    // [XMLNode] -> Either IO String IO String
    const updatedDocumentLR = isZA =>
        // Children of selected row replaced
        // by sorted copies.
        app => doc => parentRow => children => nodes => {
            const
                uw = ObjC.unwrap,
                sortedXML = nodes.map(
                    li => uw(li.XMLString)
                )
                .join("\n"),
                h = [
                    // eslint-disable-next-line quotes
                    '<?xml version="1.0" encoding="UTF-8"?>',
                    "<html>",
                    "<head><meta charset=\"utf-8\"/></head>",
                    `<body><ul id="${doc.id()}">`
                ]
                .join("\n"),
                t = "</ul></body></html>",
                nNodes = nodes.length,
                n = children.length,
                k = parentRow.name(),
                direction = isZA ? (
                    "Z-A descending"
                ) : "A-Z ascending";

            return nNodes !== n ? Left([
                `Children of '${k}' not sorted.`,
                "Unexpected count of sorted nodes:",
                `Expected ${n}, saw ${nNodes}`
            ].join("\n")) : (
            // Existing children deleted, and
                app.delete(children),

                // sorted copies imported.
                doc.import({
                    from: `${h}\n${sortedXML}\n${t}`,
                    to: parentRow,
                    as: "bike format"
                }),
                Right(
                    [
                        `Sorted the ${n} children of '${k}'.`,
                        `(${direction})`
                    ].join("\n")
                )
            );
        };


    // --------------------- XQUERY ----------------------

    // xQueryLR :: String ->
    // String -> Either String [XMLNode]
    const xQueryLR = xquery =>
    // Either a message or a list of XMLNode objects
    // defined by application of the XQuery over the XML.
        xml => {
            const
                uw = ObjC.unwrap,
                error = $(),
                node = $.NSXMLDocument.alloc
                .initWithXMLStringOptionsError(
                    xml, 0, error
                );

            return bindLR(
                node.isNil() ? (
                    Left(uw(error.localizedDescription))
                ) : Right(node)
            )(
                oNode => {
                    const
                        err = $(),
                        xs = oNode.objectsForXQueryError(
                            xquery, err
                        );

                    return xs.isNil() ? (
                        Left(uw(err.localizedDescription))
                    ) : Right(uw(xs));
                }
            );
        };

    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        // Display of a given title and message.
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };

    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = m =>
        mf => m.Left ? (
            m
        ) : mf(m.Right);


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => e.Left ? (
            fl(e.Left)
        ) : fr(e.Right);


    // elapsed :: Date -> String
    const elapsed = start =>
        // Single digit precision string showing
        // rough time elapsed since start, in seconds.
        ((new Date() - start) / 1000)
        .toFixed(1);


    // toUpper :: String -> String
    const toUpper = s =>
        s.toLocaleUpperCase();

    // MAIN ---
    return main();
})();

For a basic example:

Expand disclosure triangle to view AppleScript source
use framework "Foundation"

------------ PREPEND OR APPEND TO FORMATTED ROW ----------

-- affixToRow :: (Bool, String, Bike Row) -> IO XML String
on affixToRow(atStart, txt, targetRow)
    using terms from application "Bike"
        set doc to container document of targetRow
        
        set xml to export doc from targetRow as bike format
        
        if atStart then
            set newXML to allReplaced("<p>", "<p>" & txt, xml)
        else
            set newXML to allReplaced("</p>", txt & "</p>", xml)
        end if
        
        import doc as bike format from newXML to (after targetRow)
        delete targetRow
    end using terms from
    newXML
end affixToRow

--------------------------- TEST -------------------------
tell application "Bike"
    set doc to front document
    if exists doc then
        set targetRow to selection row of doc
        my affixToRow(true, "Some prefix ", targetRow)
        
        -- The orginal row reference is lost by now
        set targetRow to selection row of doc
        my affixToRow(false, " Some postfix", targetRow)
    end if
    activate
end tell

------------------------- GENERIC ------------------------

-- allReplaced :: String -> String -> String -> String
on allReplaced(needle, replacement, haystack)
    set s to current application's NSString's stringWithString:haystack
    
    (s's stringByReplacingOccurrencesOfString:(needle) ¬
        withString:(replacement)) as string
end allReplaced

I’m sorry for being late to this thread. Long weekend of kids and snowstorms kept me distracted. I’ve messed around a bit and I think this is how AppleScript wants you to do it:

tell application "Bike"
	tell front document
		tell first row
			tell text content
				make attribute run at end with data " append text"
			end tell
		end tell
	end tell
end tell

That appends text while also not discarding any formatting in the original text. It also seems to work the same if you replace attribute run with word or with paragraph. That term seems to be just for getting insert location, the important part is the make I think.

1 Like

Thanks !

( and the mirror word seems to be beginning rather than start )

Expand disclosure triangle to view AppleScript source
tell application "Bike"
	tell front document
		tell first row
			tell text content
				make attribute run at beginning with data "PREPEND text "
			end tell
		end tell
	end tell
end tell
1 Like

Thank you, Jesse. I think this should work in JXA. Could you check, if that is not much trouble ? I don’t get any errors, but the new object is not succesfully added.

(() => {
    'use strict';

    // jxaContext :: IO ()
    const jxaContext = () => {
        // main :: IO ()
        const main = () => {
            const
                bike = Application("Bike"),
                doc = bike.documents.at(0),
                row = doc.rows.at(0),
                attributeRun = bike.AttributeRun({
                    withData: " append text", 
                    bold: true
                })
            return (
                row
                .textContent
                .attributeRuns
                .push(attributeRun)
            )
        };

        // MAIN --
        return main();
    };

    return jxaContext();
})();

With this code (calling .make()), I get the error:

Error: Can’t make or move that element into that container. (-10024)

(() => {
    'use strict';

    // jxaContext :: IO ()
    const jxaContext = () => {
        // main :: IO ()
        const main = () => {
            const
                bike = Application("Bike"),
                doc = bike.documents.at(0),
                row = doc.rows.at(0),
                attributeRun = bike.AttributeRun({
                    withData: " append text", 
                    bold: true
                }).make()
            return (
                row
                .textContent
                .attributeRuns
                .push(attributeRun)
            )
        };

        // MAIN --
        return main();
    };

    return jxaContext();
})();

I’m trying, but not having much luck with JavaScript version. Having a hard time finding examples that use make command. Are you able to get this sort of thing to work in other apps such as TextEdit? That’s what I’m currently trying… how to append text to rich text document in text edit… but no luck so far.

Thank you !

I am able to do something similar in OmniFocus; however, I didn’t try adding a new attributeRun in other apps. This is how I add a new Tag object in OF database:

(() => {
    'use strict';

    // main :: IO ()
    const main = () => {
        const
            oApp = Application('OmniFocus'),
            oDoc = oApp.defaultDocument,
            oProject = oDoc.flattenedProjects.byName('Test'),
            newTag = oApp.Tag({
                name: 'Sample Tag'
            })
        return (
            oDoc.tags.push(newTag)
        )
    };

    // MAIN ----------------------------------------------------------------
    return main();
})();

Ahh, just made some good progress!

var bike = Application('Bike')
var document = bike.documents.at(0)
var row = document.rows.at(0)
var run = bike.AttributeRun({bold: true}, " appending...")
row.textContent.attributeRuns.push(run)

I think that’s what you need right?

Update

The key find for me was this document:

It’s the only document I could find that actually describes the basics of how things are supposed to work instead of a soup of examples that I can’t make sense of.

I think I now remember in the past going through a similar process… and finally coming across that same bit of documentation. Will probably happen in the future to since all the easy to find documentation never seems to help me.

3 Likes

It’s possible that some searching might turn up an incantation that works to append a new attribute run in JXA …

(looks like Jesse has done it there while I type :slight_smile: :+1: )

but the problem is that location specifiers are not implemented for JXA, and to prepend, or insert at a given position, we really need to use AppleScript anyway …

This pretty much applies, in my experience, to all OSA scriptable apps …

2 Likes

Works great ! Thank you !

2 Likes

Thank you :D. I had lines like this in my jxa bike.make({ new: 'row', withProperties: { name: 'Search Results' }, at: doc.rootRow })

1 Like

Fortunately, I found out that we can use JS array .unshift method !

As in:

(() => {
    'use strict';

    // jxaContext :: IO ()
    const jxaContext = () => {
        // main :: IO ()
        const main = () => {
            const
                bike = Application("bike"),
                doc = bike.documents.at(0),
                row = doc.rows.at(0),
                attributeRun = bike.AttributeRun({
                    bold: true
                }, "prepend text ")
            return (
                row
                .textContent
                .attributeRuns
                .unshift(attributeRun)
            )
        };

        // MAIN --
        return main();
    };

    return jxaContext();
})();
2 Likes

How did you figure that out?

Think that’s just a standard javascript array method.