Tinderbox Forum

While waiting for Apple to fix Watched Notes-

What’s the best way I might import a few hundred Apple Notes into TB?
They contain tags and are nested topically in folders.

Thanks!
Art

There isn’t a direct method, unless you roll your own workflow in Automator (i.e using AppleScript). In part, having Googled a bit, Apple doesn’t make it easy to export your notes. You can export your Notes data - if the notes are on iCloud - as part of exporting “your data” from your Apple account. That gives you a zip with text notes nested in folders as per the original notes and with any embedded images as separate assets.

However, the latter only helps partially. If you drag in (folder-)nested text notes to Tinderbox, you will get the nesting preserved but you will get a note/container per folder and well as a note for the actual text files.

Sadly Notes doesn’t export to OPML or a useful outline-type format like that.

Also bear in mind any untested assumptions about formatting or embedding in your notes, such as OPML.

If notes are just ordinary text with no exotic styling or embedded assets (e.g. images) you are less likely to encounter further issues with the text in exported-then-imported notes.

So, the limitation is primarily that apple clearly sees Notes as a note creation device rather than as the head of a data interchange workflow.

If I had the need, and the AppleScript skills, I’d use that to read the source Notes and copy/create in Tinderbox. Perhaps an AppleScripter may want to stick their head above the parapet here with a solution (or, to say why this isn’t a good idea).

1 Like

I can sketch a first draft of a script tomorrow if you want to tell us a little more about the typical pattern, e.g. :

  • all folders or a selection of folders ?
  • all notes or only those created or modified after a certain moment ?
  • or only those bearing certain tags ?

etc …

also:

  • direct from Notes to the front TBX document ?
  • or Notes to something like an OPML or TaskPaper or Tinderbox outline file ?
  • or to the clipboard ?

Plus (forgive my ignorance – I don’t use Notes – how is the tagging done ?)

2 Likes

This may be harder than it seems, @ComplexPoint. In 10.15, I think you’ll find that all Notes scripting and automation is broken. (But if it’s fixed in the latest patch or if you find a workaround, hooray and let me know). In 10.14, current mechanisms work.

1 Like

I had forgetten that some have already taken the plunge into 10.15 :slight_smile:

(Using 10.14 out of habit here – I usually wait until Apple is about to produce the next iteration)

2 Likes

Thanks for the leads, all! I found the following app/script that are able to “brute-export” Notes files individually - without folder-nesting, and also including html line-break code which I guess I’ll need to strip out before importing into TB (my files were rtf for the most part, using bold/italics).

http://writeapp.net/notesexporter/ (from the people behind Write app)
https://bear.app/faq/Import%20&%20export/Migrate%20from%20Apple%20Notes/ (from the people behind Bear app)

It’s not pretty, and will require some hand-clean-up (284 files, ow), but it’ll have to do for now.

  1. All folders
  2. All Notes
  3. The “tags” are informal - Notes doesn’t support tagging, but I use hashtags in my notes anyway to help delineate attribute values
    4)The Notes are headed straight to TBX
  4. I use Taskpaper as well, just in case that’s a destination/transit option.

Is your operating system pre-Catalina ?

Mine is 10.14.6, which means that as @eastgate points out, I may be living in a fool’s (or technical conservative’s) paradise in relation to Notes scripting.

I’ll sketch something that works here on Mojave, but unfortunately I don’t have a Catalina machine to hand which I can test on.

1 Like

Assuming tag values have no spaces and have a hash as character #1 (i.e. ‘#new-projects’ vs ‘# new projects’), then it should be possible to detect and copy (move?) them to $Tags as part of the ingest or once the notes are in Tinderbox. It probably helps if the tags are consistently at the start, or end, of a note to cut down the changes for incorrect detection.

FWIW, still on Mojave here too.

1 Like

Yes - Catalina, unfortunately.

That’s the plan, @mwra. Although I may just drop them into csv tables, setup attributes, then import.

Well, given that context, let’s start with a small experiment, to see whether we have a basis for a direct import into Tinderbox.

What happens if you run this AppleScript fragment in Script Editor ?

(Or rather, what, if anything, do you see in the Results panel at the foot of Script Editor – you may need to click the small icon for ‘Show or hide the log’ to reveal the results panel)

(Copy and paste the whole of this code, scrolling right down to the last line at end mReturn)

use AppleScript version "2.4" -- Yosemite (10.10) or later
use scripting additions


on run
    tell application "Notes"
        set refNotes to a reference to (notes where password protected is false)
        set values to {its name, its body, its creation date, its modification date} of refNotes
        set tuples to my transpose(values)
    end tell
end run


-- transpose :: [[a]] -> [[a]]
on transpose(rows)
    script cols
        on |λ|(_, iCol)
            script cell
                on |λ|(row)
                    item iCol of row
                end |λ|
            end script
            concatMap(cell, rows)
        end |λ|
    end script
    map(cols, item 1 of rows)
end transpose


-- 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

-- 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
1 Like

