How to make Bike save expanded rows and selection

Edit Jesse moved this to its own topic.

+1 for saving state between app opens (well, between document opens, really). Right now when I close and reopen a bike document, it reopens with every single line fully expanded. So I have to go through and collapse everything, then reopen just the ones I wanted open…

@npydyuan I’m not having the same problem with my Bike docs. If I’ve folded something up, quit, restart then the same areas are still folded. Perhaps you documents are very different from mine (maybe much longer). Or I’m misreading your post.

This is working for “app” opens, Bike will restore your open documents state, but it is not working when you close and then reopen a document.

This is a design decision, though I’m not sure if it’s the correct one. Right now I’m using the standard macOS document restoration behavior. Which says keep that sort of temporary state when a document is left open and restored on next app launch… but discard that state of a document is closed.

I can also start saving that state within the document which I “think” might be a better experience, but it also isn’t the way most Mac apps work.

To be clear, it does open to the last focused row including what is expanded/collapsed, but it does not return the selection row state. It will always put the cursor to the top of the document.

1 Like

So I just opened a Bike document, made changes including expanding and collapsing various rows, saved the document, quit the app, reopened the app. What I see whe the app launches is the default blank new document. When I then reopen the document I had been working on, every row is fully expanded.
What I was trying to say I would prefer is to have my documents remember which rows were collapsed and which were expanded. Based on the comments here, it seems this behavior is different for others?? Or am I just completely missing something or perhaps not explaining myself well…

@npydyuan go into your General System Preferences

Do you have Close windows when quitting an app checked? If you do that’s the behavior that you’d see I believe.

2 Likes

This should be possible to make work. That’s how it works on my computer, but it does have two things you need to do:

  1. Make sure that “System Preferences > General > Close windows when quitting an app” is not checked.

  2. When you close Bike you need to quit the app, but leave the document windows open. If you first close the document windows no state will be saved.

Edit It also shouldn’t really matter if you save your document or not This state is stored separate from the document file. This process is all controlled by underlying macOS code frameworks.

1 Like

I think it is necessary to store the status of expanded/collapsed in the document. It is a part of my thinking and it is worth to be saved.

the standard macOS document restoration behavior is not handy enough for me; I don’t want to keep an unfinished document window open all the time, but rather want to continue (thinking) the previous document only after a certain day.

1 Like

As an interim experiment – not sure that this is the right approach – I’ve sketched a couple of rough draft Bike macros for Keyboard Maestro:

Bike Save and Restore Fold and Focus.kmmacros.zip (17.0 KB)


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

    // Save fold state of front document as custom attribute
    // custom.foldState :: JSON Array of folded row ids

    // Rob Trew @2022
    // EXPERIMENTAL DRAFT – USE WITH DUMMY DATA ONLY

    // Ver 0.02

    // main :: IO ()
    const main = () => {
        const doc = Application("Bike").documents.at(0);

        return doc.exists() ? (() => {
            const
                fp = `${doc.file()}`,
                collapsedIdsJSON = JSON.stringify(
                    doc.rows.where({
                        collapsed: true
                    }).id()
                ),
                maybeFocusedRow = doc.focusedRow(),
                kvs = [
                    [
                        "custom.foldState",
                        `'${collapsedIdsJSON}'`
                    ],
                    [
                        "custom.focusRow",
                        Boolean(maybeFocusedRow) ? (
                            maybeFocusedRow.id()
                        ) : "''"
                    ]
                ];

            return either(
                alert("Save fold state")
            )(x => x)(
                fileAttrsSetLR(filePath(fp))(kvs)
            );
        })() : "No documents open in Bike";
    };

    // fileAttrSetLR :: FilePath ->
    // [(String, String)] -> Either String  Dict
    const fileAttrsSetLR = fp =>
        kvs => {
            const fpFull = filePath(fp);

            return bindLR(
                doesFileExist(fpFull) ? (
                    Right(fpFull)
                ) : Left(`File not found: ${fpFull}`)
            )(fpFound => {
                try {
                    Object.assign(
                        Application.currentApplication(),
                        {includeStandardAdditions: true}
                    )
                    .doShellScript(
                        kvs.map(
                            ([k, v]) =>
                                `xattr -w ${k} ${v} "${fpFound}"`
                        ).join("\n")
                    );

                    return Right(
                        [
                            kvs.map(
                                ([k, v]) => `${k}:${v}`
                            ).join("\n"),
                            fpFound
                        ].join("\n")
                    );
                } catch (e) {
                    return Left(e.message);
                }
            });
        };

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

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

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

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

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

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

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

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

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


    // main :: IO()
    return main();
})();

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

    // Restore fold and focus state of front document
    // from custom file attributes
    // custom.foldState :: JSON Array of any folded row ids
    // custom.focusRow :: id of focus row (if any)

    // Rob Trew @2022
    // EXPERIMENTAL DRAFT – USE WITH DUMMY DATA ONLY

    // Ver 0.03

    // main :: IO ()
    const main = () => {
        const doc = Application("Bike").documents.at(0);

        return doc.exists() ? (
            either(
                alert("Restore fold and focus state")
            )(
                restoredFoldsAndFocus(doc)
            )(
                fileAttrsReadLR(
                    filePath(`${doc.file()}`)
                )([
                    "custom.foldState",
                    "custom.focusRow"
                ])
            )
        ) : "No documents open in Bike";
    };


    // ----------------------- 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(
            ObjC.wrap(s).stringByStandardizingPath
        );


    // fileAttrsReadLR :: FilePath -> String -> Maybe Dict
    const fileAttrsReadLR = fp =>
        ks => {
            const fpFull = filePath(fp);

            return "null" !== fpFull ? (
                bindLR(
                    doesFileExist(fpFull) ? (
                        Right(fpFull)
                    ) : Left(`File not found: ${fpFull}`)
                )(fpFound => {
                    try {
                        return Right(
                            zip(ks)(lines(
                                Object.assign(
                                    Application.currentApplication(),
                                    {includeStandardAdditions: true}
                                )
                                .doShellScript(
                                    ks.map(
                                        k => `xattr -p ${k} "${fpFound}"`
                                    ).join("\n")
                                )
                            ))
                            .reduce(
                                (a, [k, v]) => Object.assign(
                                    {[k]: v},
                                    a
                                ),
                                {}
                            )
                        );
                    // );
                    } catch (e) {
                        return Left(e.message);
                    }
                })
            ) : Left("Front document not saved.");
        };

    // restoredFoldsAndFocus ::
    // Bike Doc Dict -> IO String
    const restoredFoldsAndFocus = doc =>
        dict => {
            const
                foldedIDs = either(() => [])(x => x)(
                    jsonParseLR(dict["custom.foldState"])
                ),
                focusRowId = dict["custom.focusRow"] || "",
                rows = doc.rows,

                focusMessage = Boolean(focusRowId) ? (
                    restoreFocus(doc)(focusRowId)
                ) : "",
                foldMessage = (() => {
                    rows.where({containsRows: true})()
                    .forEach(x => x.expand());

                    return foldedIDs.flatMap(k => {
                        const row = rows.byId(k);

                        return row.exists() ? [(
                            row.collapse(),
                            row.name()
                        )] : [];
                    }).join();
                })();

            return [
                `Restored folds:\n${foldMessage}`,
                focusMessage
            ].join("\n");
        };

    // restoreFocus :: Bike Rows -> String -> Bike IO ()
    const restoreFocus = doc =>
        rowID => {
            const row = doc.rows.byId(rowID);

            return row.exists() ? (
                doc.focusedRow = row,
                `Focus restored: '${row.name()}'`
            ) : `Focus row not found: ${rowID}`;
        };


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

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

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

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

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

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

    // lines :: String -> [String]
    const lines = s =>
    // A list of strings derived from a single string
    // which is delimited by \n or by \r\n or \r.
        Boolean(s.length) ? (
            s.split(/\r\n|\n|\r/u)
        ) : [];


    // zip :: [a] -> [b] -> [(a, b)]
    const zip = xs =>
    // The paired members of xs and ys, up to
    // the length of the shorter of the two lists.
        ys => Array.from({
            length: Math.min(xs.length, ys.length)
        }, (_, i) => [xs[i], ys[i]]);

    // showLog :: a -> IO ()
    const showLog = (...args) =>
    // eslint-disable-next-line no-console
        console.log(
            args
            .map(JSON.stringify)
            .join(" -> ")
        );

    // main :: IO()
    return main();
})();
2 Likes

If you know this mechanism in advance and complete it on one Mac, the following three methods will not cause any problems.

  1. Do not close.
  2. Mark where you want to collapse when closing. When opened in the future, search for signs and collapse, etc.
  3. Use @complexpoint’s script. (Now: EXPERIMENTAL DRAFT – USE WITH DUMMY DATA ONLY)

(For me, though.)

However, this mechanism cannot be used to work with iPhone’s Outliner, etc., using .OPML. It is inconvenient to collapse again on the narrow screen of the iPhone. Especially when using a small part of a large outline for checklists.
Also, even when creating a site with .opml as the source, such as http://opml.org, it seems more convenient to include <expansionState> and create it with Bike.
In addition, it may be convenient to save <expansionState> when sharing one outline with multiple people.

Writing out a sequence of <expansionState> integer strings (and reading them back to a folding state if found) does sound interesting.

A problem at the at the back of my mind – and perhaps worth checking – is that I seem to remember discovering, at some point, that there are two traditions of how to calculate and interpret that integer sequence – one in the OPML spec, and another (incompatible, if memory serves ?) used by Omni, and copied, I think, by one or two other apps.

1 Like

