kankaku 0.6.0 → 0.7.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 +103 -14
- package/dist/adapters/cached-catalog.d.ts +8 -2
- package/dist/adapters/cached-catalog.js +13 -3
- package/dist/adapters/pocketbase-catalog.d.ts +7 -5
- package/dist/adapters/pocketbase-catalog.js +22 -5
- package/dist/adapters/pocketbase-sink.d.ts +2 -1
- package/dist/adapters/pocketbase-sink.js +2 -1
- package/dist/domain/hub-entry.d.ts +12 -3
- package/dist/domain/hub-entry.js +14 -4
- package/dist/domain/task-view.d.ts +4 -0
- package/dist/domain/task-view.js +2 -0
- package/dist/domain/work-record.d.ts +18 -0
- package/dist/domain/work-record.js +18 -0
- package/dist/domain/work-target.d.ts +30 -1
- package/dist/domain/work-target.js +33 -11
- package/dist/ports/catalog.d.ts +4 -2
- package/package.json +1 -1
- package/src/adapters/cached-catalog.ts +22 -6
- package/src/adapters/hub-actions.ts +83 -0
- package/src/adapters/kankaku-command.ts +236 -244
- package/src/adapters/panel/kankaku-panel.ts +274 -0
- package/src/adapters/panel/panel-items.ts +77 -0
- package/src/adapters/panel/panel-lines.ts +13 -0
- package/src/adapters/panel/panel-theme.ts +32 -0
- package/src/adapters/panel/screens/about.ts +69 -0
- package/src/adapters/panel/screens/doctor.ts +85 -0
- package/src/adapters/panel/screens/export.ts +119 -0
- package/src/adapters/panel/screens/report.ts +141 -0
- package/src/adapters/panel/screens/sync.ts +126 -0
- package/src/adapters/panel/screens/target.ts +367 -0
- package/src/adapters/pi-tracker.ts +110 -6
- package/src/adapters/pocketbase-catalog.ts +36 -7
- package/src/adapters/pocketbase-sink.ts +15 -3
- package/src/adapters/report-views.ts +98 -0
- package/src/adapters/report.ts +66 -16
- package/src/adapters/session-target.ts +111 -4
- package/src/adapters/target-picker.ts +46 -1
- package/src/domain/hub-entry.ts +20 -5
- package/src/domain/panel-model.ts +267 -0
- package/src/domain/task-view.ts +6 -0
- package/src/domain/work-record.ts +23 -0
- package/src/domain/work-target.ts +60 -11
- package/src/extension.ts +19 -8
- package/src/ports/catalog.ts +4 -2
|
@@ -26,7 +26,18 @@ function findUsableProject(projects, projectId, clientId) {
|
|
|
26
26
|
return undefined;
|
|
27
27
|
return project;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* A hub task usable as a link target: exists and belongs to `projectId`.
|
|
31
|
+
* Any {@link HubTask.status} is accepted — a task marked `done` mid-session
|
|
32
|
+
* keeps linking — only the project match matters here.
|
|
33
|
+
*/
|
|
34
|
+
function findUsableTask(tasks, taskId, projectId) {
|
|
35
|
+
const task = tasks.find((candidate) => candidate.id === taskId);
|
|
36
|
+
if (!task || task.projectId !== projectId)
|
|
37
|
+
return undefined;
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
function buildTarget(client, project, task) {
|
|
30
41
|
return {
|
|
31
42
|
clientId: client.id,
|
|
32
43
|
clientCode: client.code,
|
|
@@ -38,6 +49,7 @@ function buildTarget(client, project) {
|
|
|
38
49
|
projectName: project.name,
|
|
39
50
|
}
|
|
40
51
|
: {}),
|
|
52
|
+
...(task !== undefined ? { hubTaskId: task.id, hubTaskTitle: task.title } : {}),
|
|
41
53
|
};
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
@@ -45,14 +57,17 @@ function buildTarget(client, project) {
|
|
|
45
57
|
* file) into a target, or `undefined` when the client id does not resolve
|
|
46
58
|
* to a usable client. A projectId that does not resolve to a usable
|
|
47
59
|
* project of that client is dropped — the client-only target is still
|
|
48
|
-
* returned — rather than failing the whole candidate.
|
|
60
|
+
* returned — rather than failing the whole candidate. A `hubTaskId` is
|
|
61
|
+
* kept only when it resolves to a task of the resolved project; a target
|
|
62
|
+
* with no project never carries one.
|
|
49
63
|
*/
|
|
50
|
-
function resolveCandidate(candidate, clients, projects) {
|
|
64
|
+
function resolveCandidate(candidate, clients, projects, tasks) {
|
|
51
65
|
const client = findUsableClient(clients, candidate.clientId);
|
|
52
66
|
if (!client)
|
|
53
67
|
return undefined;
|
|
54
68
|
const project = candidate.projectId !== undefined ? findUsableProject(projects, candidate.projectId, client.id) : undefined;
|
|
55
|
-
|
|
69
|
+
const task = project !== undefined && candidate.hubTaskId !== undefined ? findUsableTask(tasks, candidate.hubTaskId, project.id) : undefined;
|
|
70
|
+
return buildTarget(client, project, task);
|
|
56
71
|
}
|
|
57
72
|
/** The longest-matching active project whose `repoPaths` contains `cwd`, exactly or as an ancestor directory. */
|
|
58
73
|
function matchByRepoPath(cwd, projects) {
|
|
@@ -82,11 +97,12 @@ function isCwdWithin(cwd, repoPath) {
|
|
|
82
97
|
/** Which source (if any) {@link resolveWorkTarget} would use, computed independently so callers can display it. */
|
|
83
98
|
export function resolveWorkTargetSource(input) {
|
|
84
99
|
const { session } = input;
|
|
100
|
+
const tasks = input.tasks ?? [];
|
|
85
101
|
if (session === "skipped")
|
|
86
102
|
return undefined;
|
|
87
|
-
if (session !== undefined && resolveCandidate(session, input.clients, input.projects) !== undefined)
|
|
103
|
+
if (session !== undefined && resolveCandidate(session, input.clients, input.projects, tasks) !== undefined)
|
|
88
104
|
return "session";
|
|
89
|
-
if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects) !== undefined)
|
|
105
|
+
if (input.project !== undefined && resolveCandidate(input.project, input.clients, input.projects, tasks) !== undefined)
|
|
90
106
|
return "project";
|
|
91
107
|
const matched = matchByRepoPath(input.cwd, input.projects);
|
|
92
108
|
if (matched && findUsableClient(input.clients, matched.clientId))
|
|
@@ -101,15 +117,16 @@ export function resolveWorkTargetSource(input) {
|
|
|
101
117
|
*/
|
|
102
118
|
export function resolveWorkTarget(input) {
|
|
103
119
|
const { session } = input;
|
|
120
|
+
const tasks = input.tasks ?? [];
|
|
104
121
|
if (session === "skipped")
|
|
105
122
|
return undefined;
|
|
106
123
|
if (session !== undefined) {
|
|
107
|
-
const resolved = resolveCandidate(session, input.clients, input.projects);
|
|
124
|
+
const resolved = resolveCandidate(session, input.clients, input.projects, tasks);
|
|
108
125
|
if (resolved)
|
|
109
126
|
return resolved;
|
|
110
127
|
}
|
|
111
128
|
if (input.project !== undefined) {
|
|
112
|
-
const resolved = resolveCandidate(input.project, input.clients, input.projects);
|
|
129
|
+
const resolved = resolveCandidate(input.project, input.clients, input.projects, tasks);
|
|
113
130
|
if (resolved)
|
|
114
131
|
return resolved;
|
|
115
132
|
}
|
|
@@ -117,11 +134,16 @@ export function resolveWorkTarget(input) {
|
|
|
117
134
|
if (matched) {
|
|
118
135
|
const client = findUsableClient(input.clients, matched.clientId);
|
|
119
136
|
if (client)
|
|
120
|
-
return buildTarget(client, matched);
|
|
137
|
+
return buildTarget(client, matched, undefined);
|
|
121
138
|
}
|
|
122
139
|
return undefined;
|
|
123
140
|
}
|
|
124
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* `<clientName>`, `<clientName> · <projectName>`, or with a linked hub task
|
|
143
|
+
* appended as `... › <hubTaskTitle>` — the display label used by the
|
|
144
|
+
* status bar and the "remember" prompt.
|
|
145
|
+
*/
|
|
125
146
|
export function formatWorkTargetLabel(target) {
|
|
126
|
-
|
|
147
|
+
const base = target.projectName ? `${target.clientName} · ${target.projectName}` : target.clientName;
|
|
148
|
+
return target.hubTaskTitle ? `${base} › ${target.hubTaskTitle}` : base;
|
|
127
149
|
}
|
package/dist/ports/catalog.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
2
|
-
/** A cached read of the hub's clients/projects, plus when and against which hub URL it was fetched. */
|
|
1
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
2
|
+
/** A cached read of the hub's clients/projects/tasks, plus when and against which hub URL it was fetched. */
|
|
3
3
|
export interface CatalogSnapshot {
|
|
4
4
|
fetchedAt: number;
|
|
5
5
|
url: string;
|
|
6
6
|
clients: Client[];
|
|
7
7
|
projects: Project[];
|
|
8
|
+
/** Optional because a cache written by an older build (before hub task linking) never had this field; a missing value reads as "no tasks known yet." */
|
|
9
|
+
tasks?: HubTask[];
|
|
8
10
|
}
|
|
9
11
|
/**
|
|
10
12
|
* Read-only access to the hub catalog (clients/projects). `read()` is a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import type { Client, Project } from "../domain/work-target.ts";
|
|
3
|
+
import type { Client, HubTask, Project } from "../domain/work-target.ts";
|
|
4
4
|
import type { Clock } from "../ports/clock.ts";
|
|
5
5
|
import type { Catalog, CatalogSnapshot } from "../ports/catalog.ts";
|
|
6
6
|
import { OWNER_DIR_MODE, OWNER_FILE_MODE } from "./file-modes.ts";
|
|
@@ -16,6 +16,10 @@ function isProjectArray(value: unknown): value is Project[] {
|
|
|
16
16
|
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as Project).id === "string");
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function isHubTaskArray(value: unknown): value is HubTask[] {
|
|
20
|
+
return Array.isArray(value) && value.every((item) => item && typeof item === "object" && typeof (item as HubTask).id === "string");
|
|
21
|
+
}
|
|
22
|
+
|
|
19
23
|
function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
|
|
20
24
|
if (!value || typeof value !== "object") return false;
|
|
21
25
|
const record = value as Record<string, unknown>;
|
|
@@ -23,7 +27,8 @@ function isCatalogSnapshot(value: unknown): value is CatalogSnapshot {
|
|
|
23
27
|
typeof record["fetchedAt"] === "number" &&
|
|
24
28
|
typeof record["url"] === "string" &&
|
|
25
29
|
isClientArray(record["clients"]) &&
|
|
26
|
-
isProjectArray(record["projects"])
|
|
30
|
+
isProjectArray(record["projects"]) &&
|
|
31
|
+
(record["tasks"] === undefined || isHubTaskArray(record["tasks"]))
|
|
27
32
|
);
|
|
28
33
|
}
|
|
29
34
|
|
|
@@ -35,8 +40,13 @@ export interface CachedCatalogDeps {
|
|
|
35
40
|
clock: Clock;
|
|
36
41
|
/** Cache TTL in ms. Defaults to 6 hours. */
|
|
37
42
|
ttlMs?: number;
|
|
38
|
-
/**
|
|
39
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Fetch a fresh `{ clients, projects, tasks }` triple, e.g.
|
|
45
|
+
* `createPocketBaseCatalogFetcher(...)`. `tasks` is optional here (unlike
|
|
46
|
+
* on the real PocketBase fetcher) so a test double that only cares about
|
|
47
|
+
* clients/projects keeps compiling unchanged.
|
|
48
|
+
*/
|
|
49
|
+
fetchCatalog: (signal?: AbortSignal) => Promise<{ clients: Client[]; projects: Project[]; tasks?: HubTask[] }>;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
/**
|
|
@@ -73,8 +83,14 @@ export class CachedCatalog implements Catalog {
|
|
|
73
83
|
|
|
74
84
|
async refresh(signal?: AbortSignal): Promise<CatalogSnapshot | undefined> {
|
|
75
85
|
try {
|
|
76
|
-
const { clients, projects } = await this.deps.fetchCatalog(signal);
|
|
77
|
-
const snapshot: CatalogSnapshot = {
|
|
86
|
+
const { clients, projects, tasks } = await this.deps.fetchCatalog(signal);
|
|
87
|
+
const snapshot: CatalogSnapshot = {
|
|
88
|
+
fetchedAt: this.deps.clock.now(),
|
|
89
|
+
url: this.deps.url,
|
|
90
|
+
clients,
|
|
91
|
+
projects,
|
|
92
|
+
...(tasks !== undefined ? { tasks } : {}),
|
|
93
|
+
};
|
|
78
94
|
this.writeDisk(snapshot);
|
|
79
95
|
this.memo = snapshot;
|
|
80
96
|
this.memoized = true;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared line-building for the hub-facing `/kankaku` subcommands (`sync`,
|
|
3
|
+
* `sync all`, `sync status`, `backfill`, `catalog refresh`) and the panel's
|
|
4
|
+
* `sync`/`export` screens (`adapters/panel/screens/sync.ts`), extracted from
|
|
5
|
+
* `kankaku-command.ts` so the subcommands and the panel can never drift —
|
|
6
|
+
* see odd/tasks/kankaku-panel.md P4.
|
|
7
|
+
*/
|
|
8
|
+
import type { CatalogSnapshot } from "../ports/catalog.ts";
|
|
9
|
+
import type { SyncCommandDeps } from "./kankaku-command.ts";
|
|
10
|
+
import type { SyncSummary } from "./sync-runner.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `/kankaku sync status`'s report lines: the watermark, the pending count,
|
|
14
|
+
* how many tasks fell outside this run's revisit window (R3), and the last
|
|
15
|
+
* error, when any. Exact body of `handleSyncCommand`'s `status` branch.
|
|
16
|
+
*/
|
|
17
|
+
export function buildSyncStatusLines(status: ReturnType<SyncCommandDeps["status"]>): string[] {
|
|
18
|
+
const { state, pending, staleOutsideWindow } = status;
|
|
19
|
+
const lines = [state?.syncedThrough ? `synced through ${state.syncedThrough}` : "never synced", `pending: ${pending}`];
|
|
20
|
+
if (staleOutsideWindow > 0) {
|
|
21
|
+
lines.push(`${staleOutsideWindow} task(s) never synced fall outside the sync window — run '/kankaku sync all' to upload them`);
|
|
22
|
+
}
|
|
23
|
+
if (state?.lastError) lines.push(`last error: ${state.lastError.message} (at ${state.lastError.at})`);
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render a {@link SyncSummary} as report lines: counts, any stop reason, the
|
|
29
|
+
* new watermark, and the unassigned/failed breakdowns. Used by `/kankaku
|
|
30
|
+
* sync`/`sync all` and the panel's sync screen.
|
|
31
|
+
*/
|
|
32
|
+
export function formatSyncSummaryLines(summary: SyncSummary): string[] {
|
|
33
|
+
const lines = [`uploaded ${summary.uploaded}, updated ${summary.updated}, skipped ${summary.skipped}, failed ${summary.failed.length}`];
|
|
34
|
+
|
|
35
|
+
if (summary.locked) lines.push("another sync is already in progress; nothing was attempted");
|
|
36
|
+
if (summary.error) lines.push(`stopped early: ${summary.error}`);
|
|
37
|
+
if (summary.syncedThrough) lines.push(`synced through ${summary.syncedThrough}`);
|
|
38
|
+
|
|
39
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
40
|
+
if (unassignedEntries.length > 0) {
|
|
41
|
+
lines.push("unassigned (Sin determinar):");
|
|
42
|
+
for (const [label, count] of unassignedEntries) lines.push(` ${label}: ${count}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (summary.failed.length > 0) {
|
|
46
|
+
lines.push("failed:");
|
|
47
|
+
for (const entry of summary.failed) lines.push(` ${entry.id}: ${entry.reason}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return lines;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `/kankaku backfill`'s report lines: tasks routed to Sin determinar this
|
|
55
|
+
* run, grouped by their old free-text label, or "no unassigned tasks" when
|
|
56
|
+
* none were. Exact body of `handleBackfillCommand`.
|
|
57
|
+
*/
|
|
58
|
+
export function formatBackfillLines(summary: SyncSummary): string[] {
|
|
59
|
+
const unassignedEntries = Object.entries(summary.unassigned).sort(([a], [b]) => a.localeCompare(b));
|
|
60
|
+
|
|
61
|
+
const lines =
|
|
62
|
+
unassignedEntries.length > 0
|
|
63
|
+
? [
|
|
64
|
+
...unassignedEntries.map(([label, count]) => `${label}: ${count} task(s) -> Sin determinar`),
|
|
65
|
+
"reassign these in the hub web app's unassigned queue",
|
|
66
|
+
]
|
|
67
|
+
: ["no unassigned tasks"];
|
|
68
|
+
|
|
69
|
+
if (summary.error) lines.push(`stopped early: ${summary.error}`);
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `/kankaku catalog refresh`'s report lines: client/project counts, or an
|
|
75
|
+
* unreachable notice when the refresh failed (`snapshot` is `undefined`).
|
|
76
|
+
* The subcommand still notifies that failure as an error rather than a
|
|
77
|
+
* report (see `handleCatalogCommand`); the panel's sync screen shows
|
|
78
|
+
* whatever this returns either way.
|
|
79
|
+
*/
|
|
80
|
+
export function formatCatalogRefreshLines(snapshot: CatalogSnapshot | undefined): string[] {
|
|
81
|
+
if (!snapshot) return ["hub unreachable; catalog not refreshed"];
|
|
82
|
+
return [`refreshed: ${snapshot.clients.length} client(s), ${snapshot.projects.length} project(s)`];
|
|
83
|
+
}
|