Bike outlines on iOS / iPadOS – an interim hack

With some limitations (scale, attributes, single file) I find the Kosshi iOS / iPadOS Outliner quite useful for doing some work on a Bike-authored outline while away from macOS.

Kosshi has a simple macOS version which automatically syncs its SQLite database – a single file – with the iOS / iPadOS version.

  1. When I want to be able to work on a Bike outline away from my desk I copy it and paste it into macOS Kosshi, which then syncs to iOS.
  2. When I’m done, I copy the (modified and synced) outline from Kosshi, paste back into Bike, and delete from Kosshi.

Limitations of this approach

  1. Scale – When I pasted Moby Dick into macOS kosshi, it did, in time, successfully sync to iOS, but the time was longish, and until the :whale: was fully arrived the iOS app kept vanishing. Outlines on a sub-cetacean scale seem to sync well and promptly.
  2. Attributes Kosshi knows nothing of Bike attributes and doesn’t preserve them. It knows about task completion status (truefalse), but not about task completion date-times, which it drops.
  3. Inline formats and row types. Kosshi does parse and render inline Markdown formatting, and Markdown code and quote blocks, but it doesn’t write the markup out to file in its default OPML export. Kosshi doesn’t know about headings or notes.

To improve the preservation of inline formats and row types in the Bike ⇄ Kosshi mapping, I use a couple of Copy As scripts, which I am personally finding useful, but for which I make no guarantees of any kind (MIT License). Test on dummy data, and make sure you have understood what the two Copy scripts are doing.

They aim to preserve:

  • inline formats and links,
  • code and quote blocks,
  • completed and uncompleted tasks,
  • ordered and unordered lists,
  • horizontal rules

To test either of the scripts below:

  • Click expansion triangle and copy the whole script, using the copy icon at top right of the code field
  • Paste into Script Editor, and set the language selector at top left to JavaScript
  • With Bike or Kosshi open, use the run button in Script Editor.

You can assign a script to a hotkey with a utility like Keyboard Maestro or FastScripts.

More generally, see Using Scripts | Bike


