Literature and Latte's excellent Scapple doesn't inherently use a nested architecture:
- All nodes are peers, by default,
- The built-in Save As OPML yields a flat, un-nested (unindented) list of peer nodes.
If, however, we choose to interpret Scapple arrow links as parent → child links then we can:
- Write some code to Save As OPML with nested structure, or
- Copy selections (or the whole file) as a Tab-indented plain text outline.
Here are a couple macros, for Save As OPML outline, and Copy As Tab-indented outline.
Scapple Macros.kmmacros (111.9 KB)
Scapple doesn't have a scripting API, so these macros work by:
- Reading any
com.scapple.internal.pboardType
content in the clipboard to get IDs of selected+copied nodes - Parsing the XML .scap file for the active Scapple window, to obtain the detail of shapes and links.
(JS source below)
JavaScript source
// Ver 0.03 Handled a minor bug which can arise in Scapple XML files
(() => {
'use strict';
ObjC.import('AppKit');
const main = () => {
const
kme = Application('Keyboard Maestro Engine'),
blnOPML = Boolean(eval(kme.getvariable(
'scappleAsOPML'))),
strDefaultPath = kme.getvariable(
'defaultScappleOutPath'
);
return bindLR(
bindLR(
allOrCopiedScapplesLR(),
dot(Right, (
blnOPML ? (
opmlFromTrees('Scapple')
) : tabIndentFromTrees
)(fScappleText))
),
blnOPML ? (
savedAsOPMLR(strDefaultPath)
) : copiedAsTabIndentedLR
);
};
// GENERIC FUNCTIONS ----------------------------------
// Just :: a -> Just a
const Just = x => ({
type: 'Maybe',
Nothing: false,
Just: x
});
// Left :: a -> Either a b
const Left = x => ({
type: 'Either',
Left: ' ' + x
});
// Node :: a -> [Tree a] -> Tree a
const Node = (v, xs) => ({
type: 'Node',
root: v,
nest: xs || []
});
// Nothing :: () -> Nothing
const Nothing = () => ({
type: 'Maybe',
Nothing: true,
});
// 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
});
// any :: (a -> Bool) -> [a] -> Bool
const any = (p, xs) => xs.some(p);
// append (++) :: [a] -> [a] -> [a]
// append (++) :: String -> String -> String
const append = (xs, ys) => xs.concat(ys);
// bindLR (>>=) :: Either a -> (a -> Either b) -> Either b
const bindLR = (m, mf) =>
m.Right !== undefined ? (
mf(m.Right)
) : m;
// compare :: a -> a -> Ordering
const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);
// comparing :: (a -> b) -> (a -> a -> Ordering)
const comparing = f =>
(x, y) => {
const
a = f(x),
b = f(y);
return a < b ? -1 : (a > b ? 1 : 0);
};
// concat :: [[a]] -> [a]
// concat :: [String] -> String
const concat = xs =>
xs.length > 0 ? (() => {
const unit = typeof xs[0] === 'string' ? '' : [];
return unit.concat.apply(unit, xs);
})() : [];
// concatMap :: (a -> [b]) -> [a] -> [b]
const concatMap = (f, xs) => [].concat.apply([], xs.map(f));
// cons :: a -> [a] -> [a]
const cons = (x, xs) => [x].concat(xs);
// doesFileExist :: FilePath -> IO Bool
const doesFileExist = strPath => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(strPath)
.stringByStandardizingPath, ref
) && ref[0] !== 1;
};
// difference (\\) :: Eq a => [a] -> [a] -> [a]
const difference = (xs, ys) =>
xs.filter(x => ys.indexOf(x) === -1);
// doesDirectoryExist :: FilePath -> IO Bool
const doesDirectoryExist = strPath => {
const ref = Ref();
return $.NSFileManager.defaultManager
.fileExistsAtPathIsDirectory(
$(strPath)
.stringByStandardizingPath, ref
) && ref[0] === 1;
};
// dot (.) :: (b -> c) -> (a -> b) -> a -> c
const dot = (f, g) => x => f(g(x));
// drop :: Int -> [a] -> [a]
// drop :: Int -> String -> String
const drop = (n, xs) => xs.slice(n);
// dropWhile :: (a -> Bool) -> [a] -> [a]
const dropWhile = (p, xs) => {
let i = 0;
for (let lng = xs.length;
(i < lng) && p(xs[i]); i++) {}
return xs.slice(i);
};
// elem :: Eq a => a -> [a] -> Bool
const elem = (x, xs) => xs.includes(x);
// enumFromToInt :: Int -> Int -> [Int]
const enumFromToInt = (m, n) =>
n >= m ? (
iterateUntil(x => x >= n, x => 1 + x, m)
) : [];
// eq (==) :: Eq a => a -> a -> Bool
const eq = (a, b) => {
const t = typeof a;
return t !== typeof b ? (
false
) : t !== 'object' ? (
a === b
) : (() => {
const aks = Object.keys(a);
return aks.length !== Object.keys(b).length ? (
false
) : aks.every(k => eq(a[k], b[k]));
})();
};
// even :: Int -> Bool
const even = n => n % 2 === 0;
// filePath :: String -> FilePath
const filePath = s =>
ObjC.unwrap(ObjC.wrap(s)
.stringByStandardizingPath);
// filter :: (a -> Bool) -> [a] -> [a]
const filter = (f, xs) => xs.filter(f);
// foldl :: (a -> b -> a) -> a -> [b] -> a
const foldl = (f, a, xs) => xs.reduce(f, a);
// foldl1 :: (a -> a -> a) -> [a] -> a
const foldl1 = (f, xs) =>
xs.length > 1 ? xs.slice(1)
.reduce(f, xs[0]) : xs[0];
// foldlTree :: (b -> a -> b) -> b -> Tree a -> b
const foldlTree = (f, acc, node) => {
const go = (a, x) =>
x.nest.reduce(go, f(a, x));
return go(acc, node);
};
// index (!!) :: [a] -> Int -> a
const index = (xs, i) => xs[i];
// iterateUntil :: (a -> Bool) -> (a -> a) -> a -> [a]
const iterateUntil = (p, f, x) => {
let vs = [x],
h = x;
while (!p(h))(h = f(h), vs.push(h));
return vs;
};
// map :: (a -> b) -> [a] -> [b]
const map = (f, xs) => xs.map(f);
const mapAccumL = (f, acc, xs) =>
xs.reduce((a, x, i) => {
const pair = f(a[0], x, i);
return Tuple(pair[0], a[1].concat(pair[1]));
}, Tuple(acc, []));
// Expects functions in the argument list to be
// paired with Bools:
// true -> ascending sort on that key
// false -> descending sort on that key
// mappendComparing_ :: [((a -> b), Bool)] ->
// (a -> a -> Ordering)
const mappendComparing_ = fboolPairs =>
(x, y) => fboolPairs.reduce(
(ordr, fb) => {
const f = fb[0];
return ordr !== 0 ? (
ordr
) : fb[1] ? (
compare(f(x), f(y))
) : compare(f(y), f(x));
}, 0
);
// mean :: [Num] -> Num
const mean = xs =>
xs.reduce((a, x) => a + x, 0) / xs.length;
// partition :: Predicate -> List -> (Matches, nonMatches)
// partition :: (a -> Bool) -> [a] -> ([a], [a])
const partition = (p, xs) =>
xs.reduce(
(a, x) =>
p(x) ? (
Tuple(a[0].concat(x), a[1])
) : Tuple(a[0], a[1].concat(x)),
Tuple([], [])
);
// readFile :: FilePath -> IO String
const readFile = strPath => {
let error = $(),
str = ObjC.unwrap(
$.NSString
.stringWithContentsOfFileEncodingError(
$(strPath)
.stringByStandardizingPath,
$.NSUTF8StringEncoding,
error
)
);
return Boolean(error.code) ? (
ObjC.unwrap(error.localizedDescription)
) : str;
};
// showJSON :: a -> String
const showJSON = x => JSON.stringify(x, null, 2);
// showLog :: a -> IO ()
const showLog = (...args) =>
console.log(
args
.map(JSON.stringify)
.join(' -> ')
);
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = (f, xs) =>
xs.slice()
.sort(f);
// Split a filename into directory and file.
// combine is the inverse.
// splitFileName :: FilePath -> (String, String)
const splitFileName = strPath =>
strPath !== '' ? (
strPath[strPath.length - 1] !== '/' ? (() => {
const
xs = strPath.split('/'),
stem = xs.slice(0, -1);
return stem.length > 0 ? (
Tuple(stem.join('/') + '/', xs.slice(-1)[0])
) : Tuple('./', xs.slice(-1)[0]);
})() : Tuple(strPath, '')
) : Tuple('./', '');
// unlines :: [String] -> String
const unlines = xs => xs.join('\n');
// unwords :: [String] -> String
const unwords = xs => xs.join(' ');
// unwrap :: NSObject -> a
const unwrap = o => ObjC.unwrap(o);
// words :: String -> [String]
const words = s => s.split(/\s+/);
// writeFile :: FilePath -> String -> IO ()
const writeFile = (strPath, strText) =>
$.NSString.alloc.initWithUTF8String(strText)
.writeToFileAtomicallyEncodingError(
$(strPath)
.stringByStandardizingPath, false,
$.NSUTF8StringEncoding, null
);
// zip :: [a] -> [b] -> [(a, b)]
const zip = (xs, ys) =>
xs.slice(0, Math.min(xs.length, ys.length))
.map((x, i) => Tuple(x, ys[i]));
// zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
const zipWith = (f, xs, ys) =>
Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => f(xs[i], ys[i], i));
// TREES ----------------------------------------
// First argument is a text accessor function
// eg x => x.root.String
// or x => x.root
// fScappleText :: Node -> String
const fScappleText = x => x.root.String || '';
// tabIndentFromTrees :: (Tree -> String) ->
// [Tree] -> String
const tabIndentFromTrees = fnText => trees => {
const go = indent => tree =>
indent + fnText(tree) +
(tree.nest.length > 0 ? (
'\n' + tree.nest
.map(go('\t' + indent))
.join('\n')
) : '');
return trees.map(go('')).join('\n');
};
// First argument is a text accessor function,
// e.g. x => x.root.String || ''
// or x => x.root
// opmlFromTrees :: String -> (Tree -> String) -> [Tree] ->
// Optional String -> OPML String
const opmlFromTrees = strTitle => fnText => xs => {
const
// ents :: [(Regex, String)]
ents = zipWith.apply(null,
cons(
(x, y) => [new RegExp(x, 'g'), '&' + y + ';'],
map(words, ['& \' " < >', 'amp apos quot lt gt'])
)
),
// entCoded :: a -> String
entCoded = v => ents.reduce(
(a, [x, y]) => a.replace(x, y),
v.toString()
),
// Nest -> Comma-delimited row indices of all parents in tree
// expands :: [textNest] -> String
expands = xs => {
const indexAndMax = (n, xs) =>
mapAccumL((m, node) =>
node.nest.length > 0 ? (() => {
const sub = indexAndMax(m + 1, node.nest);
return [sub[0], cons(m, concat(sub[1]))];
})() : [m + 1, []], n, xs);
return concat(indexAndMax(0, xs)[1]).join(',');
};
// go :: String -> Dict -> String
const go = indent => node =>
indent + '<outline ' + unwords(map(
([k, v]) => k + '="' + entCoded(v) + '"',
cons(['text', fnText(node) || ''], node.kvs || [])
)) + (node.nest.length > 0 ? (
'>\n' +
unlines(map(go(indent + ' '), node.nest)) +
'\n' +
indent + '</outline>'
) : '/>');
// OPML serialization -----------------------------
return unlines(concat([
[
'<?xml version=\"1.0\" encoding=\"utf-8\"?>',
'<opml version=\"2.0\">',
' <head>',
' <title>' + (strTitle || '') + '</title>',
' <expansionState>' + expands(xs) +
'</expansionState>',
' </head>',
' <body>'
],
map(go(' '), xs), [
' </body>',
'</opml>'
]
]));
};
// JXA ---------------------------------------------
const uw = ObjC.unwrap;
// filePathFromURL :: String -> String
const filePathFromURL = fileURL =>
drop(7, decodeURIComponent(fileURL));
// confirmSavePathLR :: FilePath -> Either Message FilePath
const confirmSavePathLR = fp => {
const
sa = standardSEAdditions(),
tpl = splitFileName(fp),
fldr = tpl[0],
fname = tpl[1];
return (() => {
sa.activate();
try {
return Right(
sa.chooseFileName({
withPrompt: "Save As:",
defaultName: fname,
defaultLocation: Path(ObjC.unwrap(
$(doesDirectoryExist(fldr) ? (
fldr
) : '~')
.stringByExpandingTildeInPath
))
})
.toString()
);
} catch (e) {
return Left(e.message)
};
})();
};
// savedAsOPMLR :: FilePath -> String ->
// Either String IOString
const savedAsOPMLR = fp => strOutline =>
bindLR(
confirmSavePathLR(fp),
fp => (
writeFile(fp, strOutline),
Right('OPML written to ' + fp)
)
);
// copiedAsTabIndentedLR :: String -> Either String IOString
const copiedAsTabIndentedLR = s =>
(
standardSEAdditions()
.setTheClipboardTo(s),
Right(s)
);
// SYSTEM EVENTS ------------------------------------
// standardSEAdditions :: () -> Application
const standardSEAdditions = () =>
Object.assign(Application('System Events'), {
includeStandardAdditions: true
});
// frontScappleWindowFileUrlLR :: () -> URL
const frontScappleWindowFileUrlLR = () => {
const
procs = standardSEAdditions()
.applicationProcesses.where({
name: 'Scapple'
});
return bindLR(
bindLR(
procs.length > 0 ? (
Right(procs.at(0).windows)
) : Left('No frontmost GUI app found'),
ws => ws.length > 0 ? (
Right(ws.at(0))
) : Left('No windows found for Scapple')
),
w => Right(w.attributes.byName('AXDocument').value())
);
};
// SCAPPLE XML ----------------------------------------------
// dictFromXMLPathStringLR :: String -> Either String Dict
const dictFromXMLPathStringLR = strPath => {
// attribsDictLR :: NSArray -> Either String Dict
const attribsDictLR = attribs => {
const v = uw(attribs);
return Array.isArray(v) ? (
uw(v[0].name) === 'ID' ? (
Right(
v.reduce(
(a, attr) =>
Object.assign(a, {
[uw(
attr.name
)]: uw(
attr.stringValue
)
}), {}
)
)
) : Left('No id')
) : Left('Not Array');
};
// xmlNodeDict :: NSXMLNode -> Dictionary
const xmlNodeDict = xmlNode => {
const
go = x => parseInt(x.childCount, 10) > 0 ? {
root: bindLR(
x.attributes !== undefined ? (
Right(x.attributes)
) : Left('None'),
attribsDictLR
).Right || uw(x.name),
nest: uw(x.children)
.map(go)
} : {
root: uw(x.stringValue),
nest: []
};
return go(xmlNode);
};
const fp = filePath(strPath);
return bindLR(
doesFileExist(fp) ? (
Right(readFile(fp))
) : Left('File not found: ' + fp),
strXML => {
const
error = $(),
node = $.NSXMLDocument.alloc
.initWithXMLStringOptionsError(
strXML, 0, error
);
return Boolean(error.code) ? (
Left(`File could not be parsed as XML: '${fp}'`)
) : Right(xmlNodeDict(node));
}
);
};
// listFromRangesString :: String -> [Int]
const listFromRangesString = s =>
concatMap(x => {
const parts = map(
s => parseInt(s, 10),
x.split('-')
);
return parts.length > 1 ? (
enumFromToInt(...parts)
) : isNaN(parts[0]) ? [] : parts;
}, s.split(/,\s*/g));
// nodeListFromFileLR :: FilePath -> Either String [Dict]
const nodeListFromFileLR = fp =>
bindLR(
dictFromXMLPathStringLR(fp),
dctXML => Right(map(
x => x.nest.reduce(
(a, child) => {
const k = child.root;
return k !== 'Appearance' ? (
Object.assign(a, {
[k]: k.endsWith('IDs') ? (
listFromRangesString(
child.nest[0].root
)
) : child.nest[0].root
})
) : a;
}, {
'ID': parseInt(x.root.ID, 10),
'xy': map(
s => parseFloat(s, 10),
x.root.Position.split(',')
)
}
),
dctXML.nest[0].nest[0].nest
))
);
// Root IDs at head of list, with remainder sorted by
// descending childCount and
// and ascending distance from origin at top left.
// rootIDsFromNodes :: [Dict] -> [Int]
const rootEtcIDsFromNodes = nodes => {
const
// Shapes with only *outgoing* arrows, vs. the rest.
tplRootsRest = partition(
x => eq(
x.ConnectedNoteIDs,
x.PointsToNoteIDs
),
// nodes sorted first by number of outgoing
// connections, ??
// and then by distance from
// origin at top left
sortBy(
mappendComparing_([
// [x => x.childCount, false],
[x => x.delta, true]
]),
map(x => Object.assign(x, {
delta: Math.sqrt(
Math.pow(x.xy[0], 2) +
Math.pow(x.xy[1], 2)
),
childCount: (x.PointsToNoteIDs || [])
.length
}),
nodes
)
)
);
return map(
x => x.ID,
tplRootsRest[0].concat(
tplRootsRest[1]
)
);
};
// geoSortedChildIDs :: Dict -> [Int] -> [Int]
const geoSortedChildIDs = (dctShapes, dctParent) => {
// Anti-clockwise angles to parent due North
const
ids = filter(
k => Boolean(dctShapes[k]),
dctParent.PointsToNoteIDs || []
),
[px, py] = dctParent.xy,
pi = Math.PI,
[east, south, west, north] = [2, 1.5, 1, 1.5]
.map(x => pi * x),
radDegrees = 180 / Math.PI,
// clockDegreesFromDxDy :: Bool -> Float -> Float -> Float
clockDegreesFromDxDy = (blnClock, radStart, dx, dy) =>
(radStart + (blnClock ? 1 : -1) * Math.atan2(dy, dx)) *
radDegrees % 360,
antiClockDegreesFromNorth = ([dx, dy]) =>
clockDegreesFromDxDy(
false, north, dx, dy
),
quadrantFunction = ma => {
const blnClock = ma <= 45 || ma > 225;
return ma <= 135 || ma > 315 ? (
blnClock ? (
([dx, dy]) => clockDegreesFromDxDy(
true, south, dx, dy
)
) : ([dx, dy]) => clockDegreesFromDxDy(
false, east, dx, dy
)
) : blnClock ? (
([dx, dy]) => clockDegreesFromDxDy(
true, west, dx, dy
)
) : antiClockDegreesFromNorth;
};
return Boolean(ids) && ids.length > 0 ? (() => {
const
// Anti-clockwise angles from North
// (of vectors from parent to children).
alphas = map(
k => {
const xy = dctShapes[k].xy;
return antiClockDegreesFromNorth(
[xy[0] - px, xy[1] - py]
);
},
ids
),
sortFn = quadrantFunction(
mean(
any(x => x < 45, alphas) &&
any(x => x > 315, alphas) ? (
map(x => x < 45 ? 360 + x : x, alphas)
) : alphas
)
);
return map(
x => x.ID,
sortBy(
comparing(x => x.theta),
map(k => {
const
shp = dctShapes[k],
xy = shp.xy;
return Object.assign(
shp, {
theta: sortFn(
[xy[0] - px, xy[1] - py]
)
}
);
},
ids || []
)
)
);
})() : [];
};
// scappleTreesFromIdList :: Dict -> [Int] -> [Tree]
const scappleTreesFromIdList = (dctShapes, shapeIDs) => {
const go = (rootIDs, visited) =>
foldl(
(a, intID) =>
a[1].includes(intID) ? (
a
) : (() => {
const
shpParent = dctShapes[intID],
tplSub = go(
geoSortedChildIDs(
dctShapes,
shpParent
),
a[1].concat(intID)
);
return Tuple(
a[0].concat(
Node(
shpParent,
tplSub[0]
)
),
tplSub[1]
);
})(), Tuple([], visited),
rootIDs
);
return go(shapeIDs, [])[0];
};
// CLIPBOARD XML ---------------------------------------------
// plistStringLR :: NSObject -> {error : String |
// Undefined, plist: String}
const plistStringLR = nsObject => {
let error = $();
const
v = $.NSString.alloc.initWithDataEncoding(
$.NSPropertyListSerialization
.dataWithPropertyListFormatOptionsError(
nsObject,
$.NSPropertyListXMLFormat_v1_0,
0,
error
),
$.NSUTF8StringEncoding
);
return Boolean(error.code) ? (
Left(error.localizedDescription)
) : Right(uw(v));
};
// xmlDocNodeLR :: XML String -> Either String XMLNode
const xmlDocNodeLR = strXML => {
const
error = $(),
node = $.NSXMLDocument.alloc
.initWithXMLStringOptionsError(
strXML, 0, error
);
return Boolean(error.code) ? (
Left('Could not be parsed as XML')
) : Right(node);
};
// plistArrayNodeFromXMLDoc :: XML Node -> XML Node
const plistArrayNodeFromXMLDoc = nodeDoc =>
nodeDoc.childAtIndex(0)
.childAtIndex(0)
.childAtIndex(3);
// nsIndicesFromNode :: xmlNode -> [Index]
const nsIndicesFromNode = node =>
map(x => uw(
x.childAtIndex(1)
.stringValue
),
uw(node.children)
);
// noteAttribDictFromIndex :: Plist Array -> Index -> Dict
const noteAttribDictFromIndex = (plistArray, index) =>
foldl(
(a, x, i, xs) => even(i) ? (() => {
const
nodeAttrib = plistArray[
uw(xs[1 + i].childAtIndex(1).stringValue)
],
k = uw(x.stringValue);
// return 'dict' !== uw(nodeAttrib.name) ? (() => {
return ('string' === k || 'uniqueID' === k) ? (() => {
const v = uw(nodeAttrib.stringValue);
return '$null' !== v ? (
a[k] = v,
a
) : a;
})() : a;
})() : a, {},
uw(plistArray[index].children)
);
// plistKeyDictFromArray :: Plist Array -> Dict
const plistKeyDictFromArray = plistArray => {
const dctIndices = foldl(
(a, x, i, xs) => 'key' === uw(
x.name
) ? (() => {
const k = uw(x.stringValue);
return k !== '$class' ? (
a[k] = nsIndicesFromNode(
xs[i + 1]
),
a
) : a
})() : a, {},
uw(dropWhile(
x => uw(x.name) !== 'dict',
plistArray
)[0].children)
);
return foldl(
(a, tpl) => (
a[tpl[0]] = tpl[1],
a
), {},
zip(map(
x => uw(
plistArray[x].stringValue
),
dctIndices['NS.keys']
), map(
x => plistArray[x],
dctIndices['NS.objects']
))
);
};
// attributesOfNotesLR :: XMLNode -> Either String [Dict]
const attributesOfNotesLR = nodeDoc => {
const
plistArray = uw(
plistArrayNodeFromXMLDoc(
nodeDoc
).children
),
ixArrays = dropWhile(
x => 'array' !== uw(x.name),
uw(plistKeyDictFromArray(
plistArray
)['Notes']
.children
)
);
return bindLR(
ixArrays.length > 0 ? (
Right(ixArrays[0])
) : Left('array not found in Notes dict'),
niArray => Right(map(
x => noteAttribDictFromIndex(
plistArray,
uw(
x.childAtIndex(1)
.stringValue
)
),
uw(niArray.children)
)));
};
// scappleNotesInClipboardLR :: Scapple () ->
// Either String [Dict]
const scappleNotesInClipboardLR = () => {
const clipScapple = ObjC.deepUnwrap(
$.NSPasteboard.generalPasteboard
.propertyListForType(
'com.scapple.internal.pboardType'
)
);
return bindLR(
Boolean(clipScapple) ? (
Right(clipScapple)
) : Left('No Scapple selection in clipboard'),
nsPlist => bindLR(
plistStringLR(nsPlist),
strXML => {
//console.log('strXML', strXML);
return bindLR(
xmlDocNodeLR(strXML),
attributesOfNotesLR
);
}
)
);
};
// allOrCopiedScapplesLR :: () -> Either String [Tree scappleShape]
const allOrCopiedScapplesLR = () =>
bindLR(
bindLR(
bindLR(
frontScappleWindowFileUrlLR(),
dot(Right, filePathFromURL)
),
nodeListFromFileLR
),
nodes => Right(
scappleTreesFromIdList(
// All nodes hashed by ID
foldl(
(a, x) => (
a[x.ID] = x,
a
), {},
nodes
),
// Copied nodes or all nodes
bindLR(
scappleNotesInClipboardLR(),
xs => Right(map(
x => parseInt(x.uniqueID, 10),
xs
))
).Right || rootEtcIDsFromNodes(nodes)
)
)
);
// MAIN ------------------------------------------------
return main();
})();