After a couple minutes crunching, the entire text of all 284 notes (along with some header and timestamp info and html tags) is displayed on the Results panel.
I was able to import and explode the text into around 500 notes (some are just duplicate (", date) lines), so cleanup should be relatively simple and I have the original creation dates as a bonus!
Thanks :slight_smile:

That’s encouraging …

If that worked, and you can wait until this evening or tomorrow morning, then I think we should be able to more or less automate the whole thing, including tag extraction and (if you are happy to install https://pandoc.org, which a script can use) the clearing up of HTML markup.

2 Likes

There are various ways we could do this, but for the moment, here are two draft scripts for a two-stage second experiment:

  1. Script 1 - copy the current Notes database to the clipboard in an XML format compatible with the Tinderbox clipboard (simplified TBX only, not full enough for opening from a text file)

  2. Script 2 - Paste the XML from the clipboard into the front TBX document as an outline of notes.

NB both of these scripts (below) are drafted in JavaScript for Automation, so in Script Editor you would need to set the language drop-down at top left to JavaScript rather than AppleScript.

NB2 You don’t need to have Pandoc installed for these scripts to work, but if you do, the first script will be able to use it to clean up any HTML markup in your Apple Notes documents.

JS drafts behind disclosure triangles below:

Make sure to copy the whole of each script. This first one ends with

    return main();
})();
copy the current Notes database to the clipboard in an XML format
(() => {
    'use strict';

    ObjC.import('AppKit');

    // Copy current Apple Notes database to clipboard as XML 
    // (in a format compatible with the Tinderbox 8 clipboard)

    // Rob Trew 2020 
    // Ver 0.01

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

        // A list of generic tree structures,
        // representing folders of notes.
        // (in the Apple Notes app)
        const noteForest = map(fmapTree(hashTagsExtracted))(
            forestFromGroups(
                groupsFromNotes(
                    Application('Notes')
                    .defaultAccount.notes.where({
                        passwordProtected: false
                    })
                )
            )
        );

        // With text cleaned (of HTML markup) by Pandoc
        // if it is installed.
        // ( macOS installer at https://pandoc.org )
        return copyText(
            tbxXMLFromForest(
                either(
                    constant(noteForest)
                )(textTranslatedByPandoc(noteForest))(
                    pandocPath()
                )
            )
        );
    };

    // -------------------TINDERBOX XML--------------------

    // asPlainText :: String -> String
    const asPlainText = (sa, fpPandoc) => htmlText =>
        // A plain text translation of htmlText, produced by a copy
        // of [pandoc](https://pandoc.org) at the given file path.
        // ( See the pandocPath() function below )
        sa.doShellScript(
            `echo "${htmlText}" | ${fpPandoc} -f html -t plain`
        );

    // forestFromGroups :: [[[a]]] -> [Tree Dict]
    const forestFromGroups = groups =>
        // A list of folder trees, derived from
        // lists of notes, in which each note is
        // itself a list of values.
        // ('FolderName', 'Name', 'Text', 'Created', 'Modified')
        map(group => Node({
            Name: group[0][0]
        })(
            map(
                xs => Node(
                    zip(['Name', 'Text', 'Created', 'Modified'])(
                        xs.slice(1)
                    ).reduce(
                        (a, kv) => Object.assign(
                            a, {
                                [kv[0]]: kv[1]
                            }
                        ), {}
                    )
                )([])
            )(group)
        ))(groups);

    // groupsFromNotes :: Notes Object -> [[[a]]]
    const groupsFromNotes = notesRef =>
        // Groups of lists of notes, where
        // each note is a list of values.
        groupBy(on(eq)(fst))(
            sortBy(comparing(fst))(
                transpose([
                    map(lookup('name'))(
                        notesRef.container()
                    ),
                    ...map(flip(lookup)(notesRef))([
                        'name', 'body',
                        'creationDate',
                        'modificationDate'
                    ])
                ])
            )
        );

    // hashTagsExtracted :: Dict -> Dict
    const hashTagsExtracted = dict => {
        // An updated dictionary, with a list of
        // any hash tag names extracted from .Name
        // .Text, and separately entered as .Tags
        const
            strName = dict['Name'] || '',
            strText = dict['Text'] || '';
        return Boolean(strName || strText) ? (() => {
            const [tplName, tplText] = [strName, strText].map(
                textAndHashTagList
            );
            const
                strPlainText = tplText[0].trim(),
                tags = tplName[1].concat(tplText[1]);
            return Object.assign({}, dict, {
                'Name': tplName[0]
            }, Boolean(strPlainText) ? {
                'Text': strPlainText
            } : {}, 0 < tags.length ? {
                'Tags': tags
            } : {});
        })() : dict;
    };

    // pandocPath :: IO () -> Either String FilePath
    const pandocPath = () => {
        // True if [pandoc](https://pandoc.org) is installed,
        // and we can use if from HTML to a text format
        // like Markdown or MultiMarkdown.
        // standardAdditions (for shell script use of pandoc)
        try {
            return Right(
                Object.assign(
                    Application.currentApplication(), {
                        includeStandardAdditions: true
                    })
                .doShellScript('command -v pandoc')
            );
        } catch (e) {
            return Left('Pandoc not found.');
        }
    };

    // tbxXMLFromForest :: [Tree Dict] -> XML String
    const tbxXMLFromForest = trees =>
        unlines([
            '<?xml version="1.0" encoding="UTF-8" ?>',
            '<tinderbox version="2" revision="12" >',
            xmlTag(false)('item')([])(
                unlines(
                    map(
                        tpl => xmlTag(true)('attribute')(
                            [Tuple('name')(fst(tpl))]
                        )(snd(tpl))
                    )([
                        Tuple('Name')('ImportedNote'),
                        Tuple('IsPrototype')('true'),
                        Tuple('NeverComposite')('true'),
                        Tuple('KeyAttributes')('Tags;Created;Modified'),
                        Tuple('HTMLDontExport')('true'),
                        Tuple('HTMLExportChildren')('false')
                    ])
                )
            ),
            unlines(trees.map(
                foldTree(x => xs =>
                    xmlTag(false)('item')(
                        [Tuple('proto')('ImportedNote')]
                    )(
                        // ATTRIBUTES,
                        unlines(Object.keys(x).flatMap(
                            k => {
                                const v = x[k];
                                return 'Text' !== k ? ([
                                    xmlTag(true)('attribute')([
                                        Tuple('name')(k)
                                    ])('Tags' !== k ? (
                                        'Date' !== v.constructor.name ? (
                                            v
                                        ) : iso8601Local(v).replace(
                                            '.000Z', 'Z'
                                        )
                                    ) : v.join(';'))
                                ]) : []
                            })) + (
                            // ANY TEXT,
                            Boolean(x['Text']) ? (
                                '\n' + xmlTag(true)('text')([])(x.Text)
                            ) : ''
                        ) + (
                            // AND ANY CHILDREN.
                            0 < xs.length ? (
                                '\n' + unlines(xs)
                            ) : ''
                        )
                    )
                )
            )),
            '</tinderbox>'
        ]);

    // textAndHashTagList :: String -> (String, [String])
    const textAndHashTagList = s =>
        // A tuple of the tag-stripped body text, and
        // a list of tag names (without their hash-prefixes).
        0 < s.length ? (
            Tuple(s.replace(/#\w+\s?/g, ''))(
                map(compose(tail, fst))(
                    regexMatches(/#\w+/g)(s)
                )
            )
        ) : Tuple('')([]);

    // textTranslatedByPandoc :: Tree Dict -> FilePath -> Tree Dict
    const textTranslatedByPandoc = forest => fpPandoc => {
        // Any HTML mark up in Text fields cleaned up by Pandoc
        // [pandoc](https://pandoc.org)  (if installed)
        const sa = Object.assign(
            Application.currentApplication(), {
                includeStandardAdditions: true
            });
        return forest.map(fmapTree(
            dict => {
                const txt = dict.Text;
                return txt && txt.includes('</') ? (
                    Object.assign({}, dict, {
                        'Text': asPlainText(sa, fpPandoc)(txt)
                    })
                ) : dict;
            }
        ));
    };

    // xmlTag :: String -> [(String, String)] -> String
    const xmlTag = blnSingleLine =>
        // An XML of the given name,
        // with any name-value attribute pairs,
        // and enclosing a content string.
        name => kvs => content =>
        `<${name}${
                0 < kvs.length ? (
                    ' ' + unwords(kvs.map(
                        kv => kv[0] + '="' + kv[1] + '"'
                    ))
                ) : ''
            }>${blnSingleLine ? (
                content
            ) : '\n' + content + '\n'}</${name}>`;

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

    // copyText :: String -> IO String
    const copyText = s => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();

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

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

    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: 'Node',
            root: v,
            nest: xs || []
        });

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

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

    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        x => y => {
            const
                a = f(x),
                b = f(y);
            return a < b ? -1 : (a > b ? 1 : 0);
        };

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        x => fs.reduceRight((a, f) => f(a), x);

    // constant :: a -> b -> a
    const constant = k =>
        _ => k;

    // 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;

    // eq (==) :: Eq a => a -> a -> Bool
    const eq = a =>
        // True when a and b are equivalent in the terms
        // defined below for their shared data type.
        b => {
            const t = typeof a;
            return t !== typeof b ? (
                false
            ) : 'object' !== t ? (
                'function' !== t ? (
                    a === b
                ) : a.toString() === b.toString()
            ) : (() => {
                const kvs = Object.entries(a);
                return kvs.length !== Object.keys(b).length ? (
                    false
                ) : kvs.every(([k, v]) => eq(v)(b[k]));
            })();
        };

    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = f =>
        1 < f.length ? (
            (a, b) => f(b, a)
        ) : (x => y => f(y)(x));

    // fmapTree :: (a -> b) -> Tree a -> Tree b
    const fmapTree = f =>
        // A new tree. The result of a structure-preserving
        // application of f to each root in the existing tree.
        tree => {
            const go = x => Node(f(x.root))(
                x.nest.map(go)
            );
            return go(tree);
        };

    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f =>
        // The catamorphism on trees. A summary
        // value obtained by a depth-first fold.
        tree => {
            const go = x => f(x.root)(
                x.nest.map(go)
            );
            return go(tree);
        };

    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];

    // group :: [a] -> [[a]]
    const group = xs => {
        // A list of lists, each containing only equal elements,
        // such that the concatenation of these lists is xs.
        const go = xs =>
            0 < xs.length ? (() => {
                const
                    h = xs[0],
                    i = xs.findIndex(x => h !== x);
                return i !== -1 ? (
                    [xs.slice(0, i)].concat(go(xs.slice(i)))
                ) : [xs];
            })() : [];
        return go(xs);
    };


    // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
    const groupBy = fEq =>
        // Typical usage: groupBy(on(eq)(f), xs)
        xs => 0 < xs.length ? (() => {
            const
                tpl = xs.slice(1).reduce(
                    (gw, x) => {
                        const
                            gps = gw[0],
                            wkg = gw[1];
                        return fEq(wkg[0])(x) ? (
                            Tuple(gps)(wkg.concat([x]))
                        ) : Tuple(gps.concat([wkg]))([x]);
                    },
                    Tuple([])([xs[0]])
                );
            return tpl[0].concat([tpl[1]])
        })() : [];

    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without finite
        // length. This enables zip and zipWith to choose
        // the shorter argument when one is non-finite,
        // like cycle, repeat etc
        (Array.isArray(xs) || 'string' === typeof xs) ? (
            xs.length
        ) : Infinity;

    // lookup :: String -> Dict -> a
    const lookup = k =>
        // The value returned from obj by method k
        // Not a total function – assumes that k exists.
        obj => {
            const method = obj[k];
            return method.exists ? (
                method()
            ) : undefined;
        };

    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f to each element of xs.
        // (The image of xs under f).
        xs => (Array.isArray(xs) ? (
            xs
        ) : xs.split('')).map(f);

    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = f =>
        // e.g. sortBy(on(compare,length), xs)
        g => a => b => f(g(a))(g(b));

    // regexMatches :: Regex -> String -> [[String]]
    const regexMatches = rgx =>
        // All matches of the given (global /g) regex in
        strHay => {
            let m = rgx.exec(strHay),
                xs = [];
            while (m)(xs.push(m), m = rgx.exec(strHay));
            return xs;
        };

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
        xs => xs.slice()
        .sort(uncurry(f));

    // tail :: [a] -> [a]
    const tail = xs =>
        // A new list consisting of all
        // items of xs except the first.
        0 < xs.length ? xs.slice(1) : [];


    // take :: Int -> [a] -> [a]
    // take :: Int -> String -> String
    const take = n =>
        // The first n elements of a list,
        // string of characters, or stream.
        xs => 'GeneratorFunction' !== xs
        .constructor.constructor.name ? (
            xs.slice(0, n)
        ) : [].concat.apply([], Array.from({
            length: n
        }, () => {
            const x = xs.next();
            return x.done ? [] : [x.value];
        }));


    // transpose :: [[a]] -> [[a]]
    const transpose = rows =>
        // The columns of the input transposed
        // into new rows.
        // Simpler version of transpose_, assuming input
        // rows of even length.
        0 < rows.length ? rows[0].map(
            (x, i) => rows.flatMap(
                x => x[i]
            )
        ) : [];

    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        (x, y) => f(x)(y);


    // unlines :: [String] -> String
    const unlines = xs =>
        // A linefeed-delimited string constructed
        // from the list of lines in xs.
        xs.join('\n');

    // unwords :: [String] -> String
    const unwords = xs => xs.join(' ');

    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
        // Use of `take` and `length` here allows for zipping with non-finite
        // lists - i.e. generators like cycle, repeat, iterate.
        ys => {
            const
                lng = Math.min(length(xs), length(ys)),
                vs = take(lng)(ys);
            return take(lng)(xs).map(
                (x, i) => Tuple(x)(vs[i])
            );
        };

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

The second script also ends with

// MAIN ---
    return main();
})();
Paste from the current clipboard into Tinderbox as TBX notes with $Tags
(() => {
    'use strict';

    // Paste TBX XML as (Tinderbox 8) note(s) and line(s)

    // Rob Trew 2019
    // Ver 0.1

    ObjC.import('AppKit');

    // main :: IO ()
    const main = () =>
        either(alert('Pasting TBX as note(s)'))(x => x)(
            bindLR(clipTextLR())(
                strClip => isPrefixOf('<?xml')(strClip) ? (() => {
                    // Well-formed XML in the text clipboard ?
                    const eXML = $();
                    return bindLR(
                        $.NSXMLDocument.alloc
                        .initWithXMLStringOptionsError(strClip, 0, eXML)
                        .isNil() ? Left(
                            'Problem in clipboard XML:\n\n' +
                            ObjC.unwrap(eXML.localizedDescription)
                        ) : Right(strClip)
                    )(strXML => {
                        const tbx = Application('Tinderbox 8');
                        return (
                            tbx.activate(),
                            setClipOfTextType(
                                'com.eastgate.tinderbox.scrap'
                            )(strXML),
                            //delay(0.05),
                            menuItemClickedLR('Tinderbox 8')([
                                'Edit', 'Paste'
                            ])
                        );
                    });
                })() : Left('No XML found in clipboard.')
            )
        );

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

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

    // menuItemClickedLR :: String -> [String] -> Either String IO String
    const menuItemClickedLR = strAppName => lstMenuPath => {
        const intMenuPath = lstMenuPath.length;
        return intMenuPath > 1 ? (() => {
            const
                appProcs = Application('System Events')
                .processes.where({
                    name: strAppName
                });
            return appProcs.length > 0 ? (() => {
                Application(strAppName).activate();
                return bindLR(
                    lstMenuPath.slice(1, -1)
                    .reduce(
                        (lra, x) => bindLR(lra)(a => {
                            const menuItem = a.menuItems[x];
                            return menuItem.exists() ? (
                                Right(menuItem.menus[x])
                            ) : Left('Menu item not found: ' + x);
                        })(),
                        (() => {
                            const
                                k = lstMenuPath[0],
                                menu = appProcs[0].menuBars[0]
                                .menus.byName(k);
                            return menu.exists() ? (
                                Right(menu)
                            ) : Left('Menu not found: ' + k)
                        })()
                    )
                )(xs => {
                    const
                        k = lstMenuPath[intMenuPath - 1],
                        items = xs.menuItems,
                        strPath = [strAppName]
                        .concat(lstMenuPath).join(' > ');
                    return bindLR(
                        items[k].exists() ? (
                            Right(items[k])
                        ) : Left('Menu item not found: ' + k)
                    )(menuItem => menuItem.enabled() ? (
                        Right((
                            menuItem.click(),
                            'Clicked: ' + strPath
                        ))
                    ) : Left(
                        'Menu item disabled : ' + strPath
                    ))
                })
            })() : Left(strAppName + ' not running.');
        })() : Left('MenuItemClicked needs a menu path of 2+ items.');
    };

    // clipTextLR :: () -> Either String String
    const clipTextLR = () => {
        const v = ObjC.unwrap($.NSPasteboard.generalPasteboard
            .stringForType($.NSPasteboardTypeString));
        return Boolean(v) && v.length > 0 ? (
            Right(v)
        ) : Left('No utf8-plain-text found in clipboard');
    };

    // setClipOfTextType :: String -> String -> IO Bool
    const setClipOfTextType = utiOrBundleID => txt => {
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(txt),
                utiOrBundleID
            )
        );
    };

    // 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;


    // isPrefixOf :: String -> String -> Bool
    const isPrefixOf = xs => ys =>
        ys.startsWith(xs);

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

