Script to copy/paste tags to select columns (redux!)


#1

Hi,

I’ve got several OmniOutliner documents in which all of my tags fall under a Tag column. I’d like to create new columns, as subject categories, and then I’d like to populate those newly-created subject category columns with the tags that correspond with them (e.g. subject category column " Fruit " would contain tags “Bananas;Grapes;Apples” based on the tags contained in that row’s Tag column).

(The tags in my documents don’t have any hashtags or underscores, and they’re separated with semicolons, without any whitespace before after the tag – but including any whitespace that exists in between words within a tag. ** )

To that end, I’m seeking a script that will enable me to

  1. Assign which tags correspond with these newly-created “subject category” columns (i.e., first creating subject category columns " Fruit " and " Vegetables ", and then assigning Bananas, Grapes, Apples and Cabbage, Cauliflower, Brussel Sprouts to the categories, respectively)

  2. Copy the tags under each row’s Tag column…

  3. and then paste the to the cells under their respective “subject category” column for each row – while separating each tag with semicolons, without any whitespace before after the tag (i.e., subject category column " Fruit " would contain tags “Bananas;Grapes;Apples” and " Vegetables " would contain tags “Cabbage;Cauliflower;Brussel Sprouts” based on the tags contained in that row’s Tag column – and with that tag separation formatting ).

( ** As you can see, there’s space between Brussel Sprouts under the Vegetables tags – but no whitespace separation between the semicolons and the tags)

Does anyone have any suggestions for the steps I ought to take, and a script I could use, for this process?

Many thanks for any help you can provide…!


#2

Just wondering if anyone has any ideas / suggestions for a script I could use for the approach I’m seeking (outlined above).

Many thanks for your help!


#3

BTW, if no one has ideas here, is there another, suggested forum I should try?

Thanks again.


Using Clipboard in Omni Automation
#4

Another forum member suggested that I use screenshots to better illustrate what I’m seeking. Here’s an example of the kind of layout I’d be using in an OmniOutliner document…


Some quick background… I’m using documents that I’ve annotated with an app called MarginNote, in which it produces exported of annotated notes via OmniOutliner export (among other file types). OmniOutliner has kindly helped me figure out how to clean up MarginNote-exported files (since they’re still a bit rough). So, the “Topic” is the note title, " _note" is the body of the note, and “Book Title” refers to the name of the document. OmniOutliner also helped me capture all the tags and dates affixed to each note, by plane them in their respective columns.

As I mentioned above, "I’d like to create new columns, as subject categories, and then I’d like to populate those newly-created subject category columns with the tags that correspond with them (e.g. subject category column “Fruit” would contain tags “Bananas;Grapes;Apples” based on the tags contained in that row’s Tag column). So, here’s what it looks like when I manually create the new subject category columns Fruit and Vegetables

As I said above, I’m seeking a script that will enable me to:

  1. Assign which tags correspond with these newly-created “subject category” columns (i.e., first creating subject category columns Fruit and Vegetables, and then assigning Bananas, Grapes, Apples and Cabbage, Cauliflower, Brussel Sprouts to the categories, respectively)

  2. Copy the tags under each row ’s Tag column…

  3. and then paste the to the cells under their respective “subject category” column for each row – while separating each tag with semicolons, without any whitespace before after the tag (i.e., subject category column Fruit would contain tags “Bananas;Grapes;Apples” and Vegetables would contain tags “Cabbage;Cauliflower;Brussel Sprouts” based on the tags contained in that row’s Tag column – and with that tag separation formatting).

So, the ultimate end result of such a script would be represented in this kind of output…

Hope that’s helpful. Thanks for your help!


#5

A JXA approach (Script Editor – top left language tab set to JavaScript, or JavaScript for Automation Script Action in Keyboard Maestro, etc) might look something like:

( in which anything not listed as a fruit is assumed to be a vegetable )

