Is there a good outline path that will look for duplicates? By duplicates I mean rows that contain exactly the same text, without specifying what that text is.
I don’t think you can do this with just a path, but this script should do it I think:
tell application "Bike"
tell front document
repeat with each in rows
set theText to name of each
set thePath to "//@text = \"" & theText & "\""
set theMatches to query outline path thePath
if (count of theMatches) > 1 then
display dialog theText
end if
end repeat
end tell
end tell
and with a script you could, of course, also define various kinds of listing.
Here, for example, a list of labelled bike links (to duplicate lines) is displayed and copied to the clipboard.
Expand disclosure triangle to view JS source
(() => {
"use strict";
ObjC.import("AppKit");
// Copy list of links to duplicated lines
// in the front Bike document.
// Rob Trew @2023
// Ver 0.02
// main :: IO ()
const main = () => {
const
bike = Application("Bike"),
doc = bike.documents.at(0);
return either(
alert("Duplicated lines in Bike document")
)(
compose(
alert("Links to duplicates copied to clipboard"),
copyText
)
)(
doc.exists()
? duplicateRowsLR(doc)
: Left("No document open in Bike")
);
};
// duplicateRowsLR :: Bike Doc -> Either String String
const duplicateRowsLR = doc => {
// Either a message or list of Bike links
// to duplicated rows in the document.
const
docID = doc.id(),
rows = doc.rows,
duplicates = groupOn(fst)(
sortOn(fst)(
zip(
rows.name()
.map(x => x.trim())
)(
rows.id()
)
)
)
.flatMap(
xs => 1 < xs.length
? xs.map(
([txt, id]) =>
`[${txt}](bike://${docID}/#${id})`
)
: []
);
return 0 < duplicates.length
? Right(
duplicates.join("\n")
)
: Left("No duplicates found");
};
// ----------------------- 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
);
};
// 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
});
// compose (<<<) :: (b -> c) -> (a -> b) -> a -> c
const compose = (...fs) =>
// A function defined by the right-to-left
// composition of all the functions in fs.
fs.reduce(
(f, g) => x => f(g(x)),
x => x
);
// 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);
// comparing :: Ord a => (b -> a) -> b -> b -> Ordering
const comparing = f =>
// The ordering of f(x) and f(y) as a value
// drawn from {-1, 0, 1}, representing {LT, EQ, GT}.
x => y => {
const
a = f(x),
b = f(y);
return a < b ? -1 : (a > b ? 1 : 0);
};
// fst :: (a, b) -> a
const fst = tpl =>
// First member of a pair.
tpl[0];
// groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
const groupBy = eqOp =>
// A list of lists, each containing only elements
// equal under the given equality operator, such
// that the concatenation of these lists is xs.
xs => 0 < xs.length ? (() => {
const [h, ...t] = xs;
const [groups, g] = t.reduce(
([gs, a], x) => eqOp(a[0])(x) ? (
[gs, [...a, x]]
) : [[...gs, a], [x]],
[[], [h]]
);
return [...groups, g];
})() : [];
// groupOn :: (a -> b) -> [a] -> [[a]]
const groupOn = f =>
// A list of lists, each containing only elements
// which return equal values for f,
// such that the concatenation of these lists is xs.
xs => 0 < xs.length
? groupBy(a => b => a[0] === b[0])(
xs.map(x => [f(x), x])
)
.map(gp => gp.map(ab => ab[1]))
: [];
// sortBy :: (a -> a -> Ordering) -> [a] -> [a]
const sortBy = f =>
// A copy of xs sorted by the comparator function f.
xs => xs.slice()
.sort((a, b) => f(a)(b));
// sortOn :: Ord b => (a -> b) -> [a] -> [a]
const sortOn = f =>
// Equivalent to sortBy(comparing(f)), but with f(x)
// evaluated only once for each x in xs.
// ('Schwartzian' decorate-sort-undecorate).
xs => sortBy(
comparing(x => x[0])
)(
xs.map(x => [f(x), x])
)
.map(x => x[1]);
// 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]]);
return main();
})();
To test in Script Editor, set the language selector at top left to JavaScript
rather than AppleScript
See: Using Scripts - Bike
Thank to both. My goal was to automate the creation of a master task list within a document, where the list would be populated by “mirrors” of tasks in the rest of the outline. A “mirror” is simply a task type row with the name and link to the original task.
I wanted the automation to omit tasks that are already mirrored on the master list.
The solution was to run two separate queries, one querying the outline for tasks (except the master task list) and the other querying the master task list, then checking to see if the first query includes items already in the second query. If there’s a task missing on the master list, the automation creates a mirror to the missing tasks inside the master task list.
I’m attaching the demo shortcut if anyone wants to play with it.
Bike Create master todo list.shortcut.zip (13.3 KB)
Some improvements that could be made:
- the Master task list needs to have at least one task item inside for the script to work. I’m sure there’s a way to fix that;
- a better way to compare lists would be by checking if links in the master task list include row IDs from the rest of the outline, instead of checking the row name. This would allow the inclusion of tasks that share the same name. I’m guessing there’s a way to do that with
//*/run::@link
but haven’t the time to follow this through.