Testing and glitch reports particularly welcome on this one – its approach is in the tradition of Rube Goldberg and Heath Robinson GUI scripting.
Does seem to be working here, but no guarantees ...
JS Source
(() => {
'use strict';
ObjC.import('AppKit');
// Copy as MD Link for macOS Kindle
// Rob Trew @ 2020
// Read available fields
// (for md link :: Title + Author, Location, ASIN)
// from active Kindle view <- {'page', 'icon', 'list'}
// and continue to 'next' view,
// until 'home' and 'dry'.
// main :: IO ()
const main = () => {
const kme = Application('Keyboard Maestro Engine');
return either(
alert('Copy as MD Link')
)(x => (
kme.setvariable('mdLink', {
to: x
}),
x
))(
bindLR(
kindleProcLR()
)(proc => bindLR(
until(
homeAndDryOrSunk
)(
harvestNextViewLR(kme)(proc)
)(
viewHarvestFromProcessLR(kme)(proc)({
viewPath: []
})
)
)(linkFromDictLR))
);
};
// harvestNextViewLR :: Application -> a
// Dict -> Either String Dict
const harvestNextViewLR = kme =>
proc => lrDict => {
const
dict = lrDict.Right,
path = dict.viewPath,
currentView = last(path);
return (
// In Kindle:
!isComplete(dict) ? (
'list' !== currentView ? (
kme.doScript(
'page' !== currentView ? (
hitReturnPlist
) : menuChainPlist([
'File',
'Close Book'
])
)
) : Boolean(dict.location) ? (
clickViewButton(proc)('list')
) : kme.doScript(hitReturnPlist)
) : 'page' !== currentView ? (
'page' !== path[0] ? (
clickViewButton(proc)(currentView)
) : kme.doScript(
menuChainPlist(['File', 'Open Book'])
)
) : kme.doScript(
menuChainPlist(['File', 'Close Book'])
),
// In JavaScript interpreter:
viewHarvestFromProcessLR(kme)(proc)(
dict
)
);
};
// linkFromDictLR :: Dict -> Either String String
const linkFromDictLR = dict =>
// Either a message or an MD Link string
bindLR(
Boolean(dict.name) ? (
Right(dict.name)
) : Left('Name field not found')
)(name => {
const
i = [...name].findIndex(
c => ',' === c
);
return -1 !== i ? (() => {
const [title, rest] = splitAt(i)(name);
const
authorList = Object.keys(
rest.slice(1).split(/; /g).reduce(
(a, k) => Object.assign({},
a, {
[
reverse(
k.split(', ')
).join(' ')
]: 1
}), {}
)
);
return Right(
`[${title} – ${authorList.join(', ')}]` + (
'(kindle://book?action=open&asin=' + (
`${dict.asin}&location=${dict.location})`
)
)
);
})() : Left('No comma found in name field.');
});
// isComplete :: Dict -> Bool
const isComplete = dict => {
// Complete if the dictionary
// holds all 3 key values <- {asin, location, name}
const ks = Object.keys(dict);
return ['name', 'asin', 'location'].every(
k => ks.includes(k)
);
};
// homeAndDryOrSunk :: Either String Dict -> Bool
const homeAndDryOrSunk = lrDict =>
// Either an explanatory message, or
// True if all the sought key values are
// present in the dictionary, and the path
// shows a return to initial position.
Boolean(lrDict.Left) || (() => {
const
dict = lrDict.Right,
path = dict.viewPath;
return 1 < path.length && (
path[0] === last(path)
) && isComplete(dict);
})();
// viewHarvestFromProcessLR :: Application ->
// process -> Dict -> Either String Dict
const viewHarvestFromProcessLR = kme =>
// Either a message or a dictionary with
// additional fields and an extended path
// of visited views.
process => history => bindLR(
kindleProcWinLR(process)
)(window => bindLR(
kindleWindowToolbarLR(window)
)(toolbar => bindLR(
kindleToolbarControlsLR(toolbar)
)(controls => bindLR(
kindleWindowTypeLR(controls)
)(winType => 'page' === winType ? (
locationFromKindlePageLR(history)(window)
) : 'list' === winType ? (
authorFromKindleListLR(history)(window)
) : 'icon' === winType ? (
bindLR(
titlePosnSizeFromKindleIconLR(window)
)(
asinEtcFromIconDetailsLR(kme)(history)
)
) : Left('other')))));
// asinEtcFromIconDetailsLR :: Application ->
// Dict -> (String, String, String) ->
// Either String Dict
const asinEtcFromIconDetailsLR = kme =>
// Either a message or a dictionary with an
// extended viewPath and updated or added
// `asin` and `name` fields.
history => ([label, posn, size]) => {
const
x = posn[0] + size[0] / 2,
y = posn[1] + size[1] / 2;
return Right(
(
copyText(''),
kme.doScript(
clickPointPlist(x)(y)
),
kme.doScript(controlCplist),
Object.assign({}, history, {
viewPath: history.viewPath.concat('icon'),
name: label.split(', Reading')[0],
asin: either(
_ => 'Empty clipboard - asin not found.'
)(
x => x.split('-0-')[0]
)(clipTextLR())
})
)
);
};
// titlePosnSizeFromKindleIconLR :: Window ->
// Either String [String, (Int, Int), (Int, Int)]
const titlePosnSizeFromKindleIconLR = window => {
const lists = window.lists;
return bindLR(
0 < lists.length ? (
Right(lists.at(0))
) : Left('No icon list found - perhaps not icon view ?')
)(list => {
const
staticText = lists.at(0)
.staticTexts.at(0);
return Right([
'title', 'position', 'size'
].map(k => staticText[k]()));
});
};
// kindleProcLR :: () -> Either String Process
const kindleProcLR = () => {
// Either a message, or a reference
// to a running Kindle process.
const
kindleProcs = Application('System Events')
.applicationProcesses.where({
bundleIdentifier: 'com.amazon.Kindle'
});
return 0 < kindleProcs.length ? (
Right(kindleProcs.at(0))
) : Left('Kindle reader not found.');
};
// kindleProcWinLR :: Process -> Either String Window
const kindleProcWinLR = process => {
const wins = process.windows;
return 0 < wins.length ? (
Right(wins.at(0))
) : Left('Open window not found in Kindle.');
};
// kindleToolbarControlsLR :: Toolbar ->
// Either Message Buttons
const kindleToolbarControlsLR = toolbar => {
const controls = toolbar.uiElements;
return 0 < controls.length ? (
Right(controls)
) : Left('No UI elements found in toolbar.');
};
// kindleWindowToolbarLR :: Window -> Either String Toolbar
const kindleWindowToolbarLR = window => {
const toolbars = window.toolbars;
return 0 < toolbars.length ? (
Right(toolbars.at(0))
) : Left(
'No toolbars found in Kindle window: ' + (
window.name()
)
);
};
// kindleWindowTypeLR :: controls ->
// Either String String
const kindleWindowTypeLR = controls => {
const count = controls.length;
return [3, 4, 8].includes(count) ? (
Right(({
3: 'list',
4: 'icon',
8: 'page'
})[count])
) : Left(
'Unrecognised pattern of controls on toolbar.'
);
};
// authorFromKindleListLR :: Dict ->
// Window -> Either String Dict
const authorFromKindleListLR = history =>
window => {
const tables = window.tables;
return bindLR(
0 < tables.length ? (
Right(tables.at(0))
) : Left('No table found in window - perhaps not list ?')
)(
table => {
const staticTexts = table.staticTexts;
return bindLR(
1 < staticTexts.length ? (
Right(staticTexts)
) : Left('Less than 2 static texts found.')
)(
texts => Right(
Object.assign({}, history, {
viewPath: history.viewPath
.concat('list'),
author: texts.at(1).title()
})
)
);
}
);
};
// --------------------- TOKENS ----------------------
// locationFromKindlePageLR :: Dict ->
// Window -> Either String Dict
const locationFromKindlePageLR = history =>
window => {
const
staticTexts = window.staticTexts(),
iLabel = staticTexts.findIndex(
x => 0 < x.uiElements.length
);
return -1 !== iLabel ? (() => {
const
xs = staticTexts[iLabel]
.uiElements.at(0).value()
.split(/\s+/g),
lng = xs.length;
return bindLR(
2 < lng ? (
Right(xs[lng - 3])
) : Left(
'Location string not found.'
)
)(label => isNaN(label) ? (
Left('Expected a location integer.')
) : Right(
Object.assign({}, history, {
viewPath: history.viewPath.concat('page'),
location: parseInt(label)
})
));
})() : Left('Library page');
};
// --------------------- KINDLE ----------------------
// kindlePageBookNameLR :: Either String String
const kindlePageBookNameLR = () => {
const
se = Application('System Events'),
kindleProcs = se.applicationProcesses.where({
bundleIdentifier: 'com.amazon.Kindle'
});
return bindLR(
0 < kindleProcs.length ? (
Right(kindleProcs.at(0))
) : Left('Kindle reader not found.')
)(kindleProc => {
const ws = kindleProc.windows;
return bindLR(
0 < ws.length ? (
Right(ws.at(0))
) : Left('No windows open in Kindle')
)(win => {
const
toolbar = win.toolbars.at(0),
buttons = toolbar.buttons;
return buttons.length !== 4 ? (
Left('This is not a reading page')
) : Right(win.title().split(' - ').slice(1)[0]);
});
});
};
// kindleButtonTypeLR :: () -> IO Dict
const kindleButtonTypeLR = () => {
// Either a message, or a dictionary with
// a single key drawn from {'icon', 'list', 'library'}
// the value of the key is a button which can be
// clicked with the method:
// (dct[k]).actions.at(0).perform()
const
se = Application('System Events'),
kindleProcs = se.applicationProcesses.where({
bundleIdentifier: 'com.amazon.Kindle'
});
return bindLR(
0 < kindleProcs.length ? (
Right(kindleProcs.at(0))
) : Left('Kindle reader not found.')
)(kindleProc => {
const ws = kindleProc.windows;
return bindLR(
0 < ws.length ? (
Right(ws.at(0))
) : Left('No windows open in Kindle')
)(win => {
const
toolbar = win.toolbars.at(0),
buttons = toolbar.buttons;
return Right(
0 < buttons.length ? ({
library: buttons.at(0)
}) : (() => {
const
groups = toolbar.groups,
blnIconView = 3 < groups.length,
group = groups.at(
blnIconView ? (
2
) : 1
);
return {
[blnIconView ? 'list' : 'icons']: (
group.radioGroups.at(0)
.radioButtons.at(
blnIconView ? (
1
) : 0
)
)
};
})()
);
});
});
};
// clickViewButton :: Process -> String -> Kindle IO
const clickViewButton = proc =>
// A button clicked
// Either the List view or Icons view button,
// to toggle from the current view to its sibling.
viewName => {
const
toolbar = proc.windows.at(0)
.toolbars.at(0),
blnIconView = 'list' !== viewName,
group = toolbar.groups.at(
blnIconView ? 2 : 1
),
button = group.radioGroups.at(0)
.radioButtons.at(
blnIconView ? 1 : 0
);
return button.actions.at(0).perform();
};
// kindleLocationFromPageLR ::
const kindleLocationFromPageLR = () => {
const
se = Application('System Events'),
kindleProcs = se.applicationProcesses.where({
bundleIdentifier: 'com.amazon.Kindle'
});
return bindLR(
0 < kindleProcs.length ? (
Right(kindleProcs.at(0))
) : Left('Kindle reader not found.')
)(kindleProc => {
const ws = kindleProc.windows;
return bindLR(
0 < ws.length ? (
Right(ws.at(0))
) : Left('No windows open in Kindle')
)(win => {
const
staticTexts = win.staticTexts(),
iLabel = staticTexts.findIndex(
x => 0 < x.uiElements.length
);
return -1 !== iLabel ? (() => {
const
xs = staticTexts[iLabel]
.uiElements.at(0).value()
.split(/\s+/g),
lng = xs.length;
return bindLR(
2 < lng ? (
Right(xs[lng - 3])
) : Left(
'Location string not found.'
)
)(label => isNaN(label) ? (
Left('Expected a location integer.')
) : Right(parseInt(label)));
})() : Left('Library page');
});
});
};
// kindleTypeAndFieldLR :: () -> Either String (String, String)
const kindleTypeAndFieldLR = () =>
// Either a message or a key value pair in which
// the key is drawn from {'name', 'asin', 'location'}
either(
_ => bindLR(
kindleLocationFromPageLR()
)(
label => Right(['location', label])
)
)(
txt => 0 < txt.length ? (
txt.endsWith('EBOK') ? (
Right(['asin', txt.split('-')[0]])
) : Right(['name', txt])
) : Left('No text copied in Library view.')
)(
clipTextLR()
);
// ------------------- KM ACTIONS --------------------
// activateKindlePlist :: XML String
const activateKindlePlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>AllWindows</key>
<true/>
<key>AlreadyActivatedActionType</key>
<string>Normal</string>
<key>Application</key>
<dict>
<key>BundleIdentifier</key>
<string>com.amazon.Kindle</string>
<key>Name</key>
<string>Kindle</string>
<key>NewFile</key>
<string>/Applications/Kindle.app</string>
</dict>
<key>MacroActionType</key>
<string>ActivateApplication</string>
<key>ReopenWindows</key>
<false/>
<key>TimeOutAbortsMacro</key>
<true/>
</dict>
</array>
</plist>`;
// clickPointPlist :: Int -> Int -> XML String
const clickPointPlist = x =>
y => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>Action</key>
<string>MoveAndClick</string>
<key>Button</key>
<integer>0</integer>
<key>ClickCount</key>
<integer>1</integer>
<key>DisplayMatches</key>
<false/>
<key>DragHorizontalPosition</key>
<string>0</string>
<key>DragVerticalPosition</key>
<string>0</string>
<key>Fuzz</key>
<integer>15</integer>
<key>HorizontalPositionExpression</key>
<string>${x}</string>
<key>MacroActionType</key>
<string>MouseMoveAndClick</string>
<key>Modifiers</key>
<integer>0</integer>
<key>MouseDrag</key>
<string>None</string>
<key>Relative</key>
<string>Window</string>
<key>RelativeCorner</key>
<string>TopLeft</string>
<key>RestoreMouseLocation</key>
<false/>
<key>VerticalPositionExpression</key>
<string>${y}</string>
</dict>
</array>
</plist>`;
// menuChainPlist :: [String] -> XML String
const menuChainPlist = ks => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>MacroActionType</key>
<string>SelectMenuItem</string>
<key>Menu</key>
<array>
${ks.map(k => ['<string>',k,'</string>'].join('')).join('\n')}
</array>
<key>TargetApplication</key>
<dict>
<key>BundleIdentifier</key>
<string>com.amazon.Kindle</string>
<key>Name</key>
<string>Kindle</string>
<key>NewFile</key>
<string>/Applications/Kindle.app</string>
</dict>
<key>TargetingType</key>
<string>Specific</string>
</dict>
</array>
</plist>`;
// controlCplist :: XML String
const controlCplist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>KeyCode</key>
<integer>8</integer>
<key>MacroActionType</key>
<string>SimulateKeystroke</string>
<key>Modifiers</key>
<integer>256</integer>
<key>ReleaseAll</key>
<false/>
<key>TargetApplication</key>
<dict/>
<key>TargetingType</key>
<string>Front</string>
</dict>
</array>
</plist>`;
// hitReturnPlist :: XML String
const hitReturnPlist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>KeyCode</key>
<integer>36</integer>
<key>MacroActionType</key>
<string>SimulateKeystroke</string>
<key>Modifiers</key>
<integer>0</integer>
<key>ReleaseAll</key>
<false/>
<key>TargetApplication</key>
<dict/>
<key>TargetingType</key>
<string>Front</string>
</dict>
</array>
</plist>`;
// pausePlist :: XML String
const pausePlist = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<dict>
<key>MacroActionType</key>
<string>Pause</string>
<key>Time</key>
<string>0.2</string>
<key>TimeOutAbortsMacro</key>
<true/>
</dict>
</array>
</plist>`;
// ----------------------- 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
);
};
// clipTextLR :: () -> Either String String
const clipTextLR = () => (
v => Boolean(v) && 0 < v.length ? (
Right(v)
) : Left('No utf8-plain-text found in clipboard.')
)(
ObjC.unwrap($.NSPasteboard.generalPasteboard
.stringForType($.NSPasteboardTypeString))
);
// copyText :: String -> IO String
const copyText = s => {
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
// --------------------- 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 =>
// 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 => 'Either' === e.type ? (
undefined !== e.Left ? (
fl(e.Left)
) : fr(e.Right)
) : undefined;
// last :: [a] -> a
const last = xs =>
// The last item of a list.
0 < xs.length ? (
xs.slice(-1)[0]
) : undefined;
// reverse :: [a] -> [a]
const reverse = xs =>
'string' !== typeof xs ? (
xs.slice(0).reverse()
) : xs.split('').reverse().join('');
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(JSON.stringify)
.join(' -> ')
);
// splitAt :: Int -> [a] -> ([a], [a])
const splitAt = n =>
xs => [
xs.slice(0, n),
xs.slice(n)
];
// until :: (a -> Bool) -> (a -> a) -> a -> a
const until = p =>
f => x => {
let v = x;
while (!p(v)) v = f(v);
return v;
};
// MAIN ---
return main();
})();