(() => {
    'use strict';

    // main :: IO ()
    const main = () => {
        const
            appName = 'OmniOutliner',
            oo = Application(appName),
            ds = oo.documents,
            fruit = ['Apples', 'Bananas', 'Grapes'];
        return either(alert('Tag script'))(identity)(
            bindLR(
                0 < ds.length ? (
                    Right(ds.at(0))
                ) : Left('No documents open in ' + appName)
            )(doc => {
                const
                    cols = doc.columns,
                    rows = doc.rows,
                    allColNames = cols.name(),

                    // References to expected columns,
                    // found or created.
                    ks = ['Tags', 'Fruit', 'Vegetables'],
                    [colTags, colFruit, colVeg] = ks.map(
                        k => allColNames.includes(k) ? (
                            cols.byName(k)
                        ) : (() => {
                            const
                                newCol = oo.Column({
                                    name: k
                                });
                            return (
                                cols.push(newCol),
                                newCol
                            );
                        })());
                return Right(
                    rows().map(row => {
                        const cells = row.cells;
                        return zipWith(k => v => {
                            const tags = v.join(';');
                            return (
                                // Derived value (Fruit | Veg) updated.
                                cells.byName(k).value = tags,
                                0 < tags.length ? (
                                    row.topic.text() + ' -> ' +
                                    k + ' -> ' + tags
                                ) : ''
                            );
                        })(tail(ks))(
                            Array.from(
                                partition(k => fruit.includes(k))(
                                    splitOn(';')(
                                        cells.byName('Tags').value()
                                    )
                                )
                            )
                        ).join('\n')
                    }).join('\n')
                );
            }));
    };

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

    // alert :: String -> String -> IO String
    const alert = title => s => {
        const
            sa = Object.assign(Application.currentApplication(), {
                includeStandardAdditions: true
            });
        return (
            sa.activate(),
            sa.displayDialog(s, {
                withTitle: title,
                buttons: ['OK'],
                defaultButton: 'OK'
            }),
            s
        );
    };

    // ---------------------GENERIC FUNCTIONS---------------------
    // https://github.com/RobTrew/prelude-jxa

    // 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 => undefined !== m.Left ? (
            m
        ) : mf(m.Right);

    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        fr => e => 'Either' === e.type ? (
            undefined !== e.Left ? (
                fl(e.Left)
            ) : fr(e.Right)
        ) : undefined;

    // identity :: a -> a
    const identity = x => x;

    // partition :: (a -> Bool) -> [a] -> ([a], [a])
    const partition = p => xs =>
        xs.reduce(
            (a, x) =>
            p(x) ? (
                Tuple(a[0].concat(x))(a[1])
            ) : Tuple(a[0])(a[1].concat(x)),
            Tuple([])([])
        );

    // splitOn :: String -> String -> [String]
    const splitOn = pat => src =>
        /* A list of the strings delimited by
           instances of a given pattern in s. */
        src.split(pat)

    // tail :: [a] -> [a]
    const tail = xs => 0 < xs.length ? xs.slice(1) : [];

    // Tuple (,) :: a -> b -> (a, b)
    const Tuple = a => b => ({
        type: 'Tuple',
        '0': a,
        '1': b,
        length: 2
    });

    // zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
    const zipWith = f => xs => ys =>
        xs.slice(
            0, Math.min(xs.length, ys.length)
        ).map((x, i) => f(x)(ys[i]));

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

#6

Wow. Thanks so much for this!

So, I did use Script Editor, and select the top left language tab set to JavaScript (as you advised), and got:

Syntax Error

Error on line 1: SyntaxError: Unexpected token ‘)’

Did I do anything wrong? Also, I should explain a bit more about what I’m seeking, just to ensure we’re on the same page. I know I just created just two subject-category columns – I did so just to create an uncomplicated example. But when I’m actually using it, I might have around a dozen subject-category columns.

That’s why I was thinking about a process that would allow me to assign a certain set of tag values to their respective subject-category columns. Make sense?

Many thanks again for your help!


#7

The usual reason for that (unless you are using a very old macOS version), is that it can be hard to make sure you have copied all of the source text.

Try starting again, and making sure that you have selected, copied and pasted everything, including

at start:

