slack-term 1.45.0 → 1.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +14 -3
- package/dist/cli.js +194 -193
- package/package.json +1 -1
- package/ts/botIntent.ts +1 -0
- package/ts/cli.ts +211 -6
- package/ts/slack.ts +32 -0
- package/ts/slackTodos.ts +4 -3
package/package.json
CHANGED
package/ts/botIntent.ts
CHANGED
|
@@ -15,6 +15,7 @@ const WRITES = new Set([
|
|
|
15
15
|
"schedule send", "schedule rm",
|
|
16
16
|
"pinlog new", "pinlog set", "pinlog note", "pinlog pin",
|
|
17
17
|
"todo set", "todo flag",
|
|
18
|
+
"todos add", "todos done", "todos complete", "todos edit", "todos rm", "todos remove", "todos delete",
|
|
18
19
|
"lists create", "lists add", "lists set", "lists share",
|
|
19
20
|
"drafts new", "drafts save", "drafts edit", "drafts update", "drafts delete", "drafts rm",
|
|
20
21
|
"channel create", "channel new", "channel rename", "channel mv",
|
package/ts/cli.ts
CHANGED
|
@@ -117,7 +117,10 @@ import {
|
|
|
117
117
|
userName,
|
|
118
118
|
getPath,
|
|
119
119
|
type Json,
|
|
120
|
+
savedAdd,
|
|
121
|
+
savedDelete,
|
|
120
122
|
savedList,
|
|
123
|
+
savedUpdate,
|
|
121
124
|
} from "./slack.ts";
|
|
122
125
|
import { isDone, messagePermalink, parseSavedItems, sortTodos, TODO_FILTERS, todoStatus, type TodoFilter, type TodoItem } from "./slackTodos.ts";
|
|
123
126
|
import {
|
|
@@ -883,8 +886,13 @@ async function cmdDrafts(token: string, cookie?: string, showAll = false): Promi
|
|
|
883
886
|
}
|
|
884
887
|
|
|
885
888
|
// --- todos (Slack's To-dos tab) ---
|
|
886
|
-
// Read-only.
|
|
887
|
-
//
|
|
889
|
+
// Read-only. Reads the whole view, sorts it by due date, then shows the first
|
|
890
|
+
// `limit` with where each lives and the message it points at. saved.list does
|
|
891
|
+
// not return items in due order, so cutting before sorting could hide an
|
|
892
|
+
// overdue to-do behind a later one; the read is capped at TODOS_MAX_PAGES.
|
|
893
|
+
const TODOS_PAGE = 50;
|
|
894
|
+
const TODOS_MAX_PAGES = 20;
|
|
895
|
+
|
|
888
896
|
async function cmdTodos(
|
|
889
897
|
token: string,
|
|
890
898
|
cookie: string | undefined,
|
|
@@ -892,12 +900,14 @@ async function cmdTodos(
|
|
|
892
900
|
): Promise<void> {
|
|
893
901
|
const items: TodoItem[] = [];
|
|
894
902
|
let cursor: string | undefined;
|
|
903
|
+
let pages = 0;
|
|
895
904
|
do {
|
|
896
|
-
const resp = asRecord((await savedList(token, { filter: opts.filter, limit:
|
|
905
|
+
const resp = asRecord((await savedList(token, { filter: opts.filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
|
|
897
906
|
items.push(...parseSavedItems(resp as Json));
|
|
898
907
|
const next = asRecord(resp.response_metadata).next_cursor;
|
|
899
908
|
cursor = typeof next === "string" && next ? next : undefined;
|
|
900
|
-
} while (cursor &&
|
|
909
|
+
} while (cursor && ++pages < TODOS_MAX_PAGES);
|
|
910
|
+
if (cursor) console.error(`Note: read the first ${items.length} items only (${TODOS_MAX_PAGES} pages); the order covers those.`);
|
|
901
911
|
const shown = sortTodos(items).slice(0, opts.limit);
|
|
902
912
|
|
|
903
913
|
if (shown.length === 0) {
|
|
@@ -941,6 +951,139 @@ async function cmdTodos(
|
|
|
941
951
|
}
|
|
942
952
|
}
|
|
943
953
|
|
|
954
|
+
// --- todos add / done / edit / rm — gated writes to your To-dos ---
|
|
955
|
+
// Every To-do points at a message, so the target is a message (permalink or
|
|
956
|
+
// #chan:<ts>). Same two-step as send: the first run previews and prints a code,
|
|
957
|
+
// the rerun with --code=<code> writes. Your own To-dos only — the session token
|
|
958
|
+
// can touch no one else's.
|
|
959
|
+
type TodoOp =
|
|
960
|
+
| { kind: "add"; due?: number; saved?: boolean }
|
|
961
|
+
| { kind: "done"; undo: boolean }
|
|
962
|
+
| { kind: "edit"; due?: number; todo?: boolean }
|
|
963
|
+
| { kind: "rm" };
|
|
964
|
+
|
|
965
|
+
// The saved item for one message (saved.delete answers ok even when there is
|
|
966
|
+
// nothing to delete, so writes check first). Views are searched in the order
|
|
967
|
+
// the op most likely needs, stopping at the first match, and the whole search
|
|
968
|
+
// is capped at TODOS_MAX_PAGES pages so a long saved history can't turn one
|
|
969
|
+
// command into a burst of saved.list calls. "unknown" = cap hit, not found.
|
|
970
|
+
function findOrder(op: TodoOp): TodoFilter[] {
|
|
971
|
+
if (op.kind === "done") return op.undo ? ["completed", "todo", "saved", "archived"] : ["todo", "saved", "completed", "archived"];
|
|
972
|
+
return ["todo", "saved", "completed", "archived"];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function findSavedItem(
|
|
976
|
+
token: string,
|
|
977
|
+
cookie: string | undefined,
|
|
978
|
+
channel: string,
|
|
979
|
+
ts: string,
|
|
980
|
+
order: TodoFilter[],
|
|
981
|
+
): Promise<TodoItem | "unknown" | undefined> {
|
|
982
|
+
let pages = 0;
|
|
983
|
+
for (const filter of order) {
|
|
984
|
+
let cursor: string | undefined;
|
|
985
|
+
do {
|
|
986
|
+
if (pages++ >= TODOS_MAX_PAGES) return "unknown";
|
|
987
|
+
const resp = asRecord((await savedList(token, { filter, limit: TODOS_PAGE, cursor }, cookie)) as Json);
|
|
988
|
+
const hit = parseSavedItems(resp as Json).find((t) => t.channel === channel && t.ts === ts);
|
|
989
|
+
if (hit) return hit;
|
|
990
|
+
const next = asRecord(resp.response_metadata).next_cursor;
|
|
991
|
+
cursor = typeof next === "string" && next ? next : undefined;
|
|
992
|
+
} while (cursor);
|
|
993
|
+
}
|
|
994
|
+
return undefined;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function todoOpLines(op: TodoOp, fmtDue: (sec: number) => string): string[] {
|
|
998
|
+
const due = (d: number | undefined) => (d === undefined ? [] : [` Due: ${d ? fmtDue(d) : "(clear the due date)"}`]);
|
|
999
|
+
switch (op.kind) {
|
|
1000
|
+
case "add": return [` Action: add as ${op.saved ? "a saved item (no checkbox)" : "a to-do"}`, ...due(op.due)];
|
|
1001
|
+
case "done": return [` Action: mark ${op.undo ? "NOT done (reopen)" : "done"}`];
|
|
1002
|
+
case "edit": return [
|
|
1003
|
+
" Action: edit",
|
|
1004
|
+
...(op.todo === undefined ? [] : [` Kind: ${op.todo ? "to-do" : "saved item (no checkbox)"}`]),
|
|
1005
|
+
...due(op.due),
|
|
1006
|
+
];
|
|
1007
|
+
case "rm": return [" Action: REMOVE from your To-dos (the message itself is untouched)"];
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
async function cmdTodosWrite(token: string, cookie: string | undefined, target: string, op: TodoOp, code?: string): Promise<void> {
|
|
1012
|
+
const { ref, ts } = splitRefTs(target);
|
|
1013
|
+
if (!ts) {
|
|
1014
|
+
console.error("Error: a To-do points at a message — give its permalink or #chan:<ts> (see `slack todos ls`).");
|
|
1015
|
+
process.exit(2);
|
|
1016
|
+
}
|
|
1017
|
+
if (op.kind === "edit" && op.due === undefined && op.todo === undefined) {
|
|
1018
|
+
console.error("Error: nothing to edit — pass --due <time|none>, --todo or --saved.");
|
|
1019
|
+
process.exit(2);
|
|
1020
|
+
}
|
|
1021
|
+
const channel = await resolveChannel(token, ref, cookie);
|
|
1022
|
+
const found = await findSavedItem(token, cookie, channel, ts, findOrder(op));
|
|
1023
|
+
if (found === "unknown" && op.kind !== "add") {
|
|
1024
|
+
console.error(`Error: not found in the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — stopped there to stay under Slack's rate limits. Nothing was changed.`);
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
}
|
|
1027
|
+
const existing = found === "unknown" ? undefined : found;
|
|
1028
|
+
if (op.kind === "add" && existing) {
|
|
1029
|
+
console.error(`Error: that message is already in your To-dos (${isDone(existing) ? "done" : existing.todoState || "saved"}) — use \`slack todos edit\` or \`done\`.`);
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
if (op.kind !== "add" && !existing) {
|
|
1033
|
+
console.error("Error: that message is not in your To-dos — nothing to change. `slack todos ls --filter saved` lists everything saved.");
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
let teamUrl = "";
|
|
1038
|
+
let me = "";
|
|
1039
|
+
try { ({ url: teamUrl, userId: me } = await authScopes(token, cookie)); } catch { /* preview degrades */ }
|
|
1040
|
+
const label = await (await buildChLabelResolver(token, cookie, me))(channel);
|
|
1041
|
+
let msg: Record<string, Json> | undefined;
|
|
1042
|
+
try {
|
|
1043
|
+
const r = asRecord((await replies(token, channel, ts, 1, cookie)) as Json);
|
|
1044
|
+
msg = asArray(r.messages).map(asRecord).find((m) => m.ts === ts);
|
|
1045
|
+
} catch { /* not readable */ }
|
|
1046
|
+
const author = typeof msg?.user === "string" ? `@${await userName(token, msg.user, cookie)}: ` : "";
|
|
1047
|
+
const raw = typeof msg?.text === "string" ? msg.text.split("\n")[0] ?? "" : "(message not readable)";
|
|
1048
|
+
const head = maskSecrets(raw.length > 100 ? `${raw.slice(0, 100)}…` : raw);
|
|
1049
|
+
const now = Date.now() / 1000;
|
|
1050
|
+
|
|
1051
|
+
const expected = safetyCode("todos", JSON.stringify(op), channel, ts, me);
|
|
1052
|
+
if (code !== expected) {
|
|
1053
|
+
requireCode(code, expected, [
|
|
1054
|
+
"--- To-do ------------------------------------",
|
|
1055
|
+
` For: ${me ? `@${await userName(token, me, cookie)} (your own To-dos)` : "(your own To-dos)"}`,
|
|
1056
|
+
` Where: ${stripTerminalControls(label)}`,
|
|
1057
|
+
` Msg: ${stripTerminalControls(author + head)}`,
|
|
1058
|
+
...(teamUrl ? [` Link: ${messagePermalink(teamUrl, channel, ts)}`] : []),
|
|
1059
|
+
...(existing ? [` Now: ${todoStatus(existing, now, formatYmdHm)}`] : []),
|
|
1060
|
+
...(found === "unknown" ? [` Note: not checked beyond the first ${TODOS_MAX_PAGES * TODOS_PAGE} saved items — it may already be saved`] : []),
|
|
1061
|
+
...todoOpLines(op, fmtPostAt),
|
|
1062
|
+
"----------------------------------------------",
|
|
1063
|
+
]);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
const at = { channel, ts };
|
|
1067
|
+
switch (op.kind) {
|
|
1068
|
+
case "add":
|
|
1069
|
+
await savedAdd(token, at, { todo: !op.saved, ...(op.due ? { due: op.due } : {}) }, cookie);
|
|
1070
|
+
console.log(`✓ Added to your To-dos${op.due ? ` (due ${formatYmdHm(op.due)})` : ""}`);
|
|
1071
|
+
break;
|
|
1072
|
+
case "done":
|
|
1073
|
+
await savedUpdate(token, at, { mark: op.undo ? "uncompleted" : "completed" }, cookie);
|
|
1074
|
+
console.log(op.undo ? "✓ Reopened" : "✓ Marked done");
|
|
1075
|
+
break;
|
|
1076
|
+
case "edit":
|
|
1077
|
+
await savedUpdate(token, at, { ...(op.todo === undefined ? {} : { todo: op.todo }), ...(op.due === undefined ? {} : { due: op.due }) }, cookie);
|
|
1078
|
+
console.log("✓ Updated");
|
|
1079
|
+
break;
|
|
1080
|
+
case "rm":
|
|
1081
|
+
await savedDelete(token, at, cookie);
|
|
1082
|
+
console.log("✓ Removed from your To-dos");
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
944
1087
|
// --- drafts get ---
|
|
945
1088
|
async function cmdDraftGet(token: string, cookie: string | undefined, draftId: string): Promise<void> {
|
|
946
1089
|
const resp = (await listDrafts(token, cookie)) as Record<string, Json>;
|
|
@@ -4752,6 +4895,16 @@ async function botDmForTarget(
|
|
|
4752
4895
|
return openDm(botToken, userId);
|
|
4753
4896
|
}
|
|
4754
4897
|
|
|
4898
|
+
/** A `--due` value as epoch seconds; exits 2 on an unparseable one. */
|
|
4899
|
+
function todoDue(v: string): number {
|
|
4900
|
+
try {
|
|
4901
|
+
return parsePostAt(v);
|
|
4902
|
+
} catch {
|
|
4903
|
+
console.error(`Error: cannot parse --due "${v}" — use an ISO datetime (2026-11-01T18:00) or a Unix ts.`);
|
|
4904
|
+
process.exit(2);
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
|
|
4755
4908
|
function parsePostAt(at: string): number {
|
|
4756
4909
|
if (/^\d{10,}$/.test(at)) return parseInt(at, 10);
|
|
4757
4910
|
const d = new Date(at.replace(" ", "T"));
|
|
@@ -7147,7 +7300,7 @@ async function main(): Promise<void> {
|
|
|
7147
7300
|
)
|
|
7148
7301
|
.command(
|
|
7149
7302
|
"todos",
|
|
7150
|
-
"Slack's To-dos tab — your personal to-dos (
|
|
7303
|
+
"Slack's To-dos tab — your personal to-dos (requires xoxc desktop token). Not `slack todo` (reaction tasks)",
|
|
7151
7304
|
(y) => y.command(
|
|
7152
7305
|
["$0", "ls", "list"],
|
|
7153
7306
|
"List your To-dos, soonest due first",
|
|
@@ -7162,7 +7315,59 @@ async function main(): Promise<void> {
|
|
|
7162
7315
|
}
|
|
7163
7316
|
await cmdTodos(tok(argv as W), ck(argv as W), { filter: argv.filter as TodoFilter, limit: argv.limit, json: argv.json });
|
|
7164
7317
|
},
|
|
7165
|
-
)
|
|
7318
|
+
)
|
|
7319
|
+
.command(
|
|
7320
|
+
"add <target>",
|
|
7321
|
+
"Add a message to your To-dos (two-step: preview, then --code)",
|
|
7322
|
+
(y2) => y2
|
|
7323
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7324
|
+
.option("due", { type: "string", describe: "Due time (ISO datetime or Unix ts)" })
|
|
7325
|
+
.option("saved", { type: "boolean", default: false, describe: "Save it without a checkbox instead of as a to-do" })
|
|
7326
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7327
|
+
async (argv) => {
|
|
7328
|
+
const due = argv.due === undefined ? undefined : todoDue(argv.due);
|
|
7329
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "add", ...(due ? { due } : {}), saved: argv.saved }, argv.code);
|
|
7330
|
+
},
|
|
7331
|
+
)
|
|
7332
|
+
.command(
|
|
7333
|
+
["done <target>", "complete <target>"],
|
|
7334
|
+
"Check a to-do off (--undo reopens it)",
|
|
7335
|
+
(y2) => y2
|
|
7336
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7337
|
+
.option("undo", { type: "boolean", default: false, describe: "Mark it not done again" })
|
|
7338
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7339
|
+
async (argv) => {
|
|
7340
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "done", undo: argv.undo }, argv.code);
|
|
7341
|
+
},
|
|
7342
|
+
)
|
|
7343
|
+
.command(
|
|
7344
|
+
"edit <target>",
|
|
7345
|
+
"Change a to-do's due date, or switch it between to-do and saved item",
|
|
7346
|
+
(y2) => y2
|
|
7347
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7348
|
+
.option("due", { type: "string", describe: "New due time (ISO datetime or Unix ts), or `none` to clear it" })
|
|
7349
|
+
.option("todo", { type: "boolean", describe: "Make it a to-do (with a checkbox)" })
|
|
7350
|
+
.option("saved", { type: "boolean", describe: "Make it a plain saved item (no checkbox)" })
|
|
7351
|
+
.conflicts("todo", "saved")
|
|
7352
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7353
|
+
async (argv) => {
|
|
7354
|
+
const due = argv.due === undefined ? undefined : argv.due === "none" ? 0 : todoDue(argv.due);
|
|
7355
|
+
const todo = argv.todo ? true : argv.saved ? false : undefined;
|
|
7356
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, {
|
|
7357
|
+
kind: "edit", ...(due === undefined ? {} : { due }), ...(todo === undefined ? {} : { todo }),
|
|
7358
|
+
}, argv.code);
|
|
7359
|
+
},
|
|
7360
|
+
)
|
|
7361
|
+
.command(
|
|
7362
|
+
["rm <target>", "remove <target>", "delete <target>"],
|
|
7363
|
+
"Remove a message from your To-dos (the message stays)",
|
|
7364
|
+
(y2) => y2
|
|
7365
|
+
.positional("target", { type: "string", demandOption: true, describe: "Message permalink or #chan:<ts>" })
|
|
7366
|
+
.option("code", { type: "string", describe: "Confirmation code from the preview" }),
|
|
7367
|
+
async (argv) => {
|
|
7368
|
+
await cmdTodosWrite(tok(argv as W), ck(argv as W), argv.target!, { kind: "rm" }, argv.code);
|
|
7369
|
+
},
|
|
7370
|
+
),
|
|
7166
7371
|
)
|
|
7167
7372
|
.command(
|
|
7168
7373
|
"drafts",
|
package/ts/slack.ts
CHANGED
|
@@ -1119,6 +1119,38 @@ export async function savedList(
|
|
|
1119
1119
|
return postSession(token, "saved.list", params, cookie);
|
|
1120
1120
|
}
|
|
1121
1121
|
|
|
1122
|
+
// To-dos writes (internal, session token). Verified live 2026-10-26: add with
|
|
1123
|
+
// todo_state=to_do (+ date_due) creates a to-do; update mark=completed checks it
|
|
1124
|
+
// off (todo_state becomes "completed"); delete removes it. A To-do always
|
|
1125
|
+
// points at a message: item_type=message, item_id=channel, ts=message ts.
|
|
1126
|
+
export type SavedRef = { channel: string; ts: string };
|
|
1127
|
+
const savedIds = (r: SavedRef) => ({ item_type: "message", item_id: r.channel, ts: r.ts });
|
|
1128
|
+
|
|
1129
|
+
export async function savedAdd(token: string, ref: SavedRef, opts: { todo?: boolean; due?: number } = {}, cookie?: string): Promise<Json> {
|
|
1130
|
+
const params: Record<string, string> = { ...savedIds(ref), todo_state: opts.todo === false ? "saved" : "to_do" };
|
|
1131
|
+
if (opts.due) params.date_due = String(opts.due);
|
|
1132
|
+
return postSession(token, "saved.add", params, cookie);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** `mark`: completed | uncompleted | archived | unarchived. `due`: epoch seconds, 0 clears it. */
|
|
1136
|
+
export async function savedUpdate(
|
|
1137
|
+
token: string,
|
|
1138
|
+
ref: SavedRef,
|
|
1139
|
+
opts: { mark?: "completed" | "uncompleted" | "archived" | "unarchived"; todo?: boolean; due?: number },
|
|
1140
|
+
cookie?: string,
|
|
1141
|
+
): Promise<Json> {
|
|
1142
|
+
const params: Record<string, string> = savedIds(ref);
|
|
1143
|
+
if (opts.mark) params.mark = opts.mark;
|
|
1144
|
+
if (opts.todo !== undefined) params.todo_state = opts.todo ? "to_do" : "saved";
|
|
1145
|
+
if (opts.due !== undefined) params.date_due = String(opts.due);
|
|
1146
|
+
return postSession(token, "saved.update", params, cookie);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Slack answers ok even when nothing was saved — callers check first. */
|
|
1150
|
+
export async function savedDelete(token: string, ref: SavedRef, cookie?: string): Promise<Json> {
|
|
1151
|
+
return postSession(token, "saved.delete", savedIds(ref), cookie);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1122
1154
|
// Draft API (internal — requires xoxc session token + xoxd cookie)
|
|
1123
1155
|
export async function listDrafts(token: string, cookie?: string): Promise<Json> {
|
|
1124
1156
|
return postSession(token, "drafts.list", {}, cookie);
|
package/ts/slackTodos.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Slack's To-dos tab (app.slack.com/client/<team>/to-dos) —
|
|
1
|
+
// Slack's To-dos tab (app.slack.com/client/<team>/to-dos) — parsing helpers.
|
|
2
2
|
//
|
|
3
3
|
// Not to be confused with `slack todo` (todo.ts), which tracks tasks as
|
|
4
4
|
// reactions on messages. To-dos are Slack's own PERSONAL list: it replaced
|
|
@@ -26,7 +26,7 @@ export type TodoItem = {
|
|
|
26
26
|
due: number;
|
|
27
27
|
/** Epoch seconds; 0 = not completed. */
|
|
28
28
|
completed: number;
|
|
29
|
-
/** "to_do" | "saved" (as Slack reports it). */
|
|
29
|
+
/** "to_do" | "saved" | "completed" (as Slack reports it). */
|
|
30
30
|
todoState: string;
|
|
31
31
|
state: string;
|
|
32
32
|
archived: boolean;
|
|
@@ -68,7 +68,8 @@ export function sortTodos(items: TodoItem[]): TodoItem[] {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
export function isDone(t: TodoItem): boolean {
|
|
71
|
-
|
|
71
|
+
// Checking one off sets state AND todo_state to "completed" (seen live).
|
|
72
|
+
return t.completed > 0 || t.state === "completed" || t.todoState === "completed";
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
/** The status column: a checkbox, plus the due date (and OVERDUE when past it). */
|