COPY FROM BIKE FOR PASTING IN KOSSHI.APP

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

    ObjC.import("AppKit");


    // ---- COPY FROM BIKE FOR PASTING IN KOSSHI.APP -----
    //
    // Rob Trew @2026
    // Ver 0.02
    //
    // Creates an `app.kosshi.outline-rows` pasteboard
    // aiming to map as much as possible of Bike row types,
    // and inline formats.

    // The idea of pasting into the macOS Kosshi.app is to
    // is to be able to use an outline in the iOS Kosshi
    // application.

    // (Koshi automatically syncs to iOS)

    // -------------------- BEHAVIOUR --------------------

    // Which rows are copied ?
    //     In block mode, selected blocks and all their visible
    //     descendants, otherwise, all visible rows.

    // What formatting is preserved ?
    //   Inline:
    //      Inline formats are represented as Markdown, which
    //      Kosshi parses and renders.

    //   Row types:
    //      Kosshi lacks fine-grained row types, but
    //      1. Contiguous sequences of "code" or "quote" type 
    //         rows in Bike become CodeBlocks or BlockQuotes
    //         in Kosshi.
    //      2. Task rows, and their completion status
    //         but not date, are represented in Koshi
    //         with [] or [x] prefixes
    //      3. Ordered lists become numbered in Kosshi
    //      4. Unordered lists are mapped to Kosshi's "non-prose"
    //         row type, and so displayed with bullets.
    //      5. Kosshi doesn't model horizontal rules, so these
    //         are just copied as rows with the text "---"


    // main :: IO ()
    const main = () => {
        const bike = Application("Bike");

        return 0 < bike.documents.length
            ? (
                dicts => (
                    setClipOfTextType("app.kosshi.outline-rows")(
                        `{"rows": ${dicts}`
                    ),
                    (n => {
                        const affix = 1 === n ? "" : "s";

                        return `Copied ${n} row${affix} from Bike for pasting into Kosshi.`
                    })(JSON.parse(dicts).length)
                )
            )(
                bike.evaluate({ script })
            )
            : "No documents open in Bike";
    };


    // script :: Source String for Bike JSContext
    const script = (() => {

        // bikeMain :: Bike IO () -> String
        const bikeMain = () =>
            blockTypeFormats(
                groupOnKey(x => x.type)(
                    forestPreorder(
                        orderedNodesNumbered(
                            visibleForestOrBlockSelectionAsTreeTuples(
                                kosshiRowDict
                            )(
                                bike.frontmostOutlineEditor
                            )
                        )
                    )
                )
            );


        // -------------------- BIKE ---------------------
        const allCode = /^`[^`]+`$/u;


        // orderedNodesNumbered :: [Tree Dict] -> [Tree Dict]
        const orderedNodesNumbered = forest => {
            const go = xs =>
                groupOnKey(x => root(x).type)(xs)
                    .flatMap(
                        ([k, trees]) => "ordered" === k
                            ? trees.map(
                                (kv, i) => bimap(
                                    dict => ({
                                        ...dict,
                                        content: `${1 + i}. ${dict.content}`
                                    })
                                )(go)(kv)
                            )
                            : trees.map(second(go))
                    );

            return go(forest);
        };


        // blockTypeFormats :: [(String, [Dict])] -> [Dict]
        const blockTypeFormats = taggedGroups =>
            taggedGroups.flatMap(
                ([tag, dicts]) => (
                    ({
                        // "body": xs => xs,
                        // "heading": xs => xs.map(dict => ({ ...dict, bookmarked: true })),
                        "quote": blockFormat(
                            xs => `> ${xs.join("\n> ")}`
                        )(x => `> ${x}`),
                        "code": blockFormat(
                            xs => "```\n" + xs.join("\n") + "\n```"
                        )(x => `\`${x}\``),
                        // "note": xs => xs,
                        // "unordered": xs => xs,
                        // "ordered": xs => xs,
                        "task": xs => xs.map(x => ({
                            ...x,
                            content: x.completed
                                ? `[x] ${x.content}`
                                : `[] ${x.content}`
                        })),
                        "hr": xs => xs.map(
                            x => ({ ...x, content: "---" })
                        )
                    })[tag] || (xs => xs)
                )(dicts)
            );


        // blockFormat :: ([String] -> String) -> [Dict] -> [Dict]
        const blockFormat = fCombinedRows =>
            fSingleRow => xs => 1 < xs.length
                ? groupOn(x => x.level)(xs).flatMap(
                    levelRows => 1 < levelRows.length
                        ? {
                            ...levelRows[0],
                            content: fCombinedRows(
                                levelRows.map(x => x.content)
                            )
                        }
                        : levelRows.map(
                            x => ({ ...x, content: fSingleRow(x.content) })
                        )
                )
                : xs.map(
                    x => ({ ...x, content: fSingleRow(x.content) })
                );


        // kosshiRowDict :: (Bike Row, Int) -> Dict
        const kosshiRowDict = (row, topLevel) => {
            const
                md = row.text.toMarkdown(),
                type = row.type,
                isFormattedAsCode = "body" === type && allCode.test(md);

            return {
                level: row.level - topLevel,
                completed: !!row.getAttribute("done", "date"),
                content: isFormattedAsCode
                    ? md.slice(1, -1)
                    : md,
                type: isFormattedAsCode
                    ? "code"
                    : type,
                prose: "unordered" !== type
            };
        };


        // visibleForestOrBlockSelectionAsTreeTuples :: (Bike Row -> a) ->
        //  Bike Editor -> [Tree a]
        const visibleForestOrBlockSelectionAsTreeTuples = fRowValue =>
            ed => {
                const
                    { selection, focus } = ed,

                    parentRows = "block" === selection.type
                        ? selection.coverRows
                        : focus.parent ? [focus] : focus.children,

                    topLevel = Math.min(...parentRows.map(row => row.level));


                const go = rows =>
                    rows.flatMap(
                        row => ed.isFocused(row) && (row.text.count || row.firstChild)
                            ? [[
                                fRowValue(row, topLevel),
                                go(row.children)
                            ]]
                            : []
                    );

                return go(parentRows);
            };


        // -------------- GENERIC FOR BIKE ---------------

        // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
        const bimap = f =>
            // Tuple instance of bimap.
            // A tuple of the application of f and g to the
            // first and second values respectively.
            g => ([a, b]) => [f(a), g(b)];


        // forestPreorder :: [Tree a] -> [a]
        const forestPreorder = forest => {
            const go = ([v, xs]) =>
                [v].concat(xs.flatMap(go));

            return forest.flatMap(go);
        };


        // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
        const groupBy = eqOp =>
            // A list of lists, each containing only elements
            // equal under the given equality operator, such
            // that the concatenation of these lists is xs.
            // (Alternative – imperative – implementation)
            xs => {
                if (0 === xs.length) return [];

                const acc = [];
                let grp = [xs[0]];

                for (let i = 1; i < xs.length; i++) {
                    const x = xs[i];

                    // Compared against head of current group.
                    if (eqOp(grp[0])(x)) {
                        grp.push(x);
                    } else {
                        // One group completed, another started.
                        acc.push(grp);
                        grp = [x];
                    }
                }

                acc.push(grp);
                return acc;
            };

        // groupOn :: (a -> b) -> [a] -> [[a]]
        const groupOn = f =>
            // A list of lists, each containing only elements
            // which return equal values for f,
            // such that the concatenation of these lists is xs.
            xs => 0 < xs.length
                ? groupBy(
                    a => b => a[0] === b[0]
                )(
                    xs.map(x => [f(x), x])
                )
                    .map(gp => gp.map(ab => ab[1]))
                : [];


        // groupOnKey :: Eq k => (a -> k) -> [a] -> [(k, [a])]
        const groupOnKey = f =>
            // A list of (k, [a]) tuples, in which each [a]
            // contains only elements for which f returns the
            // same value, and in which k is that value.
            // The concatenation of the [a] in each tuple === xs.
            xs => 0 < xs.length
                ? groupBy(a => b => a[0] === b[0])(
                    xs.map(x => [f(x), x])
                )
                    .map(gp => [
                        gp[0][0],
                        gp.map(ab => ab[1])
                    ])
                : [];

        // nest :: Tree a -> [a]
        const nest = tree => tree[1];

        // root :: Tree a -> a
        const root = tree => tree[0];


        // second :: (a -> b) -> ((c, a) -> (c, b))
        const second = f =>
            // A function over a simple value lifted
            // to a function over a tuple, with f
            // applied to the second term.
            // f (a, b) -> (a, f(b))
            ([x, y]) => [x, f(y)];


        // BIKE MAIN ---
        return JSON.stringify(bikeMain(), null, 2);
    })
        .toString()


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

    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };

    return main();
})();

