Scrivener 3 and Hook

Someone asked us this morning whether Hook and Scrivener play well together. My answer: last time we tested Hook with Scrivener, on 2018-02-20, we found that Scrivener works out of the box with Hook.

FYI, I wrote most of the first draft of my first book, Cognitive Productivity: Using Knowledge to Become Profoundly Effective, in Scrivener. But that was using version 2. I haven’t used Scrivener 3 myself. I quite like Scrivener, but I switched to Leanpub, and converted everything to Markdown. My second book was all in Markdown too, using Leanpub again.

Scrivener users are authors who value organizing and accessing their research material. So, I think Hook will appeal to them.

  • You can add use Hook to get links to resources that are outside your Scrivener book project. You can paste those links in your project documents, or use the Hook window to link directly from any Scrivener document to a URI you obtained from Hook (e.g., to a file, email, OmniFocus project, or whatever).
  • You can also copy links to your Scrivener documents and paste them elsewhere, or link directly with the Hook window (the reciprocal of that which I described above.) That means you can navigate back and forth between your research material and Scrivener documents, wherever any of the materials may reside.
  • You can use the “Link to New” function to link from a Scrivener doc to a new note in a 3rd party app (a graphic, a spreadsheet, OmniFocus / Things task, whatever), and subsequently of course you can navigate between them with the Hook productivity window.

There are too many uses to list here…

Invitation to Scrivener users: please share your experience, comments and questions here. :slight_smile:

Cheers.

When I use Hook in Scrivener 3 I seem to just get a link to the .scriv file rather than the individual file inside the project I want to link to. This is not terribly useful given how much is going on inside a Scrivener project.

Is anybody else trying to use Scrivener 3 with Hook? Have they figured out how to get it to work?

That’s right – Hook simply produces a file link pointing broadly to an entire Scrivener .scriv bundle, of the pattern:

[sample.scriv](hook://file/gJ7gGh8ZW?p=cm9iaW50cmV3L0Rlc2t0b3A=&n=sample.scriv)

To point to component texts in a Scrivener project, it would need to use Scrivener’s own URL scheme, which can include the id of a particular text or note.

x-scrivener-item:///Users/houthakker/Desktop/sample.scriv?id=522FD958-061A-46F2-82AA-1FDDA631E90A
1 Like

Well … not really.

It needs a script to build an x-scrivener-item:// link with an id argument.

(a broad hook://file link is not particularly useful when it points to a complex bundle with a large number of distinct texts and notes inside it)

1 Like

welcome to the Hook Productivity Forum, mchapman. And thank you for asking.

And thanks, @RobTrew for the input. I’ve asked Bi Ling here to have a look at more fine-grained linking. I’ve now added a note regarding Scrivener link granularity on What Mac Apps Work with Hook? – Hook, and I will update it after our investigation (and hopefully have finer grained support!).

Another use of Hook is to get links to other resources (via Copy Link) and paste them in your Scrivener research files.

(Aside: If I write a non-Leanpub book in the future, I will reconsider Scrivener for my own writing.)

Glad to know I wasn’t missing anything. It would be great if there could be more granularity in linking to Scrivener. For now I will past Hook links in Scrivener. Thanks.

1 Like

In the meanwhile, here is a rough and slow first sketch of a script (JavaScript for Automation – so for testing in Script Editor it would need the top-left language selector set to JavaScript rather than AppleScript).

On this system, it copies a markdown link with the name and Scrivener url (with id) of whichever Scrivener item is selected in the Binder (at left of the Scrivener screen).

The Hook colleagues will have more experience of timing issues with this kind of UI scripting, and this draft could probably also be made a little faster by:

  • using a more direct method of checking that the Binder panel is open and has focus
  • caching menu references more than I have done in this sketch
  • experimenting with whether the AppleScript clipboard methods are faster than the AppKit bridging from JS which I’ve used here.
// JavaScript
(() => {
    'use strict';

    // Copying a Markdown link to a Scrivener item selected in
    // the Binder panel
    // ( x-scrivener-item: url with file path and id of item )

    // First rough draft Rob Trew 2020
    // Ver 0.01

    ObjC.import('AppKit');

    // main :: IO ()
    const main = () => {
        const
            scrivener = Application('Scrivener'),
            binderFocusMenuItem = ['Navigate', 'Move Focus To', 'Binder'];
        // The 'binder' at the left of the Scrivener screen
        // needs to be visible
        return (
            scrivener.activate(),
            either(
                // Left :: Binder may be hidden (Not yet focused)
                () => bindLR(
                    menuItemClickedLR('Scrivener')(
                        ['View', 'Show Binder|Hide Binder']
                    )
                )(
                    // 'Show Binder' has been clicked:
                    () => bindLR(
                        menuItemClickedLR('Scrivener')(binderFocusMenuItem)
                    )(scrivenerLinkLR)
                )
            )(
                // Right :: Binder already has focus.
                scrivenerLinkLR
            )(
                // Initial attempt to ensure that the Binder has the focus.
                menuItemClickedLR('Scrivener')(binderFocusMenuItem)
            )
        );
    };

    // scrivenerLinkLR :: Scrivener IO () -> Either String String
    const scrivenerLinkLR = () =>
        either(alert('No link'))(copyText)(
            bindLR(
                menuItemClickedLR('Scrivener')([
                    'Edit', 'Copy Special', 'Copy Document as External Link'
                ])
            )(scrivenerLinkFromPboardLR)
        );

    // scrivenerLinkFromPboardLR :: Scrivener IO () -> Either String String
    const scrivenerLinkFromPboardLR = () => {
        const strLink = clipboardText();
        return bindLR(
            (Boolean(strLink) && strLink.includes(
                'x-scrivener-item:'
            )) ? (
                Right(strLink)
            ) : Left('No Scrivener link found in clipboard')
        )(
            url => bindLR(
                menuItemClickedLR('Scrivener')(['Edit', 'Copy'])
            )(() => {
                delay(0.1);
                const strName = clipboardText();
                return bindLR(
                    (Boolean(strName) && (strLink !== strName)) ? (
                        Right(strName)
                    ) : Left('Scrivener item name not copied.')
                )(name => Right(`[${name}](${url})`))
            })
        )
    };

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

    // clipboardText :: IO () -> String
    const clipboardText = () =>
        // Any plain text in the clipboard.
        ObjC.unwrap(
            $.NSString.alloc.initWithDataEncoding(
                $.NSPasteboard.generalPasteboard
                .dataForType($.NSPasteboardTypeString),
                $.NSUTF8StringEncoding
            )
        );

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

    // menuItemClickedLR :: String -> [String] -> Either String IO String
    const menuItemClickedLR = strAppName => menuPath => {
        const intMenuPath = menuPath.length;
        showLog('menuPath', menuPath)
        return 1 < intMenuPath ? (() => {
            const
                appProcs = Application('System Events')
                .processes.where({
                    name: strAppName
                });
            return 0 < appProcs.length ? (() => {
                Application(strAppName).activate();
                delay(0.2);
                return bindLR(
                    menuPath.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 = menuPath[0],
                                menu = appProcs[0].menuBars[0]
                                .menus.byName(k);
                            return menu.exists() ? (
                                Right(menu)
                            ) : Left('Menu not found: ' + k);
                        })()
                    )
                )(xs => {
                    const
                        parts = menuPath[intMenuPath - 1].split('|'),
                        k = parts[0],
                        alt = 1 < parts.length ? (
                            parts[1]
                        ) : undefined,
                        items = xs.menuItems,
                        strPath = [strAppName]
                        .concat(menuPath).join(' > ');
                    return bindLR(
                        items[k].exists() ? (
                            Right(items[k])
                        ) : (Boolean(alt) && items[alt].exists()) ? (
                            Right(items[alt])
                        ) : Left('Menu item not found: ' + k)
                    )(x => x.enabled() ? (
                        x.click(),
                        Right('Clicked: ' + strPath)
                    ) : Left(
                        'Menu item disabled : ' + strPath
                    ))
                })
            })() : Left(strAppName + ' not running.');
        })() : Left(
            'MenuItemClickedLR needs a menu path of 2+ items.'
        );
    };

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

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

    return main();
})();
1 Like

Wow! I’m very grateful for your fantastic input, @RobTrew! (and I’m sure Bi Ling will be too!)

Something along these (trying applescript this time) might be a bit faster (more cacheing, less abstraction)

(Again, you will have more experience of timing and so forth than I do)

(Make sure you scroll to copy the whole script – which ends with end tell)

-- Rob Trew 2020
-- Ver 0.02

tell application "Scrivener" to activate
tell application "System Events"
    set ps to processes where its name is "Scrivener"
    if {} ≠ ps then
        set menuBar to menu bar 1 of (item 1 of ps)
        
        -- FOCUS TO BINDER
        set focusBinder to menu item "Binder" of menu 1 of ¬
            (menu item "Move Focus To" of (menu "Navigate" of menuBar))
        if enabled of focusBinder then
            click focusBinder
        else
            -- The Show Binder / Hide Binder label state is not always quite as expected.
            click (first menu item of (menu "View" of menuBar) ¬
                where name ends with "Binder")
            delay 0.1
            click focusBinder
            delay 0.1
        end if
        
        -- NAME COPIED
        set menuEdit to (menu "Edit" of menuBar)
        click (menu item "Copy" of menuEdit)
        delay 0.2
        set strName to the clipboard
        
        -- URL COPIED
        click (menu item "Copy Document as External Link" of menu ¬
            of (menu item "Copy Special" of menuEdit))
        delay 0.2
        set mdLink to "[" & strName & "](" & (the clipboard) & ")"
        -- set the clipboard to mdLink
        return mdLink
    else
        ""
    end if
end tell
1 Like

Meanwhile AmberV at the Literature and Latte forum helpfully suggests an alternative GUI scripting route:

https://www.literatureandlatte.com/forum/viewtopic.php?p=308444#p308444

I’ll take a look and perhaps update that script tonight, unless anyone feels inclined to beat me to it.

In the meanwhile I’ve posted a Keyboard Maestro macro taking the route suggested by AmberV on the Literature and Latte forum:

https://www.literatureandlatte.com/forum/viewtopic.php?p=308451#p308451

An advantage of such macros is that we can assign a single keystroke to them.

I find the ergonomic and cognitive processing costs imposed by Hook unusually high, and a bit discouraging, so in practice I’ve put it aside, though its does still potentially interest me. In particular Hook imposes:

  • Multiple keypresses to get the Hook dialog to display,
  • a wait until that dialog appears,
  • and then still a further keypress (⌘M) to copy a Markdown link.

This is all cognitively pretty noisy and resource-consuming, by any standards.

These additional processing costs might become better value if the Hook database became visible enough to be more enabling than frustrating, but in the meanwhile, Markdown links for my TaskPaper files are all I need, and it’s easier to assign a single key in Keyboard Maestro, which can interpret the meaning of that keystroke in the context of the foreground application.

(The KM route also facilitates visual notification of what has been copied, and seems to run a little more snappily too).

Hook may become more useful if:

  • we get a visible and clickable network graph of the database,
  • and the ratio of cognitive effort to value created generally catches up with Apple UI norms.

Hey @RobTrew, thanks for your work on this, here’s the script we ended up including in Hook, a modified version of what you’ve done

set the clipboard to ""
tell application "System Events"
	tell process "Scrivener"
		tell menu bar 1
			
			-- check if binder is hidden
			click menu item 3 of menu 1 of menu item "Move Focus To" of menu "Navigate"
			set isHidden to not enabled of menu item "Copy Document as External Link" of menu 1 of menu item "Copy Special" of menu "Edit"
			
			-- show binder, select item in binder, move focus to binder
			click menu item "Reveal in Binder" of menu "Navigate"
			click menu item 3 of menu 1 of menu item "Move Focus To" of menu "Navigate"
			
			-- get address
			click menu item "Copy Document as External Link" of menu 1 of menu item "Copy Special" of menu "Edit"
			delay 0.1
			set scrivAddr to the clipboard
			
			-- get name
			click menu item "Copy" of menu "Edit"
			delay 0.1
			set scrivName to the clipboard
			
			-- hide binder
			if isHidden then
				click menu item 5 of menu "View"
			end if
			
			return "[" & scrivName & "](" & scrivAddr & ")"
		end tell
	end tell
end tell

some notes:

We don’t check that the process exists. If it doesn’t exist then the script will throw an error which Hook will catch and log.

We set the clipboard to “” because if the “Copy” commands fail, the script will return a markdown link with whatever is in the clipboard, and if a URL is in the clipboard, Hook will believe that a valid link was returned, if []() is returned Hook will discard it as garbage.

As you noticed, the “Hide/Show Binder” menu item label changes. The “Move Focus To>Binder” also changes, so for both of those we access the menu items by index number.

Navigate>Reveal in Binder is better for showing the binder than “Show Binder” because

  1. It works whether or not binder is hidden, no need to check
  2. It selects the current item in the binder

#1 is actually a moot point because we check whether the binder is hidden so that we can re-hide it again later

But #2 handles an edge case in which two documents are open, in top and bottom editor, and the binder selection and the focused editor don’t match. Reveal in Binder will move the selection to the correct item.

As mentioned above, we track whether the binder was hidden at the start, and re-hide it at finish.

After clicking “Move Focus To>Binder”, “Edit>Copy” doesn’t work until the UI updates with the new focus and selected item, which requires a delay. But “Copy Document as External Link” works immediately. So by getting the address before the name, we’re able to remove a delay because the delay for the clipboard after copying the address serves dual purpose as delay for the UI to update for “Edit>Copy”

Delays in UI scripting are more of an art than a science but, knock on wood, 0.1 is enough for the clipboard

Code golf is a game, not a useful exercise, but if we didn’t care about re-hiding the binder, which isn’t strictly necessary, we could reduce the script to just this:

set the clipboard to ""
tell application "System Events" to tell process "Scrivener"
	-- show binder, select item in binder, move focus to binder
	click menu item "Reveal in Binder" of menu "Navigate" of menu bar 1
	click menu item 3 of menu 1 of menu item "Move Focus To" of menu "Navigate" of menu bar 1
	-- get address
	click menu item "Copy Document as External Link" of menu 1 of menu item "Copy Special" of menu "Edit" of menu bar 1
	delay 0.1
	set scrivAddr to the clipboard
	-- get name
	click menu item "Copy" of menu "Edit" of menu bar 1
	delay 0.1
	return "[" & (the clipboard) & "](" & scrivAddr & ")"
end tell

V 103 release notes: Hook integration scripts v. 103: Updated Scrivener support

Many thanks to everyone who contributed to putting this together so quickly. Much appreciated.

1 Like