I think one of the problems may be that the meaning of the OPML specification for <expansionState>, and the implication of “order is important” turns on what is known is some circles as a hapax legomenon – the word "flatdown"

See, for example, an expression of slightly unresolved puzzlement at:

Are the expansionState integers:

  • Zero-based absolute line numbers ? (This turns out to be the Omni interpretation), or
  • One-based ordinals which don’t count lines that remain hidden by folding ? ( This turns out to be what the OPML specification means)

Interpretations seem to diverge … the river parts on the meaning of flatdown

(And the computation, either way, might be fairly expensive with big documents ?)


To illustrate the lack of useable consistency in how different apps write and interpret OPML <expansionState> integer lists. This outline:

In which:

  • Beta and Iota are folded
  • but Alpha Gamma and Delta are expanded

is, according to OmniOutliner (fairly influential)

<expansionState>0,5,12</expansionState>

but according to Little Outliner 2, written by the author of the OPML specification (Dave Winer), it is:

<expansionState>1,3,7</expansionState>

perhaps this undermines the usefulness of the approach, in terms of shuttling between apps ?


FWIW

As far as I can see, the Omni interpretation seems to be: zero-based absolute line numbers, (0, 5, 12 = Alpha, Gamma, Delta)

whereas the OPML spec seems to be: a one-based ordinal sequence that skips the counting of any lines that are to remain hidden by folding. (1, 3, 7 = Alpha, Gamma, Delta)

1 Like

Thank you for your explanation. :grinning:
OK, I understand the current state of expansionState. I won’t go into that now. :upside_down_face:

For me (and probably many other Outliner users) the state of collapse-expand can be important.
It is not always possible to complete it with one machine or one application, so I hope that it will be organized and shared as a specification (such as extending it so that two or many methods can be distinguished).
The amount of computation for large documents is another story. For example, if it’s faster to just mark individual lines as collapse-expand, that’s fine for Bike. (And I hope that method will also be specified in OPML so that it can be shared by many applications.) :upside_down_face: :grinning: :thinking:

Scripted solutions are of course possible, for example a:

  1. Save as OPML for XYZ application, with
  2. Import OPML created by XYZ application

in which we write out and read in expansion-state tags tailored for the convention used by a specific application.


PS I just mentioned this to Omni, and I think they are now considering a switch to match the OPML specification.


In the meanwhile, here are JavaScript functions defining, for an open Bike document, each of the two kinds of integer lists (<expansionList> sequences):

Expand disclosure triangle for definition of OPML style expansion list
// opmlStyleExpansionList :: Bike Doc -> [Int]
const opmlStyleExpansionList = doc => {
    // One-based indices (into a list of visible rows only)
    // of expanded parent rows.
    const rows = doc.rows.where({visible: true});

    return zip(
        rows.containsRows()
    )(
        rows.collapsed()
    ).flatMap(
        ([isParent, isCollapsed], i) =>
            isParent && !isCollapsed ? (
                [1 + i]
            ) : []
    );
};
Expand disclosure triangle for definition of Omni style expansion list
// omniStyleExpansionList :: Bike Doc -> [Int]
const omniStyleExpansionList = doc => {
    // Zero-based absolute line numbers of rows which
    // are parents and are not collapsed.
    const rows = doc.rows;

    return zip(
        rows.containsRows()
    )(
        rows.collapsed()
    ).flatMap(
        ([isParent, isCollapsed], i) =>
            isParent && !isCollapsed ? (
                [i]
            ) : []
    );
};
1 Like

I haven’t spent the time to fully understand this discussion, but two things:

  1. I definitely DON’T want to have to uncheck the system-wide “Close windows when quitting an app” preference.

  2. TaskPaper appears to already be saving the expansion state as of the last document save without the system-wide “Close windows when quitting an app” preference enabled. Why is it able to do this when Bike can’t?

Thanks.

Just a design decision, based mostly in ease of implementation. From above:

Bike is now using the default OS provided mechanism for saving this (view) state. For whatever reason that system discards state when you close document.

The nice thing about this system is that it’s well know and relatively easy to plug into. Of course the difficult part is that it doesn’t really work the way many people want it to work. I agree that (at least as option) it would nice to not loose this state when closing document.

The issue is it’s a chunk of work to implement.

Ideally I could just reuse the existing state saving system and change a few lines of code to save that state along with the document. I’ve tried this a few times, but haven’t been able to figure it out. TaskPaper is using a different route and it mostly works, but for example notice that if you have a single document with multiple tabs open that state won’t be restored if you close the document… only the folds are restored. On the other hand if you leave the document open and quit/reopen TaskPaper they are restored. Probably not that big a deal, but the reason is because in TaskPaper there is a separate path for both state restoration system, ideally I don’t want to have that.

Anyway I do want to implement this as an option. This thread is pushing it more into the forefront of my mind.

2 Likes

In the latest preview release Bike will store expanded state even for closed documents: Bike 1.16 (Preview) - #11 by jessegrosjean

2 Likes