skyboard-cli 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 +148 -0
- package/bin/sb.js +2 -0
- package/package.json +32 -0
- package/src/__tests__/auth-state.test.ts +111 -0
- package/src/__tests__/card-ref.test.ts +42 -0
- package/src/__tests__/column-match.test.ts +57 -0
- package/src/__tests__/commands.test.ts +326 -0
- package/src/__tests__/helpers.ts +154 -0
- package/src/__tests__/materialize.test.ts +257 -0
- package/src/__tests__/pds.test.ts +213 -0
- package/src/commands/add.ts +47 -0
- package/src/commands/boards.ts +64 -0
- package/src/commands/cards.ts +65 -0
- package/src/commands/cols.ts +51 -0
- package/src/commands/comment.ts +64 -0
- package/src/commands/edit.ts +89 -0
- package/src/commands/login.ts +19 -0
- package/src/commands/logout.ts +14 -0
- package/src/commands/mv.ts +84 -0
- package/src/commands/new.ts +80 -0
- package/src/commands/rm.ts +79 -0
- package/src/commands/show.ts +100 -0
- package/src/commands/status.ts +78 -0
- package/src/commands/use.ts +72 -0
- package/src/commands/whoami.ts +22 -0
- package/src/index.ts +143 -0
- package/src/lib/auth.ts +244 -0
- package/src/lib/card-ref.ts +32 -0
- package/src/lib/column-match.ts +45 -0
- package/src/lib/config.ts +182 -0
- package/src/lib/display.ts +112 -0
- package/src/lib/materialize.ts +138 -0
- package/src/lib/pds.ts +440 -0
- package/src/lib/permissions.ts +28 -0
- package/src/lib/schemas.ts +97 -0
- package/src/lib/tid.ts +22 -0
- package/src/lib/types.ts +144 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import type { MaterializedTask, Board, Column, Label } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function formatDate(iso: string): string {
|
|
5
|
+
const d = new Date(iso);
|
|
6
|
+
const now = new Date();
|
|
7
|
+
const diffMs = now.getTime() - d.getTime();
|
|
8
|
+
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
9
|
+
|
|
10
|
+
if (diffDays === 0) {
|
|
11
|
+
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
12
|
+
} else if (diffDays < 7) {
|
|
13
|
+
return `${diffDays}d ago`;
|
|
14
|
+
} else {
|
|
15
|
+
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function shortRkey(rkey: string): string {
|
|
20
|
+
return rkey.slice(0, 7);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatTask(task: MaterializedTask, board: Board): string {
|
|
24
|
+
const col = board.columns.find((c) => c.id === task.effectiveColumnId);
|
|
25
|
+
const colName = col?.name ?? "?";
|
|
26
|
+
const labels = task.effectiveLabelIds
|
|
27
|
+
.map((id) => board.labels?.find((l) => l.id === id))
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.map((l) => chalk.hex(l!.color)(`[${l!.name}]`))
|
|
30
|
+
.join(" ");
|
|
31
|
+
|
|
32
|
+
const ref = chalk.dim(shortRkey(task.rkey));
|
|
33
|
+
const title = task.effectiveTitle;
|
|
34
|
+
const date = chalk.dim(formatDate(task.lastModifiedAt));
|
|
35
|
+
|
|
36
|
+
let line = ` ${ref} ${title}`;
|
|
37
|
+
if (labels) line += ` ${labels}`;
|
|
38
|
+
line += ` ${date}`;
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatBoardHeader(board: Board): string {
|
|
43
|
+
let header = chalk.bold(board.name);
|
|
44
|
+
if (board.description) {
|
|
45
|
+
header += chalk.dim(` ${board.description}`);
|
|
46
|
+
}
|
|
47
|
+
return header;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatColumnHeader(col: Column, taskCount: number): string {
|
|
51
|
+
return `\n${chalk.bold.underline(col.name)} ${chalk.dim(`(${taskCount})`)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function printBoardCards(
|
|
55
|
+
board: Board,
|
|
56
|
+
tasks: MaterializedTask[],
|
|
57
|
+
opts: { column?: string; label?: string; search?: string },
|
|
58
|
+
): void {
|
|
59
|
+
// Sort columns by order
|
|
60
|
+
const sortedColumns = [...board.columns].sort((a, b) => a.order - b.order);
|
|
61
|
+
|
|
62
|
+
for (const col of sortedColumns) {
|
|
63
|
+
// Filter tasks for this column
|
|
64
|
+
let colTasks = tasks.filter((t) => t.effectiveColumnId === col.id);
|
|
65
|
+
|
|
66
|
+
// Apply filters
|
|
67
|
+
if (opts.column && !matchesColumn(col, opts.column, sortedColumns)) continue;
|
|
68
|
+
if (opts.label) {
|
|
69
|
+
const label = board.labels?.find(
|
|
70
|
+
(l) => l.name.toLowerCase().includes(opts.label!.toLowerCase()),
|
|
71
|
+
);
|
|
72
|
+
if (label) {
|
|
73
|
+
colTasks = colTasks.filter((t) => t.effectiveLabelIds.includes(label.id));
|
|
74
|
+
} else {
|
|
75
|
+
colTasks = [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (opts.search) {
|
|
79
|
+
const s = opts.search.toLowerCase();
|
|
80
|
+
colTasks = colTasks.filter(
|
|
81
|
+
(t) =>
|
|
82
|
+
t.effectiveTitle.toLowerCase().includes(s) ||
|
|
83
|
+
(t.effectiveDescription?.toLowerCase().includes(s) ?? false),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sort by position
|
|
88
|
+
colTasks.sort((a, b) => a.effectivePosition.localeCompare(b.effectivePosition));
|
|
89
|
+
|
|
90
|
+
console.log(formatColumnHeader(col, colTasks.length));
|
|
91
|
+
if (colTasks.length === 0) {
|
|
92
|
+
console.log(chalk.dim(" (empty)"));
|
|
93
|
+
} else {
|
|
94
|
+
for (const task of colTasks) {
|
|
95
|
+
console.log(formatTask(task, board));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function matchesColumn(col: Column, query: string, allColumns: Column[]): boolean {
|
|
102
|
+
// Check numeric index (1-based)
|
|
103
|
+
const idx = parseInt(query, 10);
|
|
104
|
+
if (!isNaN(idx) && idx >= 1 && idx <= allColumns.length) {
|
|
105
|
+
return allColumns[idx - 1].id === col.id;
|
|
106
|
+
}
|
|
107
|
+
// Check exact match (case-insensitive)
|
|
108
|
+
if (col.name.toLowerCase() === query.toLowerCase()) return true;
|
|
109
|
+
// Check prefix match
|
|
110
|
+
if (col.name.toLowerCase().startsWith(query.toLowerCase())) return true;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Synced from ../src/lib/materialize.ts
|
|
2
|
+
import { buildAtUri, TASK_COLLECTION } from "./tid.js";
|
|
3
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
4
|
+
import type { Task, Op, OpFields, MaterializedTask } from "./types.js";
|
|
5
|
+
import { isTrusted } from "./permissions.js";
|
|
6
|
+
|
|
7
|
+
const MUTABLE_FIELDS: (keyof OpFields)[] = [
|
|
8
|
+
"title",
|
|
9
|
+
"description",
|
|
10
|
+
"columnId",
|
|
11
|
+
"position",
|
|
12
|
+
"labelIds",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function orderToPosition(order: number | undefined): string {
|
|
16
|
+
if (order === undefined || order === null)
|
|
17
|
+
return generateKeyBetween(null, null);
|
|
18
|
+
const clamped = Math.min(Math.max(0, order), 10_000);
|
|
19
|
+
let pos: string | null = null;
|
|
20
|
+
for (let i = 0; i <= clamped; i++) {
|
|
21
|
+
pos = generateKeyBetween(pos, null);
|
|
22
|
+
}
|
|
23
|
+
return pos!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FieldState {
|
|
27
|
+
value: unknown;
|
|
28
|
+
timestamp: string;
|
|
29
|
+
author: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function materializeTasks(
|
|
33
|
+
tasks: Task[],
|
|
34
|
+
ops: Op[],
|
|
35
|
+
ownerTrustedDids: Set<string>,
|
|
36
|
+
currentUserDid: string,
|
|
37
|
+
boardOwnerDid: string,
|
|
38
|
+
): MaterializedTask[] {
|
|
39
|
+
const seenTasks = new Set<string>();
|
|
40
|
+
const uniqueTasks = tasks.filter((task) => {
|
|
41
|
+
const key = `${task.did}:${task.rkey}`;
|
|
42
|
+
if (seenTasks.has(key)) return false;
|
|
43
|
+
seenTasks.add(key);
|
|
44
|
+
return true;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const opsByTask = new Map<string, Op[]>();
|
|
48
|
+
for (const op of ops) {
|
|
49
|
+
const list = opsByTask.get(op.targetTaskUri) || [];
|
|
50
|
+
list.push(op);
|
|
51
|
+
opsByTask.set(op.targetTaskUri, list);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return uniqueTasks.map((task) => {
|
|
55
|
+
const taskUri = buildAtUri(task.did, TASK_COLLECTION, task.rkey);
|
|
56
|
+
const taskOps = opsByTask.get(taskUri) || [];
|
|
57
|
+
|
|
58
|
+
const appliedOps: Op[] = [];
|
|
59
|
+
|
|
60
|
+
for (const op of taskOps) {
|
|
61
|
+
if (
|
|
62
|
+
op.did === boardOwnerDid ||
|
|
63
|
+
op.did === task.did ||
|
|
64
|
+
op.did === currentUserDid ||
|
|
65
|
+
isTrusted(op.did, boardOwnerDid, ownerTrustedDids)
|
|
66
|
+
) {
|
|
67
|
+
appliedOps.push(op);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fieldStates: Record<string, FieldState> = {};
|
|
72
|
+
for (const field of MUTABLE_FIELDS) {
|
|
73
|
+
let value: unknown = task[field as keyof Task];
|
|
74
|
+
if (field === "position" && !value) {
|
|
75
|
+
value = orderToPosition(task.order);
|
|
76
|
+
}
|
|
77
|
+
fieldStates[field] = {
|
|
78
|
+
value,
|
|
79
|
+
timestamp: task.createdAt,
|
|
80
|
+
author: task.did,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sortedOps = [...appliedOps].sort((a, b) =>
|
|
85
|
+
a.createdAt.localeCompare(b.createdAt),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
for (const op of sortedOps) {
|
|
89
|
+
for (const field of MUTABLE_FIELDS) {
|
|
90
|
+
const opValue = op.fields[field];
|
|
91
|
+
if (opValue !== undefined) {
|
|
92
|
+
const current = fieldStates[field];
|
|
93
|
+
if (op.createdAt > current.timestamp) {
|
|
94
|
+
fieldStates[field] = {
|
|
95
|
+
value: opValue,
|
|
96
|
+
timestamp: op.createdAt,
|
|
97
|
+
author: op.did,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let lastModifiedBy = task.did;
|
|
105
|
+
let lastModifiedAt = task.updatedAt || task.createdAt;
|
|
106
|
+
for (const field of MUTABLE_FIELDS) {
|
|
107
|
+
if (fieldStates[field].timestamp > lastModifiedAt) {
|
|
108
|
+
lastModifiedAt = fieldStates[field].timestamp;
|
|
109
|
+
lastModifiedBy = fieldStates[field].author;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
rkey: task.rkey,
|
|
115
|
+
did: task.did,
|
|
116
|
+
title: task.title,
|
|
117
|
+
description: task.description,
|
|
118
|
+
columnId: task.columnId,
|
|
119
|
+
boardUri: task.boardUri,
|
|
120
|
+
position: task.position,
|
|
121
|
+
labelIds: task.labelIds,
|
|
122
|
+
order: task.order,
|
|
123
|
+
createdAt: task.createdAt,
|
|
124
|
+
updatedAt: task.updatedAt,
|
|
125
|
+
sourceTask: task,
|
|
126
|
+
appliedOps,
|
|
127
|
+
pendingOps: [],
|
|
128
|
+
effectiveTitle: fieldStates.title.value as string,
|
|
129
|
+
effectiveDescription: fieldStates.description.value as string | undefined,
|
|
130
|
+
effectiveColumnId: fieldStates.columnId.value as string,
|
|
131
|
+
effectivePosition: fieldStates.position.value as string,
|
|
132
|
+
effectiveLabelIds: (fieldStates.labelIds.value as string[] | undefined) ?? [],
|
|
133
|
+
ownerDid: task.did,
|
|
134
|
+
lastModifiedBy,
|
|
135
|
+
lastModifiedAt,
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
}
|
package/src/lib/pds.ts
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// PDS fetch helpers — adapted from src/lib/remote-sync.ts for Node.js
|
|
2
|
+
// Returns data in memory instead of writing to IndexedDB
|
|
3
|
+
|
|
4
|
+
import type { Agent } from "@atproto/api";
|
|
5
|
+
import {
|
|
6
|
+
BOARD_COLLECTION,
|
|
7
|
+
TASK_COLLECTION,
|
|
8
|
+
OP_COLLECTION,
|
|
9
|
+
TRUST_COLLECTION,
|
|
10
|
+
COMMENT_COLLECTION,
|
|
11
|
+
buildAtUri,
|
|
12
|
+
} from "./tid.js";
|
|
13
|
+
import type { Board, Task, Op, Trust, Comment } from "./types.js";
|
|
14
|
+
import {
|
|
15
|
+
safeParse,
|
|
16
|
+
BoardRecordSchema,
|
|
17
|
+
TaskRecordSchema,
|
|
18
|
+
OpRecordSchema,
|
|
19
|
+
TrustRecordSchema,
|
|
20
|
+
CommentRecordSchema,
|
|
21
|
+
} from "./schemas.js";
|
|
22
|
+
import { materializeTasks } from "./materialize.js";
|
|
23
|
+
import type { MaterializedTask } from "./types.js";
|
|
24
|
+
|
|
25
|
+
// Cache resolved PDS endpoints
|
|
26
|
+
const pdsCache = new Map<string, string>();
|
|
27
|
+
|
|
28
|
+
export async function resolvePDS(did: string): Promise<string | null> {
|
|
29
|
+
const cached = pdsCache.get(did);
|
|
30
|
+
if (cached) return cached;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
let didDoc: Record<string, unknown>;
|
|
34
|
+
|
|
35
|
+
if (did.startsWith("did:plc:")) {
|
|
36
|
+
const res = await fetch(`https://plc.directory/${did}`);
|
|
37
|
+
if (!res.ok) return null;
|
|
38
|
+
didDoc = await res.json() as Record<string, unknown>;
|
|
39
|
+
} else if (did.startsWith("did:web:")) {
|
|
40
|
+
const host = did.slice("did:web:".length).replaceAll(":", "/");
|
|
41
|
+
const res = await fetch(`https://${host}/.well-known/did.json`);
|
|
42
|
+
if (!res.ok) return null;
|
|
43
|
+
didDoc = await res.json() as Record<string, unknown>;
|
|
44
|
+
} else {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const services = didDoc.service as
|
|
49
|
+
| Array<{ id: string; type: string; serviceEndpoint: string }>
|
|
50
|
+
| undefined;
|
|
51
|
+
const pds = services?.find(
|
|
52
|
+
(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
|
|
53
|
+
);
|
|
54
|
+
if (!pds?.serviceEndpoint) return null;
|
|
55
|
+
|
|
56
|
+
pdsCache.set(did, pds.serviceEndpoint);
|
|
57
|
+
return pds.serviceEndpoint;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchRecordsFromRepo(
|
|
64
|
+
repoDid: string,
|
|
65
|
+
collection: string,
|
|
66
|
+
): Promise<Array<{ uri: string; value: Record<string, unknown> }>> {
|
|
67
|
+
const pds = await resolvePDS(repoDid);
|
|
68
|
+
if (!pds) return [];
|
|
69
|
+
|
|
70
|
+
const records: Array<{ uri: string; value: Record<string, unknown> }> = [];
|
|
71
|
+
let cursor: string | undefined;
|
|
72
|
+
|
|
73
|
+
do {
|
|
74
|
+
const params = new URLSearchParams({
|
|
75
|
+
repo: repoDid,
|
|
76
|
+
collection,
|
|
77
|
+
limit: "100",
|
|
78
|
+
});
|
|
79
|
+
if (cursor) params.set("cursor", cursor);
|
|
80
|
+
|
|
81
|
+
const res = await fetch(
|
|
82
|
+
`${pds}/xrpc/com.atproto.repo.listRecords?${params.toString()}`,
|
|
83
|
+
);
|
|
84
|
+
if (!res.ok) break;
|
|
85
|
+
|
|
86
|
+
const data = await res.json() as { records?: Array<{ uri: string; value: Record<string, unknown> }>; cursor?: string };
|
|
87
|
+
records.push(...(data.records ?? []));
|
|
88
|
+
cursor = data.cursor;
|
|
89
|
+
} while (cursor);
|
|
90
|
+
|
|
91
|
+
return records;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function inferOpenFromRecord(
|
|
95
|
+
value: Record<string, unknown>,
|
|
96
|
+
): boolean | undefined {
|
|
97
|
+
if (value.open !== undefined) return (value.open as boolean) || undefined;
|
|
98
|
+
const perms = value.permissions as
|
|
99
|
+
| { rules?: Array<{ scope?: string }> }
|
|
100
|
+
| undefined;
|
|
101
|
+
if (perms?.rules?.some((r) => r.scope === "anyone")) return true;
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Fetch a single board record from the owner's PDS.
|
|
107
|
+
*/
|
|
108
|
+
export async function fetchBoard(
|
|
109
|
+
ownerDid: string,
|
|
110
|
+
rkey: string,
|
|
111
|
+
): Promise<Board | null> {
|
|
112
|
+
try {
|
|
113
|
+
const pds = await resolvePDS(ownerDid);
|
|
114
|
+
if (!pds) return null;
|
|
115
|
+
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
repo: ownerDid,
|
|
118
|
+
collection: BOARD_COLLECTION,
|
|
119
|
+
rkey,
|
|
120
|
+
});
|
|
121
|
+
const res = await fetch(
|
|
122
|
+
`${pds}/xrpc/com.atproto.repo.getRecord?${params.toString()}`,
|
|
123
|
+
);
|
|
124
|
+
if (!res.ok) return null;
|
|
125
|
+
|
|
126
|
+
const data = await res.json() as { value: Record<string, unknown> };
|
|
127
|
+
const value = data.value;
|
|
128
|
+
|
|
129
|
+
const validated = safeParse(BoardRecordSchema, value, "BoardRecord");
|
|
130
|
+
if (!validated) return null;
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
rkey,
|
|
134
|
+
did: ownerDid,
|
|
135
|
+
name: validated.name,
|
|
136
|
+
description: validated.description,
|
|
137
|
+
columns: validated.columns,
|
|
138
|
+
labels: validated.labels,
|
|
139
|
+
open: inferOpenFromRecord(value),
|
|
140
|
+
createdAt: validated.createdAt,
|
|
141
|
+
};
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Fetch all boards owned by a DID.
|
|
149
|
+
*/
|
|
150
|
+
export async function fetchBoards(ownerDid: string): Promise<Board[]> {
|
|
151
|
+
const records = await fetchRecordsFromRepo(ownerDid, BOARD_COLLECTION);
|
|
152
|
+
const boards: Board[] = [];
|
|
153
|
+
|
|
154
|
+
for (const record of records) {
|
|
155
|
+
const validated = safeParse(BoardRecordSchema, record.value, "BoardRecord");
|
|
156
|
+
if (!validated) continue;
|
|
157
|
+
const rkey = record.uri.split("/").pop()!;
|
|
158
|
+
boards.push({
|
|
159
|
+
rkey,
|
|
160
|
+
did: ownerDid,
|
|
161
|
+
name: validated.name,
|
|
162
|
+
description: validated.description,
|
|
163
|
+
columns: validated.columns,
|
|
164
|
+
labels: validated.labels,
|
|
165
|
+
open: inferOpenFromRecord(record.value),
|
|
166
|
+
createdAt: validated.createdAt,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return boards;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Fetch all boards from the authenticated user's PDS (using Agent).
|
|
175
|
+
*/
|
|
176
|
+
export async function fetchMyBoards(agent: Agent, did: string): Promise<Board[]> {
|
|
177
|
+
const boards: Board[] = [];
|
|
178
|
+
let cursor: string | undefined;
|
|
179
|
+
|
|
180
|
+
do {
|
|
181
|
+
const res = await agent.com.atproto.repo.listRecords({
|
|
182
|
+
repo: did,
|
|
183
|
+
collection: BOARD_COLLECTION,
|
|
184
|
+
limit: 100,
|
|
185
|
+
cursor,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
for (const record of res.data.records) {
|
|
189
|
+
const rkey = record.uri.split("/").pop()!;
|
|
190
|
+
const value = record.value as Record<string, unknown>;
|
|
191
|
+
const validated = safeParse(BoardRecordSchema, value, "BoardRecord");
|
|
192
|
+
if (!validated) continue;
|
|
193
|
+
|
|
194
|
+
boards.push({
|
|
195
|
+
rkey,
|
|
196
|
+
did,
|
|
197
|
+
name: validated.name,
|
|
198
|
+
description: validated.description,
|
|
199
|
+
columns: validated.columns,
|
|
200
|
+
labels: validated.labels,
|
|
201
|
+
open: inferOpenFromRecord(value),
|
|
202
|
+
createdAt: validated.createdAt,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cursor = res.data.cursor;
|
|
207
|
+
} while (cursor);
|
|
208
|
+
|
|
209
|
+
return boards;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fetch tasks from a participant's repo for a specific board.
|
|
214
|
+
*/
|
|
215
|
+
export async function fetchTasks(
|
|
216
|
+
participantDid: string,
|
|
217
|
+
boardUri: string,
|
|
218
|
+
): Promise<Task[]> {
|
|
219
|
+
const records = await fetchRecordsFromRepo(participantDid, TASK_COLLECTION);
|
|
220
|
+
const tasks: Task[] = [];
|
|
221
|
+
|
|
222
|
+
for (const record of records) {
|
|
223
|
+
if (record.value.boardUri !== boardUri) continue;
|
|
224
|
+
const validated = safeParse(TaskRecordSchema, record.value, "TaskRecord");
|
|
225
|
+
if (!validated) continue;
|
|
226
|
+
const rkey = record.uri.split("/").pop()!;
|
|
227
|
+
tasks.push({
|
|
228
|
+
rkey,
|
|
229
|
+
did: participantDid,
|
|
230
|
+
title: validated.title,
|
|
231
|
+
description: validated.description,
|
|
232
|
+
columnId: validated.columnId,
|
|
233
|
+
boardUri,
|
|
234
|
+
position: validated.position,
|
|
235
|
+
labelIds: validated.labelIds,
|
|
236
|
+
order: validated.order,
|
|
237
|
+
createdAt: validated.createdAt,
|
|
238
|
+
updatedAt: validated.updatedAt,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return tasks;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Fetch ops from a participant's repo for a specific board.
|
|
247
|
+
*/
|
|
248
|
+
export async function fetchOps(
|
|
249
|
+
participantDid: string,
|
|
250
|
+
boardUri: string,
|
|
251
|
+
): Promise<Op[]> {
|
|
252
|
+
const records = await fetchRecordsFromRepo(participantDid, OP_COLLECTION);
|
|
253
|
+
const ops: Op[] = [];
|
|
254
|
+
|
|
255
|
+
for (const record of records) {
|
|
256
|
+
if (record.value.boardUri !== boardUri) continue;
|
|
257
|
+
const validated = safeParse(OpRecordSchema, record.value, "OpRecord");
|
|
258
|
+
if (!validated) continue;
|
|
259
|
+
const rkey = record.uri.split("/").pop()!;
|
|
260
|
+
ops.push({
|
|
261
|
+
rkey,
|
|
262
|
+
did: participantDid,
|
|
263
|
+
targetTaskUri: validated.targetTaskUri,
|
|
264
|
+
boardUri,
|
|
265
|
+
fields: validated.fields,
|
|
266
|
+
createdAt: validated.createdAt,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return ops;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Fetch trusts from the board owner's repo for a specific board.
|
|
275
|
+
*/
|
|
276
|
+
export async function fetchTrusts(
|
|
277
|
+
ownerDid: string,
|
|
278
|
+
boardUri: string,
|
|
279
|
+
): Promise<Trust[]> {
|
|
280
|
+
const records = await fetchRecordsFromRepo(ownerDid, TRUST_COLLECTION);
|
|
281
|
+
const trusts: Trust[] = [];
|
|
282
|
+
|
|
283
|
+
for (const record of records) {
|
|
284
|
+
if (record.value.boardUri !== boardUri) continue;
|
|
285
|
+
const validated = safeParse(TrustRecordSchema, record.value, "TrustRecord");
|
|
286
|
+
if (!validated) continue;
|
|
287
|
+
const rkey = record.uri.split("/").pop()!;
|
|
288
|
+
trusts.push({
|
|
289
|
+
rkey,
|
|
290
|
+
did: ownerDid,
|
|
291
|
+
trustedDid: validated.trustedDid,
|
|
292
|
+
boardUri,
|
|
293
|
+
createdAt: validated.createdAt,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return trusts;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Fetch comments from a participant's repo for a specific board.
|
|
302
|
+
*/
|
|
303
|
+
export async function fetchComments(
|
|
304
|
+
participantDid: string,
|
|
305
|
+
boardUri: string,
|
|
306
|
+
): Promise<Comment[]> {
|
|
307
|
+
const records = await fetchRecordsFromRepo(participantDid, COMMENT_COLLECTION);
|
|
308
|
+
const comments: Comment[] = [];
|
|
309
|
+
|
|
310
|
+
for (const record of records) {
|
|
311
|
+
if (record.value.boardUri !== boardUri) continue;
|
|
312
|
+
const validated = safeParse(CommentRecordSchema, record.value, "CommentRecord");
|
|
313
|
+
if (!validated) continue;
|
|
314
|
+
const rkey = record.uri.split("/").pop()!;
|
|
315
|
+
comments.push({
|
|
316
|
+
rkey,
|
|
317
|
+
did: participantDid,
|
|
318
|
+
targetTaskUri: validated.targetTaskUri,
|
|
319
|
+
boardUri,
|
|
320
|
+
text: validated.text,
|
|
321
|
+
createdAt: validated.createdAt,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return comments;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface BoardData {
|
|
329
|
+
board: Board;
|
|
330
|
+
tasks: MaterializedTask[];
|
|
331
|
+
trusts: Trust[];
|
|
332
|
+
comments: Comment[];
|
|
333
|
+
allParticipants: string[];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Fetch all data for a board: board record, trusts, tasks, ops from all
|
|
338
|
+
* participants, materialize tasks. This is the main "load board" function.
|
|
339
|
+
*/
|
|
340
|
+
export async function fetchBoardData(
|
|
341
|
+
boardDid: string,
|
|
342
|
+
boardRkey: string,
|
|
343
|
+
currentUserDid: string,
|
|
344
|
+
): Promise<BoardData | null> {
|
|
345
|
+
const boardUri = buildAtUri(boardDid, BOARD_COLLECTION, boardRkey);
|
|
346
|
+
|
|
347
|
+
// Fetch board
|
|
348
|
+
const board = await fetchBoard(boardDid, boardRkey);
|
|
349
|
+
if (!board) return null;
|
|
350
|
+
|
|
351
|
+
// Fetch trusts to discover all participants
|
|
352
|
+
const trusts = await fetchTrusts(boardDid, boardUri);
|
|
353
|
+
const trustedDids = new Set(trusts.map((t) => t.trustedDid));
|
|
354
|
+
|
|
355
|
+
// All participants: owner + trusted DIDs + current user
|
|
356
|
+
const allParticipants = new Set<string>();
|
|
357
|
+
allParticipants.add(boardDid);
|
|
358
|
+
for (const did of trustedDids) allParticipants.add(did);
|
|
359
|
+
if (currentUserDid) allParticipants.add(currentUserDid);
|
|
360
|
+
|
|
361
|
+
// Fetch tasks + ops from all participants in parallel (batches of 3)
|
|
362
|
+
const participantList = [...allParticipants];
|
|
363
|
+
let allTasks: Task[] = [];
|
|
364
|
+
let allOps: Op[] = [];
|
|
365
|
+
let allComments: Comment[] = [];
|
|
366
|
+
|
|
367
|
+
const concurrency = 3;
|
|
368
|
+
for (let i = 0; i < participantList.length; i += concurrency) {
|
|
369
|
+
const batch = participantList.slice(i, i + concurrency);
|
|
370
|
+
|
|
371
|
+
const taskPromises = batch.map((did) => fetchTasks(did, boardUri));
|
|
372
|
+
const opPromises = batch.map((did) => fetchOps(did, boardUri));
|
|
373
|
+
const commentPromises = batch.map((did) => fetchComments(did, boardUri));
|
|
374
|
+
|
|
375
|
+
const [taskResults, opResults, commentResults] = await Promise.all([
|
|
376
|
+
Promise.allSettled(taskPromises),
|
|
377
|
+
Promise.allSettled(opPromises),
|
|
378
|
+
Promise.allSettled(commentPromises),
|
|
379
|
+
]);
|
|
380
|
+
|
|
381
|
+
for (const r of taskResults) {
|
|
382
|
+
if (r.status === "fulfilled") allTasks.push(...r.value);
|
|
383
|
+
}
|
|
384
|
+
for (const r of opResults) {
|
|
385
|
+
if (r.status === "fulfilled") allOps.push(...r.value);
|
|
386
|
+
}
|
|
387
|
+
for (const r of commentResults) {
|
|
388
|
+
if (r.status === "fulfilled") allComments.push(...r.value);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Materialize tasks
|
|
393
|
+
const materialized = materializeTasks(
|
|
394
|
+
allTasks,
|
|
395
|
+
allOps,
|
|
396
|
+
trustedDids,
|
|
397
|
+
currentUserDid,
|
|
398
|
+
boardDid,
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
board,
|
|
403
|
+
tasks: materialized,
|
|
404
|
+
trusts,
|
|
405
|
+
comments: allComments,
|
|
406
|
+
allParticipants: participantList,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a handle to a DID.
|
|
412
|
+
*/
|
|
413
|
+
export async function resolveHandle(handle: string): Promise<string | null> {
|
|
414
|
+
// Try .well-known first
|
|
415
|
+
try {
|
|
416
|
+
const res = await fetch(`https://${handle}/.well-known/atproto-did`);
|
|
417
|
+
if (res.ok) {
|
|
418
|
+
const text = (await res.text()).trim();
|
|
419
|
+
if (text.startsWith("did:")) return text;
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
// fall through
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Try bsky.social resolution
|
|
426
|
+
try {
|
|
427
|
+
const params = new URLSearchParams({ handle });
|
|
428
|
+
const res = await fetch(
|
|
429
|
+
`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?${params.toString()}`,
|
|
430
|
+
);
|
|
431
|
+
if (res.ok) {
|
|
432
|
+
const data = await res.json() as { did: string };
|
|
433
|
+
return data.did;
|
|
434
|
+
}
|
|
435
|
+
} catch {
|
|
436
|
+
// fall through
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Synced from ../src/lib/permissions.ts
|
|
2
|
+
|
|
3
|
+
export type PermissionStatus = "allowed" | "pending" | "denied";
|
|
4
|
+
export type ActionType = "create_task" | "comment" | "edit" | "move";
|
|
5
|
+
|
|
6
|
+
export function isTrusted(
|
|
7
|
+
userDid: string,
|
|
8
|
+
boardOwnerDid: string,
|
|
9
|
+
trustedDids: Set<string>,
|
|
10
|
+
): boolean {
|
|
11
|
+
return userDid === boardOwnerDid || trustedDids.has(userDid);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getActionStatus(
|
|
15
|
+
userDid: string,
|
|
16
|
+
boardOwnerDid: string,
|
|
17
|
+
trustedDids: Set<string>,
|
|
18
|
+
boardOpen: boolean,
|
|
19
|
+
action: ActionType,
|
|
20
|
+
): PermissionStatus {
|
|
21
|
+
if (isTrusted(userDid, boardOwnerDid, trustedDids)) return "allowed";
|
|
22
|
+
|
|
23
|
+
if (action === "create_task" || action === "comment") {
|
|
24
|
+
return boardOpen ? "pending" : "denied";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return "denied";
|
|
28
|
+
}
|