Added a com.houdah.HoudahSpot4
sub-macro to allow Copy as MD Link
to work with HoudahSpot – Search Tool for Mac.
(updated original macro group above)
Added a com.houdah.HoudahSpot4
sub-macro to allow Copy as MD Link
to work with HoudahSpot – Search Tool for Mac.
(updated original macro group above)
Added a com.agiletortoise.Drafts-OSX
sub-macro to extend Copy as MD Link
to work with [Drafts]
(https://getdrafts.com/)
(updated original macro group above)
Great work. Maybe you could build one for Obsidian MD Editor?
Not an app that I run myself, or have here, but if its feasible to:
mdLink
Obsidian
app ( md.obsidian
, I think ? )then it should work if you just add it to the MD Link tools group
.
Added an md.obsidian
sub-macro to extend the Copy as MD Link
macro to work with:
[ The Obsidian 0.10.1 Beta ]
( https://obsidian.md/ )
(updated original macro group above)
The Obsidian client seems to be a beta of a cross-platform Electron app, and slightly parsimonious in the data it exposes to macOS scripting
This first draft ( which would need testing – I'm not making active use of Obsidian myself ):
A limitation which is, as far as I can see, built into the Obsidian scheme, is that if two different vaults (at different paths on the system) share a name, and are both open at the same time, it is not possible for a script to know which is the foregrounded vault.
(The script here just throws up a message if this arises, so that one of the two vaults can be closed, allowing the user to try Copy as MD Link
again, in an unambiguous context).
(() => {
'use strict';
// MD link to file selected in Obsidian v. 0.10.1
// Rob Trew @2020
// Ver 0.1
// main :: IO ()
const main = () => {
const
fpFolder = '~/Library/Application Support/Obsidian',
fileName = 'obsidian.json',
fpFullPath = combine(fpFolder)(fileName);
const
kme = Application('Keyboard Maestro Engine'),
windowTitle = (
activateNamedApp(kme)('Obsidian'),
frontWindowName(kme)('windowName')
);
return either(
alert('Copy as MD Link')
)(
mdLink => kme.setvariable('mdLink', {to: mdLink})
)(
bindLR(
windowTitle.includes(' - ') ? (
Right(windowTitle.split(' - ')[0])
) : Left('Obsidian window title not found')
)(vaultName => bindLR(
doesDirectoryExist(fpFolder) ? (
Right(fpFullPath)
) : Left(`Folder not found :: ${fpFolder}`)
)(fp => bindLR(
doesFileExist(fp) ? (
readFileLR(fp)
) : Left(`File not found :: ${fp}`)
)(txt => bindLR(
jsonParseLR(txt)
)(dct => bindLR(
Boolean(dct.vaults) ? (() => {
const
vaults = dct.vaults,
ks = Object.keys(vaults),
matchingVaults = ks.filter(
k => {
const vault = vaults[k];
return Boolean(vault.open) && (
vault.path.endsWith(vaultName)
);
}
),
intMatches = matchingVaults.length;
return 0 < intMatches ? (
1 < intMatches ? (
Left(
'Ambiguous: two open vaults ' & (
`named ${vaultName}.`
)
)
) : Right(
Tuple(ks[0])(
vaults[ks[0]].path
)
)
) : Left(
`No vault matches window name ${vaultName}`
);
})() : Left(`No vaults listed in ${fp}`)
)(tpl => {
const
dataFolder = combine(tpl[1])(
'.obsidian'
);
return bindLR(
doesDirectoryExist(dataFolder) ? (
Right([tpl[0], dataFolder])
) : Left(
`Folder not found :: ${dataFolder}`
)
)(([vaultID, fpObsidian]) => bindLR(
readFileLR(
combine(fpObsidian)('workspace')
)
)(txt => bindLR(
jsonParseLR(txt)
)(dct => {
const mru = dct.lastOpenFiles;
return 0 < mru.length ? (
Right(
`[${mru[0]}](` + (
`obsidian://open?vault=${vaultID}&` + (
`file=${encodeURIComponent(mru[0])})`
)
)
)
) : Left('Last opened file not found.');
})));
})))))
);
};
// ---------------- KEYBOARD MAESTRO -----------------
// frontWindowName :: Application -> String -> String
const frontWindowName = kme =>
kmVarName => (
kme.doScript(
`<?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>SetVariableToText</string>
<key>Text</key>
<string>%FrontWindowName%</string>
<key>Variable</key>
<string>${kmVarName}</string>
</dict>
</array>
</plist>`
),
kme.getvariable(kmVarName)
);
// activateNamedApp :: Application -> String -> IO ()
const activateNamedApp = kme =>
appName => kme.doScript(
`<?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>md.obsidian</string>
<key>Name</key>
<string>${appName}</string>
<key>NewFile</key>
<string>/Applications/Obsidian.app</string>
</dict>
<key>MacroActionType</key>
<string>ActivateApplication</string>
<key>ReopenWindows</key>
<false/>
<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',
withIcon: sa.pathToResource('Obsidian.icns', {
inBundle: 'Applications/Obsidian.app'
})
}),
s
);
};
// doesDirectoryExist :: FilePath -> IO Bool
const doesDirectoryExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp)
.stringByStandardizingPath, ref
) && ref[0];
};
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = fp => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(fp)
.stringByStandardizingPath, ref
) && 1 !== ref[0];
};
// readFileLR :: FilePath -> Either String IO String
const readFileLR = fp => {
// Either a message or the contents of any
// text file at the given filepath.
const
e = $(),
ns = $.NSString
.stringWithContentsOfFileEncodingError(
$(fp).stringByStandardizingPath,
$.NSUTF8StringEncoding,
e
);
return ns.isNil() ? (
Left(ObjC.unwrap(e.localizedDescription))
) : Right(ObjC.unwrap(ns));
};
// --------------------- GENERIC ---------------------
// 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
});
// 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);
// combine (</>) :: FilePath -> FilePath -> FilePath
const combine = fp =>
// Two paths combined with a path separator.
// Just the second path if that starts
// with a path separator.
fp1 => Boolean(fp) && Boolean(fp1) ? (
'/' === fp1.slice(0, 1) ? (
fp1
) : '/' === fp.slice(-1) ? (
fp + fp1
) : fp + '/' + fp1
) : fp + fp1;
// 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;
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
// Either a message, or a JS value obtained
// from a successful parse of s.
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(
`${e.message} (line:${e.line} col:${e.column})`
);
}
};
// MAIN --
return main();
})();
Added a com.amazon.Kindle sub-macro to extend the Copy as MD Link macro to work with the macOS Kindle app.
(updated original macro group above)
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 ...
(() => {
'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();
})();
Added:
com.ulyssesapp.mac
com.soulmen.ulysses-setapp
sub-macros to extend the Copy as MD Link
macro to work with Mac AppStore and Setapp versions of:
[ Ulysses ]
( https://ulysses.app/ )
(updated original macro group above)
Added a
com.literatureandlatte.scrivener3
sub-macro to extend the Copy as MD Link macro to work with:
[Scrivener | Literature & Latte]
(https://www.literatureandlatte.com/scrivener/overview)
(updated original macro group above)
Thanks for sharing and updating a great tool.
Is there a way we can just download the new added macro for a specific app rather than re-downloading everything?
The supporting sub-macros are not stand-alone – the group forms a system, and apart from additions, individual components may also be updated at times.
(The size of the group is only about the same as that of the image)
I'll aim to maintain a parallel Github repository, in case anyone prefers to watch it or download it from there:
[ copy-as-md-link ]
( https://github.com/RobTrew/copy-as-md-link )
but I don't plan to manually disaggregate the files. Possibly one could do that with a Makefile
, but the incentives are not enormously compelling : -)
(and I don't immediately see an export method in the KM scripting API, though I may well have missed something)
Added two more sub-macros:
app.soulver.mac
com.multimarkdown.nvUltra
to extend the Copy as MD Link macro to work with:
[Soulver 3]
(https://soulver.app/)[nvUltra]
(https://nvultra.com/)(updated original macro group above, and a copy is also kept on Github)
[RobTrew/copy-as-md-link]
(https://github.com/RobTrew/copy-as-md-link)
Added a sub-macro for:
com.panic.Nova
to extend the Copy as MD Link macro to work with:
[Panic - Nova]
(https://nova.app/)(updated original macro group above, and a copy is also kept on Github)
[RobTrew/copy-as-md-link]
(https://github.com/RobTrew/copy-as-md-link )
This is unbelievable Rob. Thank you very very much! I was thinking about this for the last couple of days and was only able to find a chrome extension which ONLY worked for copying chrome links in markdown.
This is Mega!
Is it maybe possible that the option for the page attribute in Devonthink 3 would also add the page number to the end of the title
" // ( including any ?page=nn attribute in a content record)"
So for: x-devonthink-item://7EC0718F-F4CE-415D-9BD7-A43EAE8A806A?page=9
instead of:
DEVONthink 3 - Take Control Of
DEVONthink 3 - Take Control Of/9
Ah, understood ... adding the page number to the [ label ]
as well as the ( URL )
for DEVONthink ?
Should be no problem – I'll take a look at that tomorrow.
UPDATE
I've added page number and total page count to DEVONthink links, where the selection was in an DEVONThink 'content record' window, e.g. as
p173 of 592
in
[AppleScript_The_Definitive_Guide_Second_Edition.pdf p173 of 592]
(x-devonthink-item://C53E561C-9A79-4A36-97EF-896920982429?page=173)
If you are happy to make an edit in the JS source code of the:
com.devon-technologies.think3
sub-macro, you can customise the display format to something which suits you better.
The link title is defined on lines 48-59 of the JS:
const
pageCount = record.pageCount(),
page = window.currentPage(),
hasPage = -1 !== page,
pageString = hasPage ? (
`?page=${page}`
) : '',
title = record.name() + (
hasPage ? (
` p${page} of ${pageCount}`
) : ''
);
and if you want, you can experiment with adjusting the details of the string between backticks there:
` p${page} of ${pageCount}`
Thank you very very much! Worked like a charm!
I am using Roam Research
When I use it on chrome itself the macro works successfully.
However, when I use it as a Chrome app then I get error that states consider adding a macro named.... (as in the screenshot)
I wanted to add this by copying the macro for chrome, as I assumed there would be one (also hoping that no changes would be necessary in terms of code )
But there is none for chrome
Text of error:
Window 'null' of:
com.google.Chrome.app.gmhpgfbjacdocjngbdbmhkgcpochhnge
may not be a document window.
Consider adding a macro named 'com.google.Chrome.app.gmhpgfbjacdocjngbdbmhkgcpochhnge'
to the KM Group 'MD link tools'.
(Or request such a macro, which should
save a label string) in the
KM variable "mdLink")
on the Keyboard Maestro forum).
You are talking about a client application (perhaps an Electron app) supplied by Roam ?
That would need a sub-macro of its own.
Not something that I'm using myself, but If you want to point me to it, I might be able to take a look later in the week, or at the weekend.
( Electron apps can tend to be a bit opaque to macOS scripting )
Chrome App as in:
[Google is finally killing off Chrome apps
](https://www.theverge.com/2020/1/15/21067907/google-chrome-apps-end-support-lune-windows-macos-linux)
?
Looks a long shot, I'm afraid. This approach needs macOS apps with predictable bundleIDs, and that technology seems to rely on bundleID scrambling ...
Never mind. It is not that important. I will default to using the browser itself more.
Good to know.
Could you maybe add one for Notion?
Just a Note, maybe it will be of some use:
Cmd + L gets the link for the page but gets the link in form of "https" if you change https with "notion" it becomes a link for notion, as mentioned in the blog post:
Not something that I use, personally, but there again – its another Electron app, so a bit beyond the fringes of macOS scriptability.