Rough sketch, using Keyboard Maestro to periodically trigger an update of a stylesheet called Dated.less
Updated TaskPaper LESS (styling based on relative values of @due dates).kmmacros.zip (5.9 KB)
Window > Stylesheet
Every N seconds (or minutes or hours, depending on the macro settings)
(Here – for testing – every 10 seconds, which is probably much too frequent)
Dated.less
is written out afresh at each timed update from two parts:
- A main set of css styles stored in the macro as
local_taskPaperMainStyle
- appended lines which specify some extra styles in relation to dates near today.
The spec for these relative date styles is given in the macro as local_taskPaperTimedStyles
and specified, for example, like this:
@due[-1, 0, 1] -> color: rgb(250, 0, 0)
@due[2, 3, 4] -> color: rgb(250, 250, 0)
@due[5, 6, 7] -> color: rgb(0, 250, 0)
Which:
- Start with
@
, naming the tag on which the style will depend - Give some integers in JSON list format, where
0
is today,1
is tomorrow,-1
is yesterday, etc etc - Give, after an
->
arrow, one or more CSS clauses (semi-colon delimited if multiple)
Yielding, for a file in which each item has a different @due
date, something like this:
where the rules in local_taskPaperTimedStyles
have been written out at the end of Dated.less
in CSS format (for today, 2022-03-31
) as something like:
item[data-due*="2022-03-30"],
item[data-due*="2022-03-31"],
item[data-due*="2022-04-01"] {
color: rgb(250, 0, 0)
}
item[data-due*="2022-04-02"],
item[data-due*="2022-04-03"],
item[data-due*="2022-04-04"] {
color: rgb(250, 250, 0)
}
item[data-due*="2022-04-05"],
item[data-due*="2022-04-06"],
item[data-due*="2022-04-07"] {
color: rgb(0, 250, 0)
}
Expand disclosure triangle to view JS Source
(() => {
"use strict";
// Rob Trew @2022
// main :: IO ()
const main = () => {
const
kme = Application("Keyboard Maestro Engine"),
instanceID = ObjC.unwrap(
$.NSProcessInfo.processInfo.environment
.objectForKey("KMINSTANCE")
),
kmVar = k => Boolean(instanceID) ? (
kme.getvariable(`local_${k}`, {
instance: instanceID
})
) : kme.getvariable(k),
fpTPLess = combine(
combine(
applicationSupportPath()
)(
"TaskPaper/StyleSheets"
)
)(
kmVar("lessFileName")
),
mainCSS = kmVar("taskPaperMainStyle"),
specLines = lines(
kmVar("taskPaperTimedStyles")
).filter(x => Boolean(x.trim())),
message = alert("Dated TaskPaper styles");
return either(message)(xs => {
const cssDated = xs.join("\n\n");
return either(message)(
() => `Updated ${todayPlus(0)}:\n${fpTPLess}`
)(
writeFileLR(fpTPLess)(
`${mainCSS}\n\n${cssDated}`
)
);
})(
traverseList(datedStyleLR)(specLines)
);
};
// ----------------------- CSS -----------------------
// datedStyleLR :: String -> Either String String
const datedStyleLR = spec => {
// Returns either a message or
// a CSS selector and style
// for a spec like:
// @due[-1,0,1] -> color: rgb(250, 0, 0)
// where 0 signifies today, -1 yesterday,
// 1 tomorrow, etc etc
const
s = spec.trim(),
mbMatches = (
/^@(\w+)(\[[-+ ,0-9]+\])\s*->\s*(.*)$/gu
).exec(s);
return bindLR(
null === mbMatches ? (
Left(`Could not parse: ${spec}`)
) : jsonParseLR(mbMatches[2])
)(deltas => {
const
tag = mbMatches[1],
format = mbMatches[3],
selectors = deltas.map(
n => `item[data-${tag}*="${todayPlus(n)}"]`
)
.join(",\n");
return Right(
`${selectors} {\n ${format}\n}`
);
});
};
// ---------------------- DATES ----------------------
// todayPlus :: Int -> IO ISO8601 Day String
const todayPlus = n => {
// A date string for today (+/-) n days
// Today's date where n = 0
// yesterday where n = -1
// tomorrow where n = 1
const dayMilliSeconds = 8.64e+7;
return iso8601Local(
new Date(
(new Date()).getTime() + (
dayMilliSeconds * n
)
)
)
.slice(0, 10);
};
// iso8601Local :: Date -> String
const iso8601Local = dte =>
new Date(dte - (6E4 * dte.getTimezoneOffset()))
.toISOString();
// ----------------------- 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
);
};
// applicationSupportPath :: () -> String
const applicationSupportPath = () => {
const uw = ObjC.unwrap;
return uw(
uw($.NSFileManager.defaultManager
.URLsForDirectoryInDomains(
$.NSApplicationSupportDirectory,
$.NSUserDomainMask
)
)[0].path
);
};
// writeFileLR :: FilePath ->
// String -> Either String IO FilePath
const writeFileLR = fp =>
// Either a message or the filepath
// to which the string has been written.
s => {
const
e = $(),
efp = $(fp).stringByStandardizingPath;
return $.NSString.alloc.initWithUTF8String(s)
.writeToFileAtomicallyEncodingError(
efp, false,
$.NSUTF8StringEncoding, e
) ? (
Right(ObjC.unwrap(efp))
) : Left(ObjC.unwrap(
e.localizedDescription
));
};
// --------------------- 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 => 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;
// 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
);
// cons :: a -> [a] -> [a]
const cons = x =>
// A list constructed from the item x,
// followed by the existing list xs.
xs => Array.isArray(xs) ? (
[x].concat(xs)
) : "GeneratorFunction" !== xs.constructor.constructor.name ? (
x + xs
) : (
function* () {
yield x;
let nxt = xs.next();
while (!nxt.done) {
yield nxt.value;
nxt = xs.next();
}
}()
);
// 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);
// flip :: (a -> b -> c) -> b -> a -> c
const flip = op =>
// The binary function op with
// its arguments reversed.
1 !== op.length ? (
(a, b) => op(b, a)
) : (a => b => op(b)(a));
// fType :: (a -> f b) -> f
const fType = g => {
const s = g.toString();
return s.includes("Right") ? (
Right
) : s.includes("Left") ? (
Left
) : s.includes("Nothing") ? (
Just
) : s.includes("Node") ? (
flip(Node)([])
) : x => [x];
};
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(
[
e.message,
`(line:${e.line} col:${e.column})`
].join("\n")
);
}
};
// liftA2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
const liftA2 = f =>
// Lift a binary function to actions.
// liftA2 f a b = fmap f a <*> b
a => b => ({
"(a -> b)": () => liftA2Fn,
"Either": () => liftA2LR,
"Maybe": () => liftA2May,
"Tuple": () => liftA2Tuple,
"Node": () => liftA2Tree,
"List": () => liftA2List,
"Bottom": () => liftA2List
} [typeName(a) || "List"]())(f)(a)(b);
// liftA2LR :: (a -> b -> c) -> Either d a -> Either d b -> Either d c
const liftA2LR = f =>
// The binary function f lifted to a
// function over two Either values.
a => b => bindLR(a)(
x => bindLR(b)(
compose(Right, f(x))
)
);
// lines :: String -> [String]
const lines = s =>
// A list of strings derived from a single string
// which is delimited by \n or by \r\n or \r.
0 < s.length ? (
s.split(/\r\n|\n|\r/u)
) : [];
// pureLR :: a -> Either e a
const pureLR = x =>
// The value x lifted into the Either monad.
Right(x);
// pureT :: String -> f a -> (a -> f a)
const pureT = t =>
// Given a type name string, returns a
// specialised "pure", where
// "pure" lifts a value into a particular functor.
({
"Either": () => pureLR,
"(a -> b)": () => constant,
"Maybe": () => pureMay,
"Node": () => pureTree,
"Tuple": () => pureTuple,
"List": () => pureList
})[t || "List"]();
// traverseList :: (Applicative f) => (a -> f b) ->
// [a] -> f [b]
const traverseList = f =>
// Collected results of mapping each element
// of a structure to an action, and evaluating
// these actions from left to right.
xs => 0 < xs.length ? (() => {
const
vLast = f(xs.slice(-1)[0]),
t = typeName(vLast);
return xs.slice(0, -1).reduceRight(
(ys, x) => liftA2(cons)(f(x))(ys),
liftA2(cons)(vLast)(pureT(t)([]))
);
})() : fType(f)([]);
// typeName :: a -> String
const typeName = v => {
const t = typeof v;
return "object" === t ? (
null !== v ? (
Array.isArray(v) ? (
"List"
) : "Date" === v.constructor.name ? (
"Date"
) : null !== v ? (() => {
const ct = v.type;
return Boolean(ct) ? (
(/Tuple\d+/u).test(ct) ? (
"TupleN"
) : ct
) : "Dict";
})() : "Bottom"
) : "Bottom"
) : {
"boolean": "Bool",
"date": "Date",
"number": "Num",
"string": "String",
"function": "(a -> b)"
} [t] || "Bottom";
};
// showLog :: a -> IO ()
const showLog = (...args) =>
// eslint-disable-next-line no-console
console.log(
args
.map(JSON.stringify)
.join(" -> ")
);
// sj :: a -> String
const sj = (...args) =>
// Abbreviation of showJSON for quick testing.
// Default indent size is two, which can be
// overriden by any integer supplied as the
// first argument of more than one.
JSON.stringify.apply(
null,
1 < args.length && !isNaN(args[0]) ? [
args[1], null, args[0]
] : [args[0], null, 2]
);
return (
main()
);
})();