The latter (pasting Notes from XML) script is also available as a Keyboard Maestro Macro: Paste TBX XML as note(s) and links.kmmacros.zip (11.5 KB)

1 Like

If those separate scripts seem to work, then you may find that you can directly use this single script,

(again, run from Script Editor with language set to JavaScript at top left, or from a Keyboard Maestro Execute JavaScript action, or FastScripts-assigned keystroke, etc)

which aims to copy all all Apple Notes (as long as they are not password protected) into the clipboard in a form that can be immediately and directly pasted into an empty Tinderbox document.

(Full one-stop-shop script below – ends at:

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

If Pandoc is installed on your system, the script can use it to clean any HTML markup back to unmarked plain text.

JavaScript for Automation

(() => {
    'use strict';

    ObjC.import('AppKit');

    // Copy all folders and notes in the Apple Notes database
    // (except any which are password-protected)
    // to the clipboard for direct pasting as Tinderbox notes
    // with extracted $Tag values.

    // Rob Trew 2020
    // Ver 0.05

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

        // A list of generic tree structures,
        // representing folders of notes.
        // (in the Apple Notes app)
        const
            noteForest = map(fmapTree(hashTagsExtracted))(
                forestFromGroups(
                    groupsFromNotes(
                        Application('Notes')
                        .defaultAccount.notes.where({
                            passwordProtected: false
                        })
                    )
                )
            ),
            strClip = tbxXMLFromForest(
                either(
                    constant(noteForest)
                )(textTranslatedByPandoc(noteForest))(
                    pandocPath()
                )
            );

        return alert('Copy from Notes as Tinderbox')(
            either(identity)(constant(
                'Apple Notes copied to clipboard as Tinderbox 8 notes.\n\n' +
                '( Try pasting into an empty Tinderbox document. )'
            ))(
                bindLR(
                    isPrefixOf('<?xml')(strClip) ? (
                        Right(strClip)
                    ) : Left('Tinderbox XML not copied from Notes')
                )(
                    compose(
                        Right,
                        setClipOfTextType(
                            'com.eastgate.tinderbox.scrap'
                        )
                    )
                )
            )
        );
    };

    // -------------------TINDERBOX XML--------------------

    // asPlainText :: String -> String
    const asPlainText = (sa, fpPandoc) => htmlText =>
        // A plain text translation of htmlText, produced by a copy
        // of [pandoc](https://pandoc.org) at the given file path.
        // ( See the pandocPath() function below )
        sa.doShellScript(
            `echo "${htmlText}" | ${fpPandoc} -f html -t plain`
        );

    // forestFromGroups :: [[[a]]] -> [Tree Dict]
    const forestFromGroups = groups =>
        // A list of folder trees, derived from
        // lists of notes, in which each note is
        // itself a list of values.
        // ('FolderName', 'Name', 'Text', 'Created', 'Modified')
        map(grp => Node({
            Name: grp[0][0]
        })(
            map(
                xs => Node(
                    zip(['Name', 'Text', 'Created', 'Modified'])(
                        xs.slice(1)
                    ).reduce(
                        (a, kv) => Object.assign(
                            a, {
                                [kv[0]]: kv[1]
                            }
                        ), {}
                    )
                )([])
            )(grp)
        ))(groups);

    // groupsFromNotes :: Notes Object -> [[[a]]]
    const groupsFromNotes = notesRef =>
        // Groups of lists of notes, where
        // each note is a list of values.
        groupBy(on(eq)(fst))(
            sortBy(comparing(fst))(
                transpose([
                    map(lookup('name'))(
                        notesRef.container()
                    ),
                    ...map(flip(lookup)(notesRef))([
                        'name', 'body',
                        'creationDate',
                        'modificationDate'
                    ])
                ])
            )
        );

    // hashTagsExtracted :: Dict -> Dict
    const hashTagsExtracted = dict => {
        // An updated dictionary, with a list of
        // any hash tag names extracted from .Name
        // .Text, and separately entered as .Tags
        const
            strName = dict['Name'] || '',
            strText = dict['Text'] || '';
        return Boolean(strName || strText) ? (() => {
            const [tplName, tplText] = [strName, strText].map(
                textAndHashTagList
            );
            const
                strPlainText = tplText[0].trim(),
                tags = tplName[1].concat(tplText[1]);
            return Object.assign({}, dict, {
                'Name': tplName[0]
            }, Boolean(strPlainText) ? {
                'Text': strPlainText
            } : {}, 0 < tags.length ? {
                'Tags': tags
            } : {});
        })() : dict;
    };

    // pandocPath :: IO () -> Either String FilePath
    const pandocPath = () => {
        // True if [pandoc](https://pandoc.org) is installed,
        // and we can use if from HTML to a text format
        // like Markdown or MultiMarkdown.
        // standardAdditions (for shell script use of pandoc)
        try {
            return Right(
                Object.assign(
                    Application.currentApplication(), {
                        includeStandardAdditions: true
                    })
                .doShellScript('command -v pandoc')
            );
        } catch (e) {
            return Left('Pandoc not found.');
        }
    };

    // tbxXMLFromForest :: [Tree Dict] -> XML String
    const tbxXMLFromForest = trees =>
        unlines([
            '<?xml version="1.0" encoding="UTF-8" ?>',
            '<tinderbox version="2" revision="12" >',
            xmlTag(false)('item')([])(
                unlines(map(
                    tpl => xmlTag(true)('attribute')(
                        [Tuple('name')(fst(tpl))]
                    )(snd(tpl))
                )([
                    Tuple('Name')('ImportedNote'),
                    Tuple('IsPrototype')('true'),
                    Tuple('NeverComposite')('true'),
                    Tuple('KeyAttributes')('Tags;Created;Modified'),
                    Tuple('HTMLDontExport')('true'),
                    Tuple('HTMLExportChildren')('false')
                ]))
            ),
            unlines(trees.map(
                foldTree(x => xs =>
                    xmlTag(false)('item')(
                        [Tuple('proto')('ImportedNote')]
                    )(
                        // ATTRIBUTES,
                        unlines(Object.keys(x).flatMap(
                            k => {
                                const v = x[k];
                                return 'Text' !== k ? ([
                                    xmlTag(true)('attribute')([
                                        Tuple('name')(k)
                                    ])('Tags' !== k ? (
                                        'Date' !== v.constructor.name ? (
                                            v
                                        ) : iso8601Local(v).replace(
                                            '.000Z', 'Z'
                                        )
                                    ) : v.join(';'))
                                ]) : []
                            })) + (
                            // ANY TEXT,
                            Boolean(x['Text']) ? (
                                '\n' + xmlTag(true)('text')([])(x.Text)
                            ) : ''
                        ) + (
                            // AND ANY CHILDREN.
                            0 < xs.length ? (
                                '\n' + unlines(xs)
                            ) : ''
                        )
                    )
                )
            )),
            '</tinderbox>'
        ]);

    // textAndHashTagList :: String -> (String, [String])
    const textAndHashTagList = s =>
        // A tuple of the tag-stripped body text, and
        // a list of tag names (without their hash-prefixes).
        0 < s.length ? (
            Tuple(s.replace(/#\w+\s?/g, ''))(
                map(compose(tail, fst))(
                    regexMatches(/#\w+/g)(s)
                )
            )
        ) : Tuple('')([]);

    // textTranslatedByPandoc :: Tree Dict -> FilePath -> Tree Dict
    const textTranslatedByPandoc = forest => fpPandoc => {
        // Any HTML mark up in Text fields cleaned up by Pandoc
        // [pandoc](https://pandoc.org)  (if installed)
        const sa = Object.assign(
            Application.currentApplication(), {
                includeStandardAdditions: true
            });
        return forest.map(fmapTree(
            dict => {
                const txt = dict.Text;
                return txt && txt.includes('</') ? (
                    Object.assign({}, dict, {
                        'Text': asPlainText(sa, fpPandoc)(txt)
                    })
                ) : dict;
            }
        ));
    };

    // xmlTag :: String -> [(String, String)] -> String
    const xmlTag = blnSingleLine =>
        // An XML tag of the given name,
        // with any name-value attribute pairs,
        // and an enclosed content string.
        name => kvs => content =>
        `<${name}${
            0 < kvs.length ? (
                ' ' + unwords(kvs.map(
                    kv => kv[0] + '="' + kv[1] + '"'
                ))
            ) : ''
        }>${blnSingleLine ? (
            content
        ) : '\n' + content + '\n'}</${name}>`;


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

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


    // copyText :: String -> IO String
    const copyText = s => {
        // String copied to general pasteboard.
        const pb = $.NSPasteboard.generalPasteboard;
        return (
            pb.clearContents,
            pb.setStringForType(
                $(s),
                $.NSPasteboardTypeString
            ),
            s
        );
    };

    // iso8601Local :: Date -> String
    const iso8601Local = dte =>
        new Date(dte - (6E4 * dte.getTimezoneOffset()))
        .toISOString();


    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;
            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };

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

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

    // Node :: a -> [Tree a] -> Tree a
    const Node = v =>
        // Constructor for a Tree node which connects a
        // value of some kind to a list of zero or
        // more child trees.
        xs => ({
            type: 'Node',
            root: v,
            nest: xs || []
        });

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

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

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

    // comparing :: (a -> b) -> (a -> a -> Ordering)
    const comparing = f =>
        x => y => {
            const
                a = f(x),
                b = f(y);
            return a < b ? -1 : (a > b ? 1 : 0);
        };

    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        x => fs.reduceRight((a, f) => f(a), x);

    // constant :: a -> b -> a
    const constant = k =>
        _ => k;

    // 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;

    // eq (==) :: Eq a => a -> a -> Bool
    const eq = a =>
        // True when a and b are equivalent in the terms
        // defined below for their shared data type.
        b => {
            const t = typeof a;
            return t !== typeof b ? (
                false
            ) : 'object' !== t ? (
                'function' !== t ? (
                    a === b
                ) : a.toString() === b.toString()
            ) : (() => {
                const kvs = Object.entries(a);
                return kvs.length !== Object.keys(b).length ? (
                    false
                ) : kvs.every(([k, v]) => eq(v)(b[k]));
            })();
        };

    // flip :: (a -> b -> c) -> b -> a -> c
    const flip = f =>
        1 < f.length ? (
            (a, b) => f(b, a)
        ) : (x => y => f(y)(x));

    // fmapTree :: (a -> b) -> Tree a -> Tree b
    const fmapTree = f =>
        // A new tree. The result of a structure-preserving
        // application of f to each root in the existing tree.
        tree => {
            const go = x => Node(f(x.root))(
                x.nest.map(go)
            );
            return go(tree);
        };

    // foldTree :: (a -> [b] -> b) -> Tree a -> b
    const foldTree = f =>
        // The catamorphism on trees. A summary
        // value obtained by a depth-first fold.
        tree => {
            const go = x => f(x.root)(
                x.nest.map(go)
            );
            return go(tree);
        };

    // fst :: (a, b) -> a
    const fst = tpl =>
        // First member of a pair.
        tpl[0];

    // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
    const groupBy = fEq => xs =>
        // // Typical usage: groupBy(on(eq)(f), xs)
        0 < xs.length ? (() => {
            const
                tpl = xs.slice(1).reduce(
                    (gw, x) => {
                        const
                            gps = gw[0],
                            wkg = gw[1];
                        return fEq(wkg[0])(x) ? (
                            Tuple(gps)(wkg.concat([x]))
                        ) : Tuple(gps.concat([wkg]))([x]);
                    },
                    Tuple([])([xs[0]])
                );
            return tpl[0].concat([tpl[1]])
        })() : [];

    // identity :: a -> a
    const identity = x =>
        // The identity function. (`id`, in Haskell)
        x;

    // isPrefixOf :: [a] -> [a] -> Bool
    // isPrefixOf :: String -> String -> Bool
    const isPrefixOf = xs =>
        // True if xs is a prefix of ys.
        ys => {
            const go = (xs, ys) => {
                const intX = xs.length;
                return 0 < intX ? (
                    ys.length >= intX ? xs[0] === ys[0] && go(
                        xs.slice(1), ys.slice(1)
                    ) : false
                ) : true;
            };
            return 'string' !== typeof xs ? (
                go(xs, ys)
            ) : ys.startsWith(xs);
        };

    // length :: [a] -> Int
    const length = xs =>
        // Returns Infinity over objects without finite
        // length. This enables zip and zipWith to choose
        // the shorter argument when one is non-finite,
        // like cycle, repeat etc
        (Array.isArray(xs) || 'string' === typeof xs) ? (
            xs.length
        ) : Infinity;

    // lookup :: String -> Dict -> a
    const lookup = k =>
        // The value returned from obj by method k
        // Not a total function – assumes that k exists.
        obj => {
            const method = obj[k];
            return method.exists ? (
                method()
            ) : undefined;
        };

    // map :: (a -> b) -> [a] -> [b]
    const map = f =>
        // The list obtained by applying f to each element of xs.
        // (The image of xs under f).
        xs => (Array.isArray(xs) ? (
            xs
        ) : xs.split('')).map(f);

    // on :: (b -> b -> c) -> (a -> b) -> a -> a -> c
    const on = f =>
        // e.g. sortBy(on(compare,length), xs)
        g => a => b => f(g(a))(g(b));

    // regexMatches :: Regex -> String -> [[String]]
    const regexMatches = rgx =>
        // All matches of the given (global /g) regex in
        strHay => {
            let m = rgx.exec(strHay),
                xs = [];
            while (m)(xs.push(m), m = rgx.exec(strHay));
            return xs;
        };

    // showLog :: a -> IO ()
    const showLog = (...args) =>
        console.log(
            args
            .map(x => JSON.stringify(x, null, 2))
            .join(' -> ')
        );

    // snd :: (a, b) -> b
    const snd = tpl => tpl[1];

    // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
    const sortBy = f =>
        xs => xs.slice()
        .sort(uncurry(f));

    // tail :: [a] -> [a]
    const tail = xs =>
        // A new list consisting of all
        // items of xs except the first.
        0 < xs.length ? xs.slice(1) : [];


    // take :: Int -> [a] -> [a]
    // take :: Int -> String -> String
    const take = n =>
        // The first n elements of a list,
        // string of characters, or stream.
        xs => 'GeneratorFunction' !== xs
        .constructor.constructor.name ? (
            xs.slice(0, n)
        ) : [].concat.apply([], Array.from({
            length: n
        }, () => {
            const x = xs.next();
            return x.done ? [] : [x.value];
        }));


    // transpose :: [[a]] -> [[a]]
    const transpose = rows =>
        // The columns of the input transposed
        // into new rows.
        // Simpler version of transpose_, assuming input
        // rows of even length.
        0 < rows.length ? rows[0].map(
            (x, i) => rows.flatMap(
                x => x[i]
            )
        ) : [];

    // uncurry :: (a -> b -> c) -> ((a, b) -> c)
    const uncurry = f =>
        // A function over a pair, derived
        // from a curried function.
        (x, y) => f(x)(y);


    // unlines :: [String] -> String
    const unlines = xs =>
        // A linefeed-delimited string constructed
        // from the list of lines in xs.
        xs.join('\n');

    // unwords :: [String] -> String
    const unwords = xs => xs.join(' ');

    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
        // Use of `take` and `length` here allows for zipping with non-finite
        // lists - i.e. generators like cycle, repeat, iterate.
        ys => {
            const
                lng = Math.min(length(xs), length(ys)),
                vs = take(lng)(ys);
            return take(lng)(xs).map(
                (x, i) => Tuple(x)(vs[i])
            );
        };

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

Brilliant, thanks @ComplexPoint!! I’ll get on these and report results tomorrow :ok_hand:t4:

1 Like

This barebones plain vanilla AppleScript seems to be working reliably here under 10.14.6 (Mojave). It doesn’t require Pandoc or have other dependencies.

tell application "Notes"
	repeat with aFolder in folders
		repeat with aNote in notes of aFolder
			tell aNote to set {theName, theBody, theCreateDate, theModifyDate} to {name, body, creation date, modification date}
			tell front document of application "Tinderbox 8"
				set myNote to make new note with properties {name:theName}
				tell myNote
					set value of attribute "Text" to my removeMarkupFromText(theBody)
					set value of attribute "Created" to my dateToStr(theCreateDate)
					set value of attribute "Modified" to my dateToStr(theModifyDate)
					set value of attribute "Container" to name of aFolder -- TB automatically creates if doesn't yet exist!
				end tell
			end tell
		end repeat
	end repeat
end tell

# HANDLERS (=subroutines)
to dateToStr(aDate) --> convert AppleScript date to string format that Tinderbox recognizes
	tell aDate to return short date string & ", " & time string
end dateToStr

on removeMarkupFromText(theText)
	-- see https://developer.apple.com/library/archive/documentation/LanguagesUtilities/Conceptual/MacAutomationScriptingGuide/RemoveMarkupfromHTML.html
	set tagDetected to false
	set theCleanText to ""
	repeat with a from 1 to length of theText
		set theCurrentCharacter to character a of theText
		if theCurrentCharacter is "<" then
			set tagDetected to true
		else if theCurrentCharacter is ">" then
			set tagDetected to false
		else if tagDetected is false then
			set theCleanText to theCleanText & theCurrentCharacter as string
		end if
	end repeat
	return theCleanText
end removeMarkupFromText

Formatting tags in the note body are stripped, resulting in plain text in $Text of a Tinderbox note. Images (attachments) aren’t handled. Any folders used in Apple Notes are preserved by placing the notes in like-named containers in Tinderbox. Tinderbox doesn’t need to be told to create a container note if one doesn’t already exist; it just does it! Notes that weren’t in a folder in Apple Notes end up in a general Notes container in Tinderbox. Any further hierarchy in Apple Notes (I think it’s possible to have a Folder within a Folder now, though I haven’t gotten that fancy) is ignored.

Any hashtagged tags in the $Text of Tinderbox notes can easily be extracted and placed in the $Tags attribute by selecting the notes and applying this stamp:

$Tags=runCommand("grep -o '#[a-zA-Z0-9_]\+'",$Text).replace('\r',';').replace('#','')

I was thinking it might be possible to put that in an evaluate in the AppleScript but got lost in the ‘escaping’ of the special characters and decided it is much quicker and easier just to apply the stamp separately.

For substantial numbers of notes this script chugs along for several minutes. Observing notes being created in Tinderbox or watching the Replies pane in Script Editor can give reassurance that it hasn’t choked on something.

Will be interested to hear if this works in Catalina too.

2 Likes

I’m just so freakin’ impressed by you guys…!!!

The script reports an error - Error on line 433: Error: Can’t convert types.

The section the script stops at is this

// lookup :: String -> Dict -> a
const lookup = k =>
    // The value returned from obj by method k
    // Not a total function – assumes that k exists.
    obj => {
        const method = obj[k];
        return method.exists ? (
            method()

The result panel of the script editor displays
“Error -1700: Can’t convert types.”