COPY FROM KOSSHI.APP FOR PASTING IN BIKE

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

    ObjC.import("AppKit");

    // ---- COPY FROM KOSSHI.APP FOR PASTING IN BIKE -----
    //
    // Rob Trew @2026
    // Ver 0.02
    //
    //
    // Creates a `public.data.xml.opml` pasteboard
    // aiming to map as much as inline and row type,
    // formatting as possible from Kosshi to Bike 2.0

    // The idea of interaction between Bike and Kosshi.app
    // is to be able to work on Bike outlines in iOS.
    // (Koshi automatically syncs to iOS)

    // -------------------- BEHAVIOUR --------------------

    // Which rows are copied from Koshi ?
    //    All rows in the Koshi database are copied.
    //    This script is best suited to a workflow which
    //    mainly keeps Kosshi empty, only pasting into it
    //    whatever you immediately want to work on, or
    //    refer to, in iOS or iPadOS.

    // What formatting is preserved ?
    //   Inline:
    //      Inline formats are represented as Markdown
    //      markup, which Bike can read from OPML row text.

    //   Row types:
    //      Kosshi lacks fine-grained row types, but
    //      1. CodeBlocks or BlockQuotes in Kosshi become
    //         sequences of contiguous code or blockquote
    //         rows in Bike
    //      2. Task rows, and their completion status,
    //         are mapped to Bike task rows. Kosshi does not
    //         record a date-time for completion, so the moment
    //         of the copy is used.
    //      3. Ordered lists are mapped to sequences of Bike
    //         ordered rows
    //      4. "Non-prose" (i.e. bulleted – see ⇧P toggle) rows
    //         in Kosshi that are not tasks, ordered, code or quote
    //         are mapped to 'unordered' list rows in Bike.
    //      5. Any row containing just "---" or "***" is mapped,
    //         in the clipboard for Bike, to a horizontal rule.

    // main :: IO ()
    const main = () => {
        const
            rootID = "ROOT",
            fp = filePath("~/Library/Containers/app.kosshi/Data/Library/Application Support/Kosshi/Kosshi.sqlite"),
            cmd = `/usr/bin/sqlite3 ${fp.replaceAll(" ", "\\ ")} -json '${koshiRowSQL}'`;

        return either(
            alert("Kosshi outline as OPML")
        )(
            ([n, opml]) => (
                setClipOfTextType("public.data.xml.opml")(opml),
                `Copied ${n} row${1 === n ? "" : "s"} from Koshi (for pasting to Bike)`
            )
        )(
            fmapLR(
                dicts => [
                    dicts.length,
                    compose(
                        bikeOPMLFromForest(kosshiOPMLAttributeMapping),
                        blockTypeStringsAsMultipleRows,
                        forestFromKosshiParentIndex(rootID),
                        parentIndexFromDicts("parentID")(rootID)
                    )(dicts)
                ]
            )(
                doesFileExist(fp)
                    ? jsonParseLR(
                        Object.assign(
                            Application.currentApplication(),
                            { includeStandardAdditions: true }
                        )
                            .doShellScript(cmd)
                    )
                    : Left(`Database not found at path:\n\t${fp}`)
            )
        );
    };


    // ------------ TREE FROM KOSSHI RECORDS -------------

    const koshiRowSQL = `
            SELECT 
                id,
                parentID,
                content,
                prose,
                completed,
                bookmarked,
                createdAt,
                updatedAt,
                sortKey
            FROM outlineRow
            ORDER BY sortKey ASC;`

    // Some leaves (with code or quote block text), 
    // may become longer sibling runs 

    const blockTypeStringsAsMultipleRows = forest => {

        // Significantly affects only leaves in the forest
        // Each tree is simply wrapped in a list if it is NOT a leaf
        // or is neither a code nor quote block. 
        // Otherwise, it becomes a **series** of leaf trees, each with only a part of the text
        // QUESTION, do we have to inflect the sort Key to preserve order ? 
        //      IF SO, HOW ?  Sequentially rising alphabetic affix ?

        // go :: Tree Dict -> [Tree Dict]
        const go = tree => {
            const
                dict = root(tree),
                children = nest(tree),
                text = dict.text;

            const blockRows = type =>
                rowTexts => 0 < rowTexts.length
                    ? init(rowTexts).map(
                        text => Node({ ...dict, text, type })([])
                    )
                        .concat(
                            Node({ ...dict, text: last(rowTexts), type })(
                                children.flatMap(go)
                            )
                        )
                    : [];

            return text.startsWith("```\n")
                ? text.endsWith("\n```")

                    // Rows of type CODE 
                    // any descendants attached to last sub-row
                    ? blockRows("code")(
                        text.slice(4, -4).split("\n")
                    )
                    : [Node(dict)(children.flatMap(go))]
                : text.startsWith("> ")

                    // Rows of type QUOTE
                    // any descendants attached to last sub-row
                    ? blockRows("quote")(
                        text.slice(2).split("\n> ")
                    )
                    : [Node(dict)(children.flatMap(go))];
        };

        return forest.flatMap(go);
    };


    // kosshiOPMLAttributeMapping :: Dict -> String
    const kosshiOPMLAttributeMapping = dict =>
        ["text", "done", "type", "created", "modified"]
            .flatMap(k => {
                const v = dict[k];

                return v
                    ? "boolean" === typeof v
                        ? "done" === k
                            ? [[k, (new Date()).toISOString()]]
                            : [["type", v]]
                        : [[k, "text" === k ? htmlEncoded(v) : v]]
                    : [];
            })
            .map(([k, v]) => `${k}="${v}"`)
            .join(" ");


    // parentIndexFromDicts :: String -> String -> [{id, parentID ....}]
    const parentIndexFromDicts = parentKey =>
        rootID => dicts => dicts.reduce(
            (a, rowDict) => {
                const parentID = rowDict[parentKey] || rootID;

                return {
                    ...a,
                    [parentID]: (a[parentID] || []).concat(rowDict)
                };
            },
            {}
        );


    // forestFromKosshiParentIndex :: String -> 
    //  Dict -> [Tree Dict]
    const forestFromKosshiParentIndex = rootID =>
        indexDict => {
            const
                taskPrefix = /^\[[ x]*\]\s+/u,
                orderedPrefix = /^\d+\.\s+/u,
                horizRule = /^(-{3})|(\*{3})$/u,
                contentType = s => {
                    const
                        i = [taskPrefix, orderedPrefix, horizRule]
                            .findIndex(rgx => rgx.test(s));

                    return -1 === i
                        ? ""
                        : ["task", "ordered", "hr"][i];
                };

            const go = rowDict => {
                const
                    { content, prose, completed, bookmarked, createdAt, updatedAt } = rowDict,
                    matchedType = contentType(content),
                    done = (1 === completed);

                return Node({
                    text: "task" === matchedType
                        ? content.replace(taskPrefix, "")
                        : content,
                    type: 0 < matchedType.length
                        ? matchedType
                        : 1 === prose
                            ? 1 === bookmarked
                                ? "heading"
                                : "body"
                            : "unordered",
                    done,
                    created: (new Date(createdAt)).toISOString(),
                    modified: (new Date(updatedAt)).toISOString()
                })(
                    (indexDict[rowDict.id] || []).map(go)
                );
            };

            return indexDict[rootID].map(go);
        };


    // -------------------- BIKE OPML --------------------

    // bikeOPMLFromForest :: (a -> String) -> (a -> String) ->
    // Forest a -> XML String
    const bikeOPMLFromForest = fAttribs =>
        // A com.hogbaysoftware.bike.xml encoding of a
        // Forest of `a` values, given two (a -> String):
        // 1. A function returning a [k=v] attribute(s)
        //    string for an <li> tag
        // 2. A function returning contents for a <p> tag.
        trees => {
            const d = " ";
            const go = indent => x => {
                const
                    dent = d + indent,
                    attribs = fAttribs(
                        root(x)
                    ),
                    xs = nest(x);

                return 0 < xs.length
                    ? [
                        `${dent}<outline ${attribs}>`,
                        ...xs.flatMap(go(d + dent)),
                        `${dent}</outline>`
                    ]
                    : [`${dent}<outline ${attribs}/>`];
            };

            return [
                '<?xml version="1.0" encoding="UTF-8"?>',
                '<opml version="2.0">',
                "  <head>",
                '    <meta charset="utf-8" />',
                "  </head>",
                "  <body>",
                trees.flatMap(
                    go(d.repeat(3))
                )
                    .join("\n"),
                "  </body>",
                "</opml>"
            ]
                .join("\n");
        };


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


    // doesFileExist :: FilePath -> IO Bool
    const doesFileExist = fp => {
        const ref = Ref();

        return $.NSFileManager
            .defaultManager
            .fileExistsAtPathIsDirectory(
                $(fp).stringByStandardizingPath,
                ref
            ) && !ref[0];
    };


    // filePath :: String -> FilePath
    const filePath = s =>
        // The given file path with any tilde expanded
        // to the full user directory path.
        ObjC.unwrap(
            $(s).stringByStandardizingPath
        );


    // setClipOfTextType :: String -> String -> IO String
    const setClipOfTextType = utiOrBundleID =>
        txt => {
            const pb = $.NSPasteboard.generalPasteboard;

            return (
                pb.clearContents,
                pb.setStringForType(
                    $(txt),
                    utiOrBundleID
                ),
                txt
            );
        };


    // writeFile :: FilePath -> String -> IO ()
    const writeFile = fp =>
        s => $.NSString.alloc.initWithUTF8String(s)
            .writeToFileAtomicallyEncodingError(
                $(fp).stringByStandardizingPath,
                false,
                $.NSUTF8StringEncoding, null
            );

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


    // 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 || []
        });


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


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


    // apList (<*>) :: [(a -> b)] -> [a] -> [b]
    const apList = fs =>
        // The sequential application of each of a list
        // of functions to each of a list of values.
        // apList([x => 2 * x, x => 20 + x])([1, 2, 3])
        //     -> [2, 4, 6, 21, 22, 23]
        xs => fs.flatMap(f => xs.map(f));

    // bimap :: (a -> b) -> (c -> d) -> (a, c) -> (b, d)
    const bimap = f =>
        // Tuple instance of bimap.
        // A tuple of the application of f and g to the
        // first and second values respectively.
        g => ([a, b]) => [f(a), g(b)];


    // compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
    const compose = (...fs) =>
        // A function defined by the right-to-left
        // composition of all the functions in fs.
        fs.reduce(
            (f, g) => x => f(g(x)),
            x => x
        );

    // 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 => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e
            ? e
            : Right(f(e.Right));


    // htmlEncoded :: String -> String
    const htmlEncoded = s => {
        // A string in which &<>"' are replaced by
        // the corresponding entities.
        const dict = {
            "&": "&amp;",
            "<": "&lt;",
            ">": "&gt;",
            "\"": "&quot;",
            "'": "&apos;"
        };

        return s.replace(/[&<>"']/ug, c => dict[c] || c);
    };

    // init :: [a] -> [a]
    const init = xs =>
        // All elements of a list except the last.
        0 < xs.length
            ? xs.slice(0, -1)
            : null;


    // length :: [a] -> Int
    const length = xs =>
        xs.length

    // last :: [a] -> a
    const last = xs => {
        // The last item of a list.
        const n = xs.length;

        return 0 < n
            ? xs[n - 1]
            : null;
    };

    // jsonParseLR :: String -> Either String a
    const jsonParseLR = s => {
        try {
            return Right(JSON.parse(s));
        } catch (e) {
            return Left(
                [
                    e.message,
                    `(line:${e.line} col:${e.column})`
                ].join("\n")
            );
        }
    };

    // nest :: Tree a -> [a]
    const nest = tree =>
        // The children of a given tree node.
        tree.nest

    // root :: Tree a -> a
    const root = tree =>
        // The value attached to a tree node.
        tree.root;


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


4 Likes

Dang, that looks like a local-first Workflowy clone! First impressions are very good. Great find!

29.99€, more than Bike…
:disguised_face:

is it worth the price compared to Zavala (free)?

Not sure anyone can answer that for you, taste and needs differ. There’s a 7-day trial, so you can try it out yourself!

I agree, but you are definitely more expert than me and perhaps your opinion is important not only to me, thanks :innocent:

I am definitely no expert! :slight_smile:

FWIW, I think Zavala is a much more mature app with more of the kinks already ironed out. But I haven’t used it nearly enough to warrant any form of judgment. I think Kosshi is geared more towards productivity, and tagging/filtering performs much better, which is what interests me.

I also think that 30 EUR for a lifetime license (for this major version anyway) isn’t that much of an ask. That’s less than a single lunch these days.

2 Likes

Bike roundtrip (as import and export) is in testing with PaperTrail 1.3

The way I decided to do it is:

  • map Bike attribs → TaskPaper tags on import
  • map TaskPaper tags → Bike attribs on export

“simples” :zany_face:

Read more at:

1 Like

FoldNotes iOS https://foldnotes.io Does not have the limitation of scale and manages almost all of Bikes attributes with the exception of superscript and subscript. See Video iCloud Drive - Apple iCloud . I amk looking for feedback on the app. For a testflight Invite email me at support@foldnotes.io

2 Likes