taskify-nostr 0.1.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/README.md +271 -0
- package/dist/aiClient.js +40 -0
- package/dist/completions.js +637 -0
- package/dist/config.js +39 -0
- package/dist/index.js +2074 -0
- package/dist/nostrRuntime.js +888 -0
- package/dist/onboarding.js +93 -0
- package/dist/render.js +207 -0
- package/dist/shared/agentDispatcher.js +595 -0
- package/dist/shared/agentIdempotency.js +50 -0
- package/dist/shared/agentRuntime.js +7 -0
- package/dist/shared/agentSecurity.js +161 -0
- package/dist/shared/boardUtils.js +441 -0
- package/dist/shared/dateUtils.js +123 -0
- package/dist/shared/nostr.js +70 -0
- package/dist/shared/settingsTypes.js +23 -0
- package/dist/shared/taskTypes.js +12 -0
- package/dist/shared/taskUtils.js +261 -0
- package/dist/taskCache.js +59 -0
- package/package.json +44 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Minimal date utilities for taskify-cli (no browser dependencies)
|
|
2
|
+
export function startOfDay(d) {
|
|
3
|
+
const result = new Date(d);
|
|
4
|
+
result.setHours(0, 0, 0, 0);
|
|
5
|
+
return result;
|
|
6
|
+
}
|
|
7
|
+
export function isoDatePart(iso, tz) {
|
|
8
|
+
if (!iso)
|
|
9
|
+
return "";
|
|
10
|
+
try {
|
|
11
|
+
if (tz) {
|
|
12
|
+
const date = new Date(iso);
|
|
13
|
+
if (Number.isNaN(date.getTime()))
|
|
14
|
+
return iso.slice(0, 10);
|
|
15
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
16
|
+
timeZone: tz,
|
|
17
|
+
year: "numeric",
|
|
18
|
+
month: "2-digit",
|
|
19
|
+
day: "2-digit",
|
|
20
|
+
}).formatToParts(date);
|
|
21
|
+
const year = parts.find((p) => p.type === "year")?.value ?? "";
|
|
22
|
+
const month = parts.find((p) => p.type === "month")?.value ?? "";
|
|
23
|
+
const day = parts.find((p) => p.type === "day")?.value ?? "";
|
|
24
|
+
return `${year}-${month}-${day}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// fall through
|
|
29
|
+
}
|
|
30
|
+
return iso.slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
export function isoTimePart(iso, tz) {
|
|
33
|
+
if (!iso)
|
|
34
|
+
return "";
|
|
35
|
+
try {
|
|
36
|
+
if (tz) {
|
|
37
|
+
const date = new Date(iso);
|
|
38
|
+
if (Number.isNaN(date.getTime()))
|
|
39
|
+
return "";
|
|
40
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
41
|
+
timeZone: tz,
|
|
42
|
+
hour: "2-digit",
|
|
43
|
+
minute: "2-digit",
|
|
44
|
+
hour12: false,
|
|
45
|
+
}).formatToParts(date);
|
|
46
|
+
const hour = parts.find((p) => p.type === "hour")?.value ?? "";
|
|
47
|
+
const minute = parts.find((p) => p.type === "minute")?.value ?? "";
|
|
48
|
+
return `${hour}:${minute}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// fall through
|
|
53
|
+
}
|
|
54
|
+
// UTC fallback
|
|
55
|
+
const d = new Date(iso);
|
|
56
|
+
if (Number.isNaN(d.getTime()))
|
|
57
|
+
return "";
|
|
58
|
+
const h = String(d.getUTCHours()).padStart(2, "0");
|
|
59
|
+
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
|
60
|
+
return `${h}:${m}`;
|
|
61
|
+
}
|
|
62
|
+
export function isoTimePartUtc(iso) {
|
|
63
|
+
const d = new Date(iso);
|
|
64
|
+
if (Number.isNaN(d.getTime()))
|
|
65
|
+
return "";
|
|
66
|
+
const h = String(d.getUTCHours()).padStart(2, "0");
|
|
67
|
+
const m = String(d.getUTCMinutes()).padStart(2, "0");
|
|
68
|
+
return `${h}:${m}`;
|
|
69
|
+
}
|
|
70
|
+
export function parseDateKey(key) {
|
|
71
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
|
|
72
|
+
if (!match)
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
year: Number(match[1]),
|
|
76
|
+
month: Number(match[2]),
|
|
77
|
+
day: Number(match[3]),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function formatDateKeyFromParts(year, month, day) {
|
|
81
|
+
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
82
|
+
}
|
|
83
|
+
export function isoFromDateTime(dateKey, time, tz) {
|
|
84
|
+
if (!time) {
|
|
85
|
+
return new Date(`${dateKey}T00:00:00Z`).toISOString();
|
|
86
|
+
}
|
|
87
|
+
if (tz) {
|
|
88
|
+
try {
|
|
89
|
+
// Build a date string and convert from the given timezone
|
|
90
|
+
const localStr = `${dateKey}T${time}:00`;
|
|
91
|
+
const date = new Date(new Intl.DateTimeFormat("en-CA", {
|
|
92
|
+
timeZone: tz,
|
|
93
|
+
year: "numeric",
|
|
94
|
+
month: "2-digit",
|
|
95
|
+
day: "2-digit",
|
|
96
|
+
hour: "2-digit",
|
|
97
|
+
minute: "2-digit",
|
|
98
|
+
second: "2-digit",
|
|
99
|
+
hour12: false,
|
|
100
|
+
}).format(new Date(localStr)));
|
|
101
|
+
if (!Number.isNaN(date.getTime()))
|
|
102
|
+
return date.toISOString();
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// fall through
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return new Date(`${dateKey}T${time}:00Z`).toISOString();
|
|
109
|
+
}
|
|
110
|
+
export function normalizeTimeZone(tz) {
|
|
111
|
+
if (!tz || typeof tz !== "string")
|
|
112
|
+
return null;
|
|
113
|
+
const trimmed = tz.trim();
|
|
114
|
+
if (!trimmed)
|
|
115
|
+
return null;
|
|
116
|
+
try {
|
|
117
|
+
Intl.DateTimeFormat(undefined, { timeZone: trimmed });
|
|
118
|
+
return trimmed;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
|
|
2
|
+
import { getPublicKey, nip19 } from "nostr-tools";
|
|
3
|
+
function arrayLikeToHex(data) {
|
|
4
|
+
return Array.from(data).map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Normalize Nostr public key input into a compressed 33-byte hex string (66 chars).
|
|
8
|
+
*/
|
|
9
|
+
export function normalizeNostrPubkey(input) {
|
|
10
|
+
let value = input?.trim();
|
|
11
|
+
if (!value)
|
|
12
|
+
return null;
|
|
13
|
+
if (/^nostr:/i.test(value)) {
|
|
14
|
+
value = value.replace(/^nostr:/i, "");
|
|
15
|
+
}
|
|
16
|
+
const lowerValue = value.toLowerCase();
|
|
17
|
+
const maybeHex = lowerValue.startsWith("0x") ? lowerValue.slice(2) : lowerValue;
|
|
18
|
+
if (/^(02|03)[0-9a-f]{64}$/.test(maybeHex)) {
|
|
19
|
+
return maybeHex;
|
|
20
|
+
}
|
|
21
|
+
if (/^[0-9a-f]{64}$/.test(maybeHex)) {
|
|
22
|
+
return `02${maybeHex}`;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const decoded = nip19.decode(lowerValue);
|
|
26
|
+
if (decoded.type !== "npub" || !decoded.data)
|
|
27
|
+
return null;
|
|
28
|
+
const decodedData = decoded.data;
|
|
29
|
+
if (typeof decodedData === "string") {
|
|
30
|
+
if (/^[0-9a-f]{64}$/.test(decodedData))
|
|
31
|
+
return `02${decodedData.toLowerCase()}`;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (decodedData instanceof Uint8Array) {
|
|
35
|
+
return `02${bytesToHex(decodedData).toLowerCase()}`;
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(decodedData)) {
|
|
38
|
+
return `02${arrayLikeToHex(decodedData).toLowerCase()}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// fall through to null
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
export function toNpub(input) {
|
|
47
|
+
const normalized = normalizeNostrPubkey(input);
|
|
48
|
+
if (!normalized)
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
return nip19.npubEncode(normalized.slice(-64));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function isValidNostrPubkeyHex(value) {
|
|
58
|
+
return typeof value === "string" && /^(02|03)[0-9a-fA-F]{64}$/.test(value);
|
|
59
|
+
}
|
|
60
|
+
export function deriveCompressedPubkeyFromSecret(secretHex) {
|
|
61
|
+
if (!/^[0-9a-fA-F]{64}$/.test(secretHex?.trim() || ""))
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const pubkey = getPublicKey(hexToBytes(secretHex.trim()));
|
|
65
|
+
return `02${pubkey.toLowerCase()}`;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// CLI-adapted version of settingsTypes.ts (browser imports removed)
|
|
2
|
+
export const ACCENT_CHOICES = [
|
|
3
|
+
{
|
|
4
|
+
id: "blue",
|
|
5
|
+
label: "iMessage blue",
|
|
6
|
+
fill: "#0a84ff",
|
|
7
|
+
ring: "rgba(64, 156, 255, 0.32)",
|
|
8
|
+
border: "rgba(64, 156, 255, 0.38)",
|
|
9
|
+
borderActive: "rgba(64, 156, 255, 0.88)",
|
|
10
|
+
shadow: "0 12px 26px rgba(10, 132, 255, 0.32)",
|
|
11
|
+
shadowActive: "0 18px 34px rgba(10, 132, 255, 0.42)",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "green",
|
|
15
|
+
label: "Mint green",
|
|
16
|
+
fill: "#34c759",
|
|
17
|
+
ring: "rgba(52, 199, 89, 0.28)",
|
|
18
|
+
border: "rgba(52, 199, 89, 0.36)",
|
|
19
|
+
borderActive: "rgba(52, 199, 89, 0.86)",
|
|
20
|
+
shadow: "0 12px 24px rgba(52, 199, 89, 0.28)",
|
|
21
|
+
shadowActive: "0 18px 32px rgba(52, 199, 89, 0.38)",
|
|
22
|
+
},
|
|
23
|
+
];
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// CLI-adapted version of taskTypes.ts (browser imports removed)
|
|
2
|
+
export const TASK_PRIORITY_MARKS = {
|
|
3
|
+
1: "!",
|
|
4
|
+
2: "!!",
|
|
5
|
+
3: "!!!",
|
|
6
|
+
};
|
|
7
|
+
export function isExternalCalendarEvent(event) {
|
|
8
|
+
return event.external === true;
|
|
9
|
+
}
|
|
10
|
+
export function isListLikeBoard(board) {
|
|
11
|
+
return !!board && (board.kind === "lists" || board.kind === "compound");
|
|
12
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { isoDatePart, isoTimePartUtc, startOfDay } from "./dateUtils.js";
|
|
2
|
+
import { revealsOnDueDate, isFrequentRecurrence } from "./boardUtils.js";
|
|
3
|
+
// ---- Priority normalization ----
|
|
4
|
+
export function normalizeTaskPriority(value) {
|
|
5
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
6
|
+
const rounded = Math.round(value);
|
|
7
|
+
if (rounded === 1 || rounded === 2 || rounded === 3)
|
|
8
|
+
return rounded;
|
|
9
|
+
}
|
|
10
|
+
if (typeof value === "string") {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (trimmed === "!" || trimmed === "!!" || trimmed === "!!!") {
|
|
13
|
+
return trimmed.length;
|
|
14
|
+
}
|
|
15
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
16
|
+
if (parsed === 1 || parsed === 2 || parsed === 3)
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const DEFAULT_BOARD_SORT_DIRECTION = {
|
|
22
|
+
manual: "asc",
|
|
23
|
+
due: "asc",
|
|
24
|
+
priority: "desc",
|
|
25
|
+
created: "desc",
|
|
26
|
+
alpha: "asc",
|
|
27
|
+
};
|
|
28
|
+
const BOARD_SORT_MODE_IDS = new Set(["manual", "due", "priority", "created", "alpha"]);
|
|
29
|
+
export function normalizeBoardSortState(value) {
|
|
30
|
+
const modeRaw = typeof value?.mode === "string" ? value.mode : "";
|
|
31
|
+
if (!BOARD_SORT_MODE_IDS.has(modeRaw))
|
|
32
|
+
return null;
|
|
33
|
+
const mode = modeRaw;
|
|
34
|
+
const directionRaw = typeof value?.direction === "string" ? value.direction : "";
|
|
35
|
+
const direction = directionRaw === "asc" || directionRaw === "desc" ? directionRaw : DEFAULT_BOARD_SORT_DIRECTION[mode];
|
|
36
|
+
return { mode, direction };
|
|
37
|
+
}
|
|
38
|
+
// ---- Bounty list helpers ----
|
|
39
|
+
export function taskHasBountyList(task, key) {
|
|
40
|
+
if (!key)
|
|
41
|
+
return false;
|
|
42
|
+
if (!Array.isArray(task.bountyLists))
|
|
43
|
+
return false;
|
|
44
|
+
return task.bountyLists.includes(key);
|
|
45
|
+
}
|
|
46
|
+
export function withTaskAddedToBountyList(task, key) {
|
|
47
|
+
if (!key)
|
|
48
|
+
return task;
|
|
49
|
+
if (taskHasBountyList(task, key))
|
|
50
|
+
return task;
|
|
51
|
+
const nextLists = Array.isArray(task.bountyLists) ? [...task.bountyLists, key] : [key];
|
|
52
|
+
return { ...task, bountyLists: nextLists };
|
|
53
|
+
}
|
|
54
|
+
export function withTaskRemovedFromBountyList(task, key) {
|
|
55
|
+
if (!key || !Array.isArray(task.bountyLists))
|
|
56
|
+
return task;
|
|
57
|
+
if (!task.bountyLists.includes(key))
|
|
58
|
+
return task;
|
|
59
|
+
const filtered = task.bountyLists.filter((value) => value !== key);
|
|
60
|
+
if (filtered.length === 0) {
|
|
61
|
+
const clone = { ...task };
|
|
62
|
+
delete clone.bountyLists;
|
|
63
|
+
return clone;
|
|
64
|
+
}
|
|
65
|
+
return { ...task, bountyLists: filtered };
|
|
66
|
+
}
|
|
67
|
+
export function isRecoverableBountyTask(task) {
|
|
68
|
+
return !!task.bounty && typeof task.bountyDeletedAt === "string" && task.bountyDeletedAt.trim().length > 0;
|
|
69
|
+
}
|
|
70
|
+
// ---- Nostr pubkey helpers ----
|
|
71
|
+
export function toXOnlyHex(value) {
|
|
72
|
+
if (typeof value !== "string")
|
|
73
|
+
return null;
|
|
74
|
+
const trimmed = value.trim().toLowerCase();
|
|
75
|
+
const hex = trimmed.startsWith("0x") ? trimmed.slice(2) : trimmed;
|
|
76
|
+
if (/^(02|03)[0-9a-f]{64}$/.test(hex)) {
|
|
77
|
+
return hex.slice(-64);
|
|
78
|
+
}
|
|
79
|
+
if (/^[0-9a-f]{64}$/.test(hex)) {
|
|
80
|
+
return hex;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
export function ensureXOnlyHex(value) {
|
|
85
|
+
const normalized = toXOnlyHex(value);
|
|
86
|
+
return normalized ?? undefined;
|
|
87
|
+
}
|
|
88
|
+
export function pubkeysEqual(a, b) {
|
|
89
|
+
const ax = toXOnlyHex(a);
|
|
90
|
+
const bx = toXOnlyHex(b);
|
|
91
|
+
return !!(ax && bx && ax === bx);
|
|
92
|
+
}
|
|
93
|
+
// ---- Bounty normalization ----
|
|
94
|
+
export function normalizeBounty(bounty) {
|
|
95
|
+
if (!bounty)
|
|
96
|
+
return undefined;
|
|
97
|
+
const normalized = { ...bounty };
|
|
98
|
+
const owner = ensureXOnlyHex(normalized.owner);
|
|
99
|
+
if (owner)
|
|
100
|
+
normalized.owner = owner;
|
|
101
|
+
else
|
|
102
|
+
delete normalized.owner;
|
|
103
|
+
const sender = ensureXOnlyHex(normalized.sender);
|
|
104
|
+
if (sender)
|
|
105
|
+
normalized.sender = sender;
|
|
106
|
+
else
|
|
107
|
+
delete normalized.sender;
|
|
108
|
+
const receiver = ensureXOnlyHex(normalized.receiver);
|
|
109
|
+
if (receiver)
|
|
110
|
+
normalized.receiver = receiver;
|
|
111
|
+
else
|
|
112
|
+
delete normalized.receiver;
|
|
113
|
+
const token = typeof normalized.token === "string" ? normalized.token : "";
|
|
114
|
+
const hasToken = token.trim().length > 0;
|
|
115
|
+
const hasCipher = normalized.enc !== undefined && normalized.enc !== null;
|
|
116
|
+
if (normalized.state === "claimed" || normalized.state === "revoked") {
|
|
117
|
+
return normalized;
|
|
118
|
+
}
|
|
119
|
+
if (hasToken && !hasCipher) {
|
|
120
|
+
normalized.state = "unlocked";
|
|
121
|
+
if (!normalized.lock || normalized.lock === "unknown") {
|
|
122
|
+
normalized.lock = "none";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else if (hasCipher && !hasToken) {
|
|
126
|
+
normalized.state = "locked";
|
|
127
|
+
}
|
|
128
|
+
else if (hasToken && hasCipher) {
|
|
129
|
+
normalized.state = "unlocked";
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
normalized.state = "locked";
|
|
133
|
+
}
|
|
134
|
+
return normalized;
|
|
135
|
+
}
|
|
136
|
+
export function normalizeTaskBounty(task) {
|
|
137
|
+
if (!Object.prototype.hasOwnProperty.call(task, "bounty")) {
|
|
138
|
+
return task;
|
|
139
|
+
}
|
|
140
|
+
const clone = { ...task };
|
|
141
|
+
const bounty = clone.bounty;
|
|
142
|
+
if (!bounty) {
|
|
143
|
+
delete clone.bounty;
|
|
144
|
+
return clone;
|
|
145
|
+
}
|
|
146
|
+
const normalized = normalizeBounty(bounty);
|
|
147
|
+
if (!normalized) {
|
|
148
|
+
delete clone.bounty;
|
|
149
|
+
return clone;
|
|
150
|
+
}
|
|
151
|
+
clone.bounty = normalized;
|
|
152
|
+
return clone;
|
|
153
|
+
}
|
|
154
|
+
// ---- Bounty state label ----
|
|
155
|
+
export function bountyStateLabel(bounty) {
|
|
156
|
+
return bounty.state;
|
|
157
|
+
}
|
|
158
|
+
// ---- Streak helpers ----
|
|
159
|
+
export function mergeLongestStreak(task, streak) {
|
|
160
|
+
const previous = typeof task.longestStreak === "number"
|
|
161
|
+
? task.longestStreak
|
|
162
|
+
: typeof task.streak === "number"
|
|
163
|
+
? task.streak
|
|
164
|
+
: undefined;
|
|
165
|
+
if (typeof streak === "number") {
|
|
166
|
+
return previous === undefined ? streak : Math.max(previous, streak);
|
|
167
|
+
}
|
|
168
|
+
return previous;
|
|
169
|
+
}
|
|
170
|
+
// ---- Recurrence normalization ----
|
|
171
|
+
export function normalizeHiddenForRecurring(task) {
|
|
172
|
+
if (!task.hiddenUntilISO || !task.recurrence || !revealsOnDueDate(task.recurrence)) {
|
|
173
|
+
return task;
|
|
174
|
+
}
|
|
175
|
+
const dueMidnight = startOfDay(new Date(task.dueISO));
|
|
176
|
+
const hiddenMidnight = startOfDay(new Date(task.hiddenUntilISO));
|
|
177
|
+
if (Number.isNaN(dueMidnight.getTime()) || Number.isNaN(hiddenMidnight.getTime()))
|
|
178
|
+
return task;
|
|
179
|
+
const today = startOfDay(new Date());
|
|
180
|
+
if (dueMidnight.getTime() > today.getTime() && hiddenMidnight.getTime() < dueMidnight.getTime()) {
|
|
181
|
+
return { ...task, hiddenUntilISO: dueMidnight.toISOString() };
|
|
182
|
+
}
|
|
183
|
+
return task;
|
|
184
|
+
}
|
|
185
|
+
export function recurrenceSeriesKey(task) {
|
|
186
|
+
if (!task.recurrence)
|
|
187
|
+
return null;
|
|
188
|
+
if (task.seriesId)
|
|
189
|
+
return `series:${task.boardId}:${task.seriesId}`;
|
|
190
|
+
const recurrence = JSON.stringify(task.recurrence);
|
|
191
|
+
return `sig:${task.boardId}::${task.title}::${task.note || ""}::${recurrence}`;
|
|
192
|
+
}
|
|
193
|
+
export function recurringInstanceId(seriesId, dueISO, rule, timeZone) {
|
|
194
|
+
const datePart = isoDatePart(dueISO, timeZone);
|
|
195
|
+
const timePart = rule && rule.type === "every" && rule.unit === "hour"
|
|
196
|
+
? isoTimePartUtc(dueISO)
|
|
197
|
+
: "";
|
|
198
|
+
const suffix = timePart ? `${datePart}T${timePart}` : datePart;
|
|
199
|
+
return `recurrence:${seriesId}:${suffix}`;
|
|
200
|
+
}
|
|
201
|
+
export function recurringOccurrenceKey(task) {
|
|
202
|
+
if (!task.recurrence || !isFrequentRecurrence(task.recurrence))
|
|
203
|
+
return null;
|
|
204
|
+
const seriesKey = recurrenceSeriesKey(task);
|
|
205
|
+
if (!seriesKey)
|
|
206
|
+
return null;
|
|
207
|
+
const datePart = isoDatePart(task.dueISO, task.dueTimeZone);
|
|
208
|
+
return `${seriesKey}::${datePart}`;
|
|
209
|
+
}
|
|
210
|
+
export function pickRecurringDuplicate(a, b) {
|
|
211
|
+
const aCompleted = !!a.completed;
|
|
212
|
+
const bCompleted = !!b.completed;
|
|
213
|
+
if (aCompleted !== bCompleted)
|
|
214
|
+
return aCompleted ? a : b;
|
|
215
|
+
const aCompletedAt = a.completedAt ? Date.parse(a.completedAt) : 0;
|
|
216
|
+
const bCompletedAt = b.completedAt ? Date.parse(b.completedAt) : 0;
|
|
217
|
+
if (aCompletedAt !== bCompletedAt)
|
|
218
|
+
return aCompletedAt >= bCompletedAt ? a : b;
|
|
219
|
+
const aIsBase = !!(a.seriesId && a.id === a.seriesId);
|
|
220
|
+
const bIsBase = !!(b.seriesId && b.id === b.seriesId);
|
|
221
|
+
if (aIsBase !== bIsBase)
|
|
222
|
+
return aIsBase ? a : b;
|
|
223
|
+
const aOrder = typeof a.order === "number" ? a.order : Number.POSITIVE_INFINITY;
|
|
224
|
+
const bOrder = typeof b.order === "number" ? b.order : Number.POSITIVE_INFINITY;
|
|
225
|
+
if (aOrder !== bOrder)
|
|
226
|
+
return aOrder < bOrder ? a : b;
|
|
227
|
+
return a.id.localeCompare(b.id) <= 0 ? a : b;
|
|
228
|
+
}
|
|
229
|
+
export function dedupeRecurringInstances(tasks) {
|
|
230
|
+
const out = [];
|
|
231
|
+
const indexByKey = new Map();
|
|
232
|
+
let changed = false;
|
|
233
|
+
for (const task of tasks) {
|
|
234
|
+
const key = recurringOccurrenceKey(task);
|
|
235
|
+
if (!key) {
|
|
236
|
+
out.push(task);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const existingIndex = indexByKey.get(key);
|
|
240
|
+
if (existingIndex === undefined) {
|
|
241
|
+
indexByKey.set(key, out.length);
|
|
242
|
+
out.push(task);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const existing = out[existingIndex];
|
|
246
|
+
const winner = pickRecurringDuplicate(existing, task);
|
|
247
|
+
if (winner !== existing) {
|
|
248
|
+
out[existingIndex] = winner;
|
|
249
|
+
}
|
|
250
|
+
changed = true;
|
|
251
|
+
}
|
|
252
|
+
return changed ? out : tasks;
|
|
253
|
+
}
|
|
254
|
+
// ---- Task created-at normalization ----
|
|
255
|
+
export function normalizeTaskCreatedAt(value) {
|
|
256
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
257
|
+
return value;
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
// ---- Bounty list key ----
|
|
261
|
+
export const PINNED_BOUNTY_LIST_KEY = "taskify::pinned";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Task cache for fast completions and repeated list calls.
|
|
2
|
+
// Cache file: ~/.config/taskify/cache.json
|
|
3
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
export const CACHE_DIR = join(homedir(), ".config", "taskify");
|
|
7
|
+
export const CACHE_PATH = join(CACHE_DIR, "cache.json");
|
|
8
|
+
export const CACHE_TTL_MS = 300_000; // 5 minutes
|
|
9
|
+
export function readCache() {
|
|
10
|
+
try {
|
|
11
|
+
const raw = readFileSync(CACHE_PATH, "utf-8");
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { boards: {} };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function writeCache(cache) {
|
|
19
|
+
try {
|
|
20
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
21
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2), "utf-8");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Non-fatal: cache writes are best-effort
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function clearCache() {
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(CACHE_PATH);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Non-fatal if already missing
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function isCacheFresh(boardCache) {
|
|
36
|
+
return Date.now() - boardCache.fetchedAt < CACHE_TTL_MS;
|
|
37
|
+
}
|
|
38
|
+
/** Read open task IDs from cache synchronously for shell completions. */
|
|
39
|
+
export function readCachedOpenTaskIds() {
|
|
40
|
+
try {
|
|
41
|
+
const raw = readFileSync(CACHE_PATH, "utf-8");
|
|
42
|
+
const cache = JSON.parse(raw);
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const boardCache of Object.values(cache.boards ?? {})) {
|
|
46
|
+
if (now - boardCache.fetchedAt > CACHE_TTL_MS)
|
|
47
|
+
continue;
|
|
48
|
+
for (const task of boardCache.tasks ?? []) {
|
|
49
|
+
if (task.status === "open") {
|
|
50
|
+
results.push({ id: task.id.slice(0, 8), title: task.title });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "taskify-nostr",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Nostr-powered task management CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"taskify": "./dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc && node -e \"const fs=require('fs');const f='dist/index.js';let c=fs.readFileSync(f,'utf8');if(c.startsWith('#!'))c=c.slice(c.indexOf('\\n')+1);fs.writeFileSync(f,'#!/usr/bin/env node\\n'+c)\"",
|
|
11
|
+
"prepare": "npm run build",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "node --experimental-strip-types src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.0.0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"nostr",
|
|
24
|
+
"tasks",
|
|
25
|
+
"cli",
|
|
26
|
+
"productivity"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/Solife-me/Taskify_Release"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@noble/hashes": "^1.4.0",
|
|
35
|
+
"@nostr-dev-kit/ndk": "^2.18.1",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"commander": "^12.0.0",
|
|
38
|
+
"nostr-tools": "^2.16.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^25.4.0",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|