(() => {
    'use strict';

    // main :: IO ()
    const main = () => {

and at finish (scrolling quite far down), every character of:

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

#8

Yes, that’s probably true. Because of my hardware I’m unable to upgrade beyond El Capitan. I’m using AppleScript 2.5 – Version 2.8.1 (183.1).

I’ve carefully followed your instructions, but this is the error message I keep getting…

As you can see, it looks like it’s registering the error at the top of the script.

Also, just want to make sure you caught what I wrote yesterday, namely:

Thanks again for your help!


#9

El Capitan

I think that may be the problem, JavaScript for Automation with ES6 JavaScript didn’t come till OS X Sierra.

You would need to write something analogous in AppleScript, or possibly ES5 JS.


#10

Shoot. Well…I don’t know much about script writing, alas. So, I’m unsure how to proceed at this junction.

Any other ideas / suggestions?

Thanks again…


#11

Well 巧妇难为无米之炊 (even the most skilled of house-keepers experiences difficulty in preparing a meal without ingredients)

I suppose the obvious options might include:

  • Hardware that can run the software you need, or
  • software than can run on the hardware you have, or
  • a beginners book on applescript.

#12

If I’m able to access another Mac that runs a more recent version of AppleScript, and then save that script and port it over to my Mac – do you think I’ll be able to open and run that script?

Thanks.


#13

What you need for ES6 JS is macOS Sierra or above.

It’s a JXA (JavaScript for Automation) script, so the version of AppleScript is not the issue.

The draft script was tested with:

  • macOS 10.4.6 (Mojave)
  • OmniOutliner 5.5.2 (v197.10.0)

#14

It’s still unclear to me just how the script would know what columns to add and which tags to place in each.


#15

You would have to write rules.

The draft above does just one layer of partitioning – any string not in a ‘fruit’ list is assumed vegetable, but it could be reshaped so that a set of:

([String], TagName)

tuples are recursively applied.

In short, if we want a long word for something simple, an ontology, or as it’s flattish, a thesaurus :-)


#16

I was imagining that I would manually create the subject-category columns, as illustrated in my second screenshot, and then somehow assign which tags would correspond with each subject-category column.


#17

A more general mismatch between the data structure you want (named groups of tags) and the (for many purposes excellent) OO column structure, is that none of the OO column types are really designed (for display, filtering etc) to work with multiple values.

You might get a better match from TaskPaper tagging like:

Smoothie @vegetables(cabbage,sprouts) @fruit(apples, bananas)

( TaskPaper 3 filtering does support this kind of structure, with searches like //@fruit contains bananas )


#18

In the meanwhile, I won’t have time to support this, so it may well not be a good choice in practice (unless you want to learn some scripting yourself), but here FWIW is a rough draft in AppleScript, which others are welcome to improve.

(You would have to edit the thesaurus() definition at the top to meet your needs).

(and, again, you would need to copy everything all the way down to end unlines)

-- Ver 0.03 (Creating an 'Other' column for tags not found (as spelled) in thesaurus)
-- Ver 0.02 (Reporting any tags not found in thesaurus)


-- thesaurus :: () -> [(String, [String])]
on thesaurus()
    set fruit to Tuple("Fruit", {"Apples", "Bananas", "Grapes"})
    set veg to Tuple("Vegetables", {"Cabbage", "Cauliflower", "Sprouts"})
    set clubs to Tuple("Clubs", {"Arsenal", "Liverpool", "Spurs"})
    {fruit, veg, clubs}
end thesaurus


---------------------------TEST----------------------------
on run
    tell application "OmniOutliner"
        if 0 < (count of documents) then
            my updatedTagColsFromThesaurus(my thesaurus(), front document)
        else
            "No document open in OmniOutliner"
        end if
    end tell
end run


---------------TAG COLUMNS FOUND (OR CREATED)---------------
-------------AND UPDATED IN TERMS OF THESAURUS-------------

-- updateTagColsFromThesaurus :: [(String, [String])] -> OO Doc -> IO String
on updatedTagColsFromThesaurus(lexicon, oDoc)
    using terms from application "OmniOutliner"
        set colNames to {"Tags"} & my map(my fst, lexicon)
        set tagCols to my map(my columnFoundOrCreated(oDoc), colNames)
        
        script go
            on |λ|(oRow)
                set {lstUnallocated, lstTaggings} to my listFromTuple(tagParse(lexicon, ¬
                    value of cell "Tags" of oRow))
                
                ----------ROW UPDATED FROM THESAURUS ENTRIES-----------
                script tagUpdates
                    on |λ|(acc, kvs)
                        set {label, vs} to kvs
                        set value of cell label of acc to my intercalate(";", vs)
                        acc
                    end |λ|
                end script
                my foldl(tagUpdates, oRow, lstTaggings)
                
                --------ANY REMAINING TAGS WITH NO THESAURUS ENTRY---------
                if 0 < length of lstUnallocated then
                    set colOther to |λ|("Other") of columnFoundOrCreated(oDoc)
                    set strOther to intercalate(";", lstUnallocated)
                    set value of cell "Other" of oRow to strOther
                    
                    set strMsg to anyUnallocated(strOther)
                else
                    set strMsg to ""
                end if
                
                ----------------------LOG OF RESULTS-----------------------
                set strTopic to topic of oRow
                if 0 < length of strTopic then
                    script report
                        on |λ|(colName)
                            set mb to (value of (cell colName of oRow)) as string
                            if 0 < length of mb then
                                {tab & colName & " : " & mb}
                            else
                                {}
                            end if
                        end |λ|
                    end script
                    
                    {strTopic & " ->\n" & my unlines(my concatMap(report, ¬
                        rest of colNames)) & linefeed & strMsg}
                else
                    {}
                end if
            end |λ|
        end script
        my unlines(my concatMap(go, rows of oDoc))
    end using terms from
end updatedTagColsFromThesaurus


-- anyUnallocated :: String -> String
on anyUnallocated(s)
    if 0 < length of s then
        "\tOTHER: " & s & linefeed
    else
        ""
    end if
end anyUnallocated


------------------------OO GENERIC-------------------------

-- columnFoundOrCreated :: OO Doc -> String -> OO Column
on columnFoundOrCreated(oDoc)
    script go
        on |λ|(strColName)
            using terms from application "OmniOutliner"
                set cols to columns of oDoc where name is strColName
                if 0 < (count of cols) then
                    item 1 of cols
                else
                    tell oDoc to make new column with properties {name:strColName}
                end if
            end using terms from
        end |λ|
    end script
end columnFoundOrCreated


-------------------------TAG PARSE-------------------------

-- tagParse ::[(String, [String])] -> String ->  ([String], [(String, [String])])
on tagParse(lexicon, strTags)
    -- Tuple resulting from a parse of a semi-colon delimited string
    --  in terms of a thesaurus.
    -- (Remaining unallocated tags, plus a list of (label, instances) pairs)
    set ks to splitOn(";", strTags)
    script go
        on |λ|(a, tpl)
            set residue to fst(a)
            if 0 < length of residue then
                set examples to snd(tpl)
                script p
                    on |λ|(x)
                        examples contains x
                    end |λ|
                end script
                set tplParts to partition(p, residue)
                
                set harvest to fst(tplParts)
                set unallocated to snd(tplParts)
                
                if 0 < length of harvest then
                    Tuple(unallocated, snd(a) & {{fst(tpl), harvest}})
                else
                    a
                end if
            else
                a
            end if
        end |λ|
    end script
    foldl(go, Tuple(ks, {}), lexicon)
end tagParse


--------------------------GENERIC--------------------------
-- https://github.com/RobTrew/prelude-applescript

-- Tuple (,) :: a -> b -> (a, b)
on Tuple(a, b)
    -- Constructor for a pair of values, possibly of two different types.
    {type:"Tuple", |1|:a, |2|:b, length:2}
end Tuple

-- concatMap :: (a -> [b]) -> [a] -> [b]
on concatMap(f, xs)
    set lng to length of xs
    set acc to {}
    tell mReturn(f)
        repeat with i from 1 to lng
            set acc to acc & (|λ|(item i of xs, i, xs))
        end repeat
    end tell
    return acc
end concatMap

-- foldl :: (a -> b -> a) -> a -> [b] -> a
on foldl(f, startValue, xs)
    tell mReturn(f)
        set v to startValue
        set lng to length of xs
        repeat with i from 1 to lng
            set v to |λ|(v, item i of xs, i, xs)
        end repeat
        return v
    end tell
end foldl

-- fst :: (a, b) -> a
on fst(tpl)
    if class of tpl is record then
        |1| of tpl
    else
        item 1 of tpl
    end if
end fst

-- intercalate :: String -> [String] -> String
on intercalate(delim, xs)
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, delim}
    set str to xs as text
    set my text item delimiters to dlm
    str
end intercalate

-- listFromTuple :: (a, a ...) -> [a]
on listFromTuple(tpl)
    items 2 thru -2 of (tpl as list)
end listFromTuple

-- map :: (a -> b) -> [a] -> [b]
on map(f, xs)
    -- The list obtained by applying f
    -- to each element of xs.
    tell mReturn(f)
        set lng to length of xs
        set lst to {}
        repeat with i from 1 to lng
            set end of lst to |λ|(item i of xs, i, xs)
        end repeat
        return lst
    end tell
end map

-- mReturn :: First-class m => (a -> b) -> m (a -> b)
on mReturn(f)
    -- 2nd class handler function lifted into 1st class script wrapper. 
    if script is class of f then
        f
    else
        script
            property |λ| : f
        end script
    end if
end mReturn

-- partition :: (a -> Bool) -> [a] -> ([a], [a])
on partition(f, xs)
    tell mReturn(f)
        set ys to {}
        set zs to {}
        repeat with x in xs
            set v to contents of x
            if |λ|(v) then
                set end of ys to v
            else
                set end of zs to v
            end if
        end repeat
    end tell
    Tuple(ys, zs)
end partition

-- snd :: (a, b) -> b
on snd(tpl)
    if class of tpl is record then
        |2| of tpl
    else
        item 2 of tpl
    end if
end snd

-- splitOn :: String -> String -> [String]
on splitOn(pat, src)
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, pat}
    set xs to text items of src
    set my text item delimiters to dlm
    return xs
end splitOn

-- unlines :: [String] -> String
on unlines(xs)
    -- A single string formed by the intercalation
    -- of a list of strings with the newline character.
    set {dlm, my text item delimiters} to ¬
        {my text item delimiters, linefeed}
    set str to xs as text
    set my text item delimiters to dlm
    str
end unlines

#19

Wow. Thank you so much, @draft8 ! This is amazing…! BTW, I’m sorry for my late reply to this – I was in transit during the time that you posted this.

I copied your script, pasted into Script Editor, and ran it – and it works brilliantly! So, HUGE thanks!

I’ve just got one question…

We were working off of a simple example of an OmniOutliner file that I shared in the forum as a screenshot, just so I could better convey what I was seeking. Obviously it successfully led to the script you kindly produced for me. In real life, I often use a dozen+ subject category columns (e.g., “Fruit,” “Vegetables,” etc.) and scores of tag - values for each of them.

I’m happy to copy and paste the subject categories and their respective categories in the thesaurus sections of your script, but I’m wondering: is there any way to tweak it so that, upon running it, I can set up these values within a discrete windows that would include something like “For categories” -> [and then an area where I can include one subject category (e.g., Fruit), and then beside it “Tags include” -> [where I can paste all of the tag - values]?

I really don’t mean to make more work, but I’m asking because I’d be using this script for multiple OmniOutliner files, and so it seems like such a window - interface for setting up subject categories and their respective categories would ensure accuracy / prevent errors.

I welcome any ideas / suggestions you might have, and I thank you very much again for all of your tremendous help. I’m enormously grateful.


#20

That would entail a fair amount of work I think, and would probably enlarge the surface area for potential glitches too.

On alternative to hard-coded thesaurus records at the top of the code might be to specify a text file containing the relevant thesaurus.

(The text file could be TaskPaper or any other kind of tab-indented outline:

Fruit
    Apples
    Bananas
    Grapes
Vegetable
    Cabbage
    Cauliflower
    Sprouts
Clubs
    Arsenal
    Liverpool
    Spurs

etc

Even this would, of course, require a bit more coding, perhaps involving functions like:

use AppleScript version "2.4"
use framework "Foundation"
use scripting additions

-- doesFileExist :: FilePath -> IO Bool
on doesFileExist(strPath)
    set ca to current application
    set oPath to (ca's NSString's stringWithString:strPath)'s ¬
        stringByStandardizingPath
    set {bln, int} to (ca's NSFileManager's defaultManager's ¬
        fileExistsAtPath:oPath isDirectory:(reference))
    bln and (int ≠ 1)
end doesFileExist

-- readFile :: FilePath -> IO String
on readFile(strPath)
    set ca to current application
    set e to reference
    set {s, e} to (ca's NSString's ¬
        stringWithContentsOfFile:((ca's NSString's ¬
            stringWithString:strPath)'s ¬
            stringByStandardizingPath) ¬
            encoding:(ca's NSUTF8StringEncoding) |error|:(e))
    if missing value is e then
        s as string
    else
        (localizedDescription of e) as string
    end if
end readFile

and so forth.