@aprovan/hardcopy 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/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
# Generic Sync Plugin Architecture
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
This document outlines the architecture for a generic bi-directional sync system that can adapt to multiple data sources (GitHub Issues, Figma, Google Calendar, Gmail, Google Docs, etc.) while maintaining:
|
|
6
|
+
|
|
7
|
+
1. **Consistent local file representation** (Markdown/YAML)
|
|
8
|
+
2. **CRDT-based conflict resolution** using Loro
|
|
9
|
+
3. **Graph-queryable data** using GQL/Cypher patterns
|
|
10
|
+
4. **Extensible adapter pattern** for new data sources
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Architecture Layers
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
┌─────────────────────────────────────────────────────────────┐
|
|
18
|
+
│ User Interface │
|
|
19
|
+
│ CLI Commands | File Watcher | VS Code Extension | Web UI │
|
|
20
|
+
├─────────────────────────────────────────────────────────────┤
|
|
21
|
+
│ Query Engine │
|
|
22
|
+
│ GQL Parser | Graph Store | View Loader │
|
|
23
|
+
├─────────────────────────────────────────────────────────────┤
|
|
24
|
+
│ Sync Engine │
|
|
25
|
+
│ Conflict Resolution | Delta Detection | State Tracking │
|
|
26
|
+
├─────────────────────────────────────────────────────────────┤
|
|
27
|
+
│ CRDT Layer │
|
|
28
|
+
│ Loro Documents | Version Tracking | Merge │
|
|
29
|
+
├─────────────────────────────────────────────────────────────┤
|
|
30
|
+
│ Adapter Interface │
|
|
31
|
+
│ Source Adapters (GitHub, Figma, Google, etc.) │
|
|
32
|
+
├─────────────────────────────────────────────────────────────┤
|
|
33
|
+
│ Storage Layer │
|
|
34
|
+
│ Local Files | CRDT Persistence | Cache │
|
|
35
|
+
└─────────────────────────────────────────────────────────────┘
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Core Interfaces
|
|
41
|
+
|
|
42
|
+
### Source Adapter Interface
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
interface SourceAdapter<TItem, TMetadata = unknown> {
|
|
46
|
+
// Identity
|
|
47
|
+
readonly id: string; // e.g., "github-issues"
|
|
48
|
+
readonly displayName: string; // e.g., "GitHub Issues"
|
|
49
|
+
readonly icon: string; // e.g., "github"
|
|
50
|
+
|
|
51
|
+
// Configuration
|
|
52
|
+
configure(config: AdapterConfig): Promise<void>;
|
|
53
|
+
validateConfig(): Promise<ValidationResult>;
|
|
54
|
+
|
|
55
|
+
// Data Operations
|
|
56
|
+
fetchAll(options?: FetchOptions): AsyncGenerator<TItem>;
|
|
57
|
+
fetchOne(id: string): Promise<TItem>;
|
|
58
|
+
fetchMetadata(): Promise<TMetadata>;
|
|
59
|
+
|
|
60
|
+
create(data: CreateInput<TItem>): Promise<TItem>;
|
|
61
|
+
update(id: string, data: UpdateInput<TItem>): Promise<TItem>;
|
|
62
|
+
delete(id: string): Promise<void>;
|
|
63
|
+
|
|
64
|
+
// CRDT Integration
|
|
65
|
+
itemToCRDT(item: TItem): LoroDocument;
|
|
66
|
+
crdtToItem(doc: LoroDocument): Partial<TItem>;
|
|
67
|
+
|
|
68
|
+
// File Format
|
|
69
|
+
itemToFile(item: TItem): FileContent;
|
|
70
|
+
fileToItem(file: FileContent): ParseResult<TItem>;
|
|
71
|
+
|
|
72
|
+
// Graph Model
|
|
73
|
+
itemToNodes(item: TItem): GraphNode[];
|
|
74
|
+
itemToEdges(item: TItem): GraphEdge[];
|
|
75
|
+
|
|
76
|
+
// Change Detection
|
|
77
|
+
getLastModified(item: TItem): Date;
|
|
78
|
+
getETag?(item: TItem): string;
|
|
79
|
+
|
|
80
|
+
// Webhooks (optional)
|
|
81
|
+
webhookHandler?: WebhookHandler<TItem>;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Sync State Interface
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
interface SyncState {
|
|
89
|
+
// Item tracking
|
|
90
|
+
items: Map<string, ItemSyncState>;
|
|
91
|
+
|
|
92
|
+
// Global metadata
|
|
93
|
+
lastFullSync: Date | null;
|
|
94
|
+
lastIncrementalSync: Date | null;
|
|
95
|
+
|
|
96
|
+
// Adapter-specific state
|
|
97
|
+
adapterState: Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ItemSyncState {
|
|
101
|
+
id: string;
|
|
102
|
+
localPath: string;
|
|
103
|
+
|
|
104
|
+
// Versions
|
|
105
|
+
remoteVersion: string; // ETag or updated_at
|
|
106
|
+
localVersion: Uint8Array; // CRDT frontiers
|
|
107
|
+
|
|
108
|
+
// Timestamps
|
|
109
|
+
remoteUpdatedAt: Date;
|
|
110
|
+
localUpdatedAt: Date;
|
|
111
|
+
lastSyncAt: Date;
|
|
112
|
+
|
|
113
|
+
// Status
|
|
114
|
+
status: 'synced' | 'local_ahead' | 'remote_ahead' | 'conflict' | 'error';
|
|
115
|
+
errorMessage?: string;
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### File Content Interface
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
interface FileContent {
|
|
123
|
+
// Front matter (YAML)
|
|
124
|
+
frontmatter: Record<string, unknown>;
|
|
125
|
+
|
|
126
|
+
// Body content
|
|
127
|
+
body: string;
|
|
128
|
+
|
|
129
|
+
// Optional structured data
|
|
130
|
+
data?: Record<string, unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface ParseResult<T> {
|
|
134
|
+
success: boolean;
|
|
135
|
+
item?: Partial<T>;
|
|
136
|
+
errors?: ParseError[];
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Adapter Implementations
|
|
143
|
+
|
|
144
|
+
### GitHub Issues Adapter
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
class GitHubIssuesAdapter implements SourceAdapter<GitHubIssue, RepoMetadata> {
|
|
148
|
+
readonly id = 'github-issues';
|
|
149
|
+
readonly displayName = 'GitHub Issues';
|
|
150
|
+
readonly icon = 'github';
|
|
151
|
+
|
|
152
|
+
private octokit: Octokit;
|
|
153
|
+
private owner: string;
|
|
154
|
+
private repo: string;
|
|
155
|
+
|
|
156
|
+
async *fetchAll(options?: FetchOptions): AsyncGenerator<GitHubIssue> {
|
|
157
|
+
const since = options?.since?.toISOString();
|
|
158
|
+
|
|
159
|
+
for await (const response of this.octokit.paginate.iterator(
|
|
160
|
+
this.octokit.rest.issues.listForRepo,
|
|
161
|
+
{
|
|
162
|
+
owner: this.owner,
|
|
163
|
+
repo: this.repo,
|
|
164
|
+
state: 'all',
|
|
165
|
+
since,
|
|
166
|
+
per_page: 100,
|
|
167
|
+
}
|
|
168
|
+
)) {
|
|
169
|
+
for (const issue of response.data) {
|
|
170
|
+
if (!issue.pull_request) { // Skip PRs
|
|
171
|
+
yield issue as GitHubIssue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
itemToCRDT(issue: GitHubIssue): LoroDocument {
|
|
178
|
+
const doc = new Loro();
|
|
179
|
+
|
|
180
|
+
doc.getText('title').insert(0, issue.title);
|
|
181
|
+
doc.getText('body').insert(0, issue.body || '');
|
|
182
|
+
|
|
183
|
+
const labels = doc.getList('labels');
|
|
184
|
+
for (const label of issue.labels) {
|
|
185
|
+
labels.push(typeof label === 'string' ? label : label.name);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const assignees = doc.getList('assignees');
|
|
189
|
+
for (const assignee of issue.assignees || []) {
|
|
190
|
+
assignees.push(assignee.login);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const meta = doc.getMap('metadata');
|
|
194
|
+
meta.set('state', issue.state);
|
|
195
|
+
meta.set('state_reason', issue.state_reason);
|
|
196
|
+
meta.set('number', issue.number);
|
|
197
|
+
meta.set('id', issue.id);
|
|
198
|
+
meta.set('node_id', issue.node_id);
|
|
199
|
+
meta.set('url', issue.html_url);
|
|
200
|
+
meta.set('milestone', issue.milestone?.title || null);
|
|
201
|
+
|
|
202
|
+
doc.commit();
|
|
203
|
+
return doc;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
itemToFile(issue: GitHubIssue): FileContent {
|
|
207
|
+
return {
|
|
208
|
+
frontmatter: {
|
|
209
|
+
id: issue.id,
|
|
210
|
+
node_id: issue.node_id,
|
|
211
|
+
number: issue.number,
|
|
212
|
+
url: issue.html_url,
|
|
213
|
+
state: issue.state,
|
|
214
|
+
state_reason: issue.state_reason,
|
|
215
|
+
labels: issue.labels.map(l => typeof l === 'string' ? l : l.name),
|
|
216
|
+
assignees: issue.assignees?.map(a => a.login) || [],
|
|
217
|
+
milestone: issue.milestone?.title || null,
|
|
218
|
+
created_at: issue.created_at,
|
|
219
|
+
updated_at: issue.updated_at,
|
|
220
|
+
},
|
|
221
|
+
body: `# ${issue.title}\n\n${issue.body || ''}`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
itemToNodes(issue: GitHubIssue): GraphNode[] {
|
|
226
|
+
const nodes: GraphNode[] = [];
|
|
227
|
+
|
|
228
|
+
// Issue node
|
|
229
|
+
nodes.push({
|
|
230
|
+
_type: 'Issue',
|
|
231
|
+
_id: `github:${this.owner}/${this.repo}#${issue.number}`,
|
|
232
|
+
number: issue.number,
|
|
233
|
+
title: issue.title,
|
|
234
|
+
body: issue.body || '',
|
|
235
|
+
state: issue.state,
|
|
236
|
+
created_at: issue.created_at,
|
|
237
|
+
updated_at: issue.updated_at,
|
|
238
|
+
url: issue.html_url,
|
|
239
|
+
source: 'github',
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// Label nodes
|
|
243
|
+
for (const label of issue.labels) {
|
|
244
|
+
const labelObj = typeof label === 'string' ? { name: label } : label;
|
|
245
|
+
nodes.push({
|
|
246
|
+
_type: 'Label',
|
|
247
|
+
_id: `github:${this.owner}/${this.repo}:label:${labelObj.name}`,
|
|
248
|
+
name: labelObj.name,
|
|
249
|
+
color: (labelObj as any).color || '',
|
|
250
|
+
description: (labelObj as any).description || null,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// User nodes
|
|
255
|
+
for (const assignee of issue.assignees || []) {
|
|
256
|
+
nodes.push({
|
|
257
|
+
_type: 'User',
|
|
258
|
+
_id: `github:${assignee.login}`,
|
|
259
|
+
login: assignee.login,
|
|
260
|
+
avatar_url: assignee.avatar_url,
|
|
261
|
+
source: 'github',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return nodes;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
itemToEdges(issue: GitHubIssue): GraphEdge[] {
|
|
269
|
+
const edges: GraphEdge[] = [];
|
|
270
|
+
const issueId = `github:${this.owner}/${this.repo}#${issue.number}`;
|
|
271
|
+
|
|
272
|
+
// Label edges
|
|
273
|
+
for (const label of issue.labels) {
|
|
274
|
+
const labelName = typeof label === 'string' ? label : label.name;
|
|
275
|
+
edges.push({
|
|
276
|
+
_type: 'HAS_LABEL',
|
|
277
|
+
_from: issueId,
|
|
278
|
+
_to: `github:${this.owner}/${this.repo}:label:${labelName}`,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Assignee edges
|
|
283
|
+
for (const assignee of issue.assignees || []) {
|
|
284
|
+
edges.push({
|
|
285
|
+
_type: 'ASSIGNED_TO',
|
|
286
|
+
_from: issueId,
|
|
287
|
+
_to: `github:${assignee.login}`,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Reference edges (from body parsing)
|
|
292
|
+
for (const ref of this.parseReferences(issue.body || '')) {
|
|
293
|
+
edges.push({
|
|
294
|
+
_type: 'REFERENCES',
|
|
295
|
+
_from: issueId,
|
|
296
|
+
_to: ref,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return edges;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Figma Comments Adapter (Example)
|
|
306
|
+
|
|
307
|
+
```typescript
|
|
308
|
+
class FigmaCommentsAdapter implements SourceAdapter<FigmaComment, FileMetadata> {
|
|
309
|
+
readonly id = 'figma-comments';
|
|
310
|
+
readonly displayName = 'Figma Comments';
|
|
311
|
+
readonly icon = 'figma';
|
|
312
|
+
|
|
313
|
+
async *fetchAll(): AsyncGenerator<FigmaComment> {
|
|
314
|
+
const response = await fetch(
|
|
315
|
+
`https://api.figma.com/v1/files/${this.fileKey}/comments`,
|
|
316
|
+
{ headers: { 'X-Figma-Token': this.token } }
|
|
317
|
+
);
|
|
318
|
+
const data = await response.json();
|
|
319
|
+
yield* data.comments;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
itemToFile(comment: FigmaComment): FileContent {
|
|
323
|
+
return {
|
|
324
|
+
frontmatter: {
|
|
325
|
+
id: comment.id,
|
|
326
|
+
file_key: this.fileKey,
|
|
327
|
+
node_id: comment.client_meta?.node_id,
|
|
328
|
+
author: comment.user.handle,
|
|
329
|
+
created_at: comment.created_at,
|
|
330
|
+
resolved_at: comment.resolved_at,
|
|
331
|
+
},
|
|
332
|
+
body: comment.message,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ... other methods
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Google Calendar Events Adapter (Example)
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
class GoogleCalendarAdapter implements SourceAdapter<CalendarEvent, CalendarMetadata> {
|
|
344
|
+
readonly id = 'google-calendar';
|
|
345
|
+
readonly displayName = 'Google Calendar';
|
|
346
|
+
readonly icon = 'calendar';
|
|
347
|
+
|
|
348
|
+
async *fetchAll(options?: FetchOptions): AsyncGenerator<CalendarEvent> {
|
|
349
|
+
const calendar = google.calendar({ version: 'v3', auth: this.auth });
|
|
350
|
+
|
|
351
|
+
let pageToken: string | undefined;
|
|
352
|
+
do {
|
|
353
|
+
const response = await calendar.events.list({
|
|
354
|
+
calendarId: this.calendarId,
|
|
355
|
+
timeMin: options?.since?.toISOString(),
|
|
356
|
+
maxResults: 100,
|
|
357
|
+
pageToken,
|
|
358
|
+
singleEvents: true,
|
|
359
|
+
orderBy: 'startTime',
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
for (const event of response.data.items || []) {
|
|
363
|
+
yield event as CalendarEvent;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
pageToken = response.data.nextPageToken || undefined;
|
|
367
|
+
} while (pageToken);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
itemToFile(event: CalendarEvent): FileContent {
|
|
371
|
+
return {
|
|
372
|
+
frontmatter: {
|
|
373
|
+
id: event.id,
|
|
374
|
+
calendar_id: this.calendarId,
|
|
375
|
+
status: event.status,
|
|
376
|
+
summary: event.summary,
|
|
377
|
+
start: event.start?.dateTime || event.start?.date,
|
|
378
|
+
end: event.end?.dateTime || event.end?.date,
|
|
379
|
+
location: event.location,
|
|
380
|
+
attendees: event.attendees?.map(a => a.email),
|
|
381
|
+
recurrence: event.recurrence,
|
|
382
|
+
updated_at: event.updated,
|
|
383
|
+
},
|
|
384
|
+
body: event.description || '',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
itemToNodes(event: CalendarEvent): GraphNode[] {
|
|
389
|
+
return [{
|
|
390
|
+
_type: 'CalendarEvent',
|
|
391
|
+
_id: `gcal:${this.calendarId}:${event.id}`,
|
|
392
|
+
summary: event.summary || '',
|
|
393
|
+
start: event.start?.dateTime || event.start?.date || '',
|
|
394
|
+
end: event.end?.dateTime || event.end?.date || '',
|
|
395
|
+
location: event.location || null,
|
|
396
|
+
source: 'google-calendar',
|
|
397
|
+
}];
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Sync Engine
|
|
405
|
+
|
|
406
|
+
```typescript
|
|
407
|
+
class SyncEngine {
|
|
408
|
+
constructor(
|
|
409
|
+
private adapters: Map<string, SourceAdapter<any>>,
|
|
410
|
+
private storage: StorageProvider,
|
|
411
|
+
private stateManager: SyncStateManager,
|
|
412
|
+
private graphStore: GraphStore,
|
|
413
|
+
) {}
|
|
414
|
+
|
|
415
|
+
// Full sync - pull everything
|
|
416
|
+
async fullSync(adapterId: string): Promise<SyncResult> {
|
|
417
|
+
const adapter = this.adapters.get(adapterId);
|
|
418
|
+
if (!adapter) throw new Error(`Unknown adapter: ${adapterId}`);
|
|
419
|
+
|
|
420
|
+
const results: SyncItemResult[] = [];
|
|
421
|
+
|
|
422
|
+
for await (const item of adapter.fetchAll()) {
|
|
423
|
+
const result = await this.syncItem(adapter, item, 'pull');
|
|
424
|
+
results.push(result);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Update metadata
|
|
428
|
+
const metadata = await adapter.fetchMetadata();
|
|
429
|
+
await this.storage.saveMetadata(adapterId, metadata);
|
|
430
|
+
|
|
431
|
+
// Rebuild graph
|
|
432
|
+
await this.rebuildGraph(adapterId);
|
|
433
|
+
|
|
434
|
+
// Update state
|
|
435
|
+
await this.stateManager.setLastFullSync(adapterId, new Date());
|
|
436
|
+
|
|
437
|
+
return { adapterId, results };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Incremental sync - only changed items
|
|
441
|
+
async incrementalSync(adapterId: string): Promise<SyncResult> {
|
|
442
|
+
const adapter = this.adapters.get(adapterId);
|
|
443
|
+
if (!adapter) throw new Error(`Unknown adapter: ${adapterId}`);
|
|
444
|
+
|
|
445
|
+
const lastSync = await this.stateManager.getLastSync(adapterId);
|
|
446
|
+
const results: SyncItemResult[] = [];
|
|
447
|
+
|
|
448
|
+
// Pull remote changes
|
|
449
|
+
for await (const item of adapter.fetchAll({ since: lastSync })) {
|
|
450
|
+
const result = await this.syncItem(adapter, item, 'pull');
|
|
451
|
+
results.push(result);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Push local changes
|
|
455
|
+
const localChanges = await this.stateManager.getLocalChanges(adapterId);
|
|
456
|
+
for (const itemId of localChanges) {
|
|
457
|
+
const result = await this.pushLocalChange(adapter, itemId);
|
|
458
|
+
results.push(result);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Update graph incrementally
|
|
462
|
+
await this.updateGraph(adapterId, results);
|
|
463
|
+
|
|
464
|
+
await this.stateManager.setLastSync(adapterId, new Date());
|
|
465
|
+
|
|
466
|
+
return { adapterId, results };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Sync single item
|
|
470
|
+
private async syncItem<T>(
|
|
471
|
+
adapter: SourceAdapter<T>,
|
|
472
|
+
remoteItem: T,
|
|
473
|
+
direction: 'pull' | 'push' | 'both'
|
|
474
|
+
): Promise<SyncItemResult> {
|
|
475
|
+
const itemId = this.getItemId(adapter, remoteItem);
|
|
476
|
+
const state = await this.stateManager.getItemState(adapter.id, itemId);
|
|
477
|
+
|
|
478
|
+
// Load or create CRDT
|
|
479
|
+
let doc = await this.storage.loadCRDT(adapter.id, itemId);
|
|
480
|
+
if (!doc) {
|
|
481
|
+
doc = adapter.itemToCRDT(remoteItem);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Check sync status
|
|
485
|
+
const remoteUpdatedAt = adapter.getLastModified(remoteItem);
|
|
486
|
+
const status = this.computeSyncStatus(state, remoteUpdatedAt, doc);
|
|
487
|
+
|
|
488
|
+
switch (status) {
|
|
489
|
+
case 'synced':
|
|
490
|
+
return { itemId, status: 'unchanged' };
|
|
491
|
+
|
|
492
|
+
case 'remote_ahead':
|
|
493
|
+
// Merge remote into local
|
|
494
|
+
const remoteDoc = adapter.itemToCRDT(remoteItem);
|
|
495
|
+
doc.import(remoteDoc.export({ mode: 'update' }));
|
|
496
|
+
break;
|
|
497
|
+
|
|
498
|
+
case 'local_ahead':
|
|
499
|
+
// Push local to remote
|
|
500
|
+
const updates = adapter.crdtToItem(doc);
|
|
501
|
+
await adapter.update(itemId, updates);
|
|
502
|
+
break;
|
|
503
|
+
|
|
504
|
+
case 'conflict':
|
|
505
|
+
// CRDT merge + push
|
|
506
|
+
const remoteDocConflict = adapter.itemToCRDT(remoteItem);
|
|
507
|
+
doc.import(remoteDocConflict.export({ mode: 'update' }));
|
|
508
|
+
const mergedUpdates = adapter.crdtToItem(doc);
|
|
509
|
+
await adapter.update(itemId, mergedUpdates);
|
|
510
|
+
break;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Save CRDT
|
|
514
|
+
await this.storage.saveCRDT(adapter.id, itemId, doc);
|
|
515
|
+
|
|
516
|
+
// Save file
|
|
517
|
+
const fileContent = adapter.itemToFile(remoteItem);
|
|
518
|
+
await this.storage.saveFile(adapter.id, itemId, fileContent);
|
|
519
|
+
|
|
520
|
+
// Update state
|
|
521
|
+
await this.stateManager.updateItemState(adapter.id, itemId, {
|
|
522
|
+
remoteVersion: adapter.getETag?.(remoteItem) || remoteUpdatedAt.toISOString(),
|
|
523
|
+
localVersion: doc.export({ mode: 'snapshot' }),
|
|
524
|
+
remoteUpdatedAt,
|
|
525
|
+
localUpdatedAt: new Date(),
|
|
526
|
+
lastSyncAt: new Date(),
|
|
527
|
+
status: 'synced',
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
return { itemId, status: 'updated' };
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
---
|
|
536
|
+
|
|
537
|
+
## Storage Provider
|
|
538
|
+
|
|
539
|
+
```typescript
|
|
540
|
+
interface StorageProvider {
|
|
541
|
+
// CRDT storage
|
|
542
|
+
loadCRDT(adapterId: string, itemId: string): Promise<LoroDocument | null>;
|
|
543
|
+
saveCRDT(adapterId: string, itemId: string, doc: LoroDocument): Promise<void>;
|
|
544
|
+
|
|
545
|
+
// File storage
|
|
546
|
+
loadFile(adapterId: string, itemId: string): Promise<FileContent | null>;
|
|
547
|
+
saveFile(adapterId: string, itemId: string, content: FileContent): Promise<void>;
|
|
548
|
+
deleteFile(adapterId: string, itemId: string): Promise<void>;
|
|
549
|
+
listFiles(adapterId: string): AsyncGenerator<string>;
|
|
550
|
+
|
|
551
|
+
// Metadata storage
|
|
552
|
+
loadMetadata(adapterId: string): Promise<unknown>;
|
|
553
|
+
saveMetadata(adapterId: string, metadata: unknown): Promise<void>;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
class FileSystemStorageProvider implements StorageProvider {
|
|
557
|
+
constructor(private baseDir: string) {}
|
|
558
|
+
|
|
559
|
+
private getPath(adapterId: string, itemId: string, ext: string): string {
|
|
560
|
+
const sanitized = itemId.replace(/[^a-zA-Z0-9-]/g, '_');
|
|
561
|
+
return path.join(this.baseDir, adapterId, `${sanitized}.${ext}`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async saveCRDT(adapterId: string, itemId: string, doc: LoroDocument): Promise<void> {
|
|
565
|
+
const filePath = this.getPath(adapterId, itemId, 'loro');
|
|
566
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
567
|
+
await fs.writeFile(filePath, Buffer.from(doc.export({ mode: 'snapshot' })));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async saveFile(adapterId: string, itemId: string, content: FileContent): Promise<void> {
|
|
571
|
+
const filePath = this.getPath(adapterId, itemId, 'md');
|
|
572
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
573
|
+
|
|
574
|
+
const yaml = YAML.stringify(content.frontmatter);
|
|
575
|
+
const fileContent = `---\n${yaml}---\n\n${content.body}`;
|
|
576
|
+
|
|
577
|
+
await fs.writeFile(filePath, fileContent, 'utf-8');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Directory Structure
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
.hardcopy/
|
|
588
|
+
├── hardcopy.yaml # Global configuration
|
|
589
|
+
├── sync-state.yaml # Sync state tracking
|
|
590
|
+
├── crdt/ # CRDT binary files
|
|
591
|
+
│ ├── github-issues/
|
|
592
|
+
│ │ ├── issue-42.loro
|
|
593
|
+
│ │ └── issue-43.loro
|
|
594
|
+
│ ├── figma-comments/
|
|
595
|
+
│ └── google-calendar/
|
|
596
|
+
├── adapters/
|
|
597
|
+
│ ├── github-issues/
|
|
598
|
+
│ │ ├── issues/
|
|
599
|
+
│ │ │ ├── 042-fix-login-bug.md
|
|
600
|
+
│ │ │ └── 043-add-dark-mode.md
|
|
601
|
+
│ │ ├── projects/
|
|
602
|
+
│ │ │ └── sprint-board/
|
|
603
|
+
│ │ │ ├── metadata.yaml
|
|
604
|
+
│ │ │ └── view.yaml
|
|
605
|
+
│ │ └── metadata/
|
|
606
|
+
│ │ ├── labels.yaml
|
|
607
|
+
│ │ ├── milestones.yaml
|
|
608
|
+
│ │ └── users.yaml
|
|
609
|
+
│ ├── figma-comments/
|
|
610
|
+
│ │ └── comments/
|
|
611
|
+
│ └── google-calendar/
|
|
612
|
+
│ └── events/
|
|
613
|
+
├── views/ # Custom views
|
|
614
|
+
│ ├── my-issues.yaml
|
|
615
|
+
│ ├── sprint-board.yaml
|
|
616
|
+
│ └── roadmap.yaml
|
|
617
|
+
└── graph/ # Graph exports
|
|
618
|
+
└── graph.json
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
---
|
|
622
|
+
|
|
623
|
+
## Configuration
|
|
624
|
+
|
|
625
|
+
```yaml
|
|
626
|
+
# .hardcopy/hardcopy.yaml
|
|
627
|
+
version: 1
|
|
628
|
+
|
|
629
|
+
# Adapter configurations
|
|
630
|
+
adapters:
|
|
631
|
+
github-issues:
|
|
632
|
+
enabled: true
|
|
633
|
+
owner: myorg
|
|
634
|
+
repo: myrepo
|
|
635
|
+
sync_interval: 5m
|
|
636
|
+
include_prs: false
|
|
637
|
+
|
|
638
|
+
github-projects:
|
|
639
|
+
enabled: true
|
|
640
|
+
project_numbers: [1, 2]
|
|
641
|
+
|
|
642
|
+
figma-comments:
|
|
643
|
+
enabled: true
|
|
644
|
+
file_keys:
|
|
645
|
+
- abc123
|
|
646
|
+
- def456
|
|
647
|
+
|
|
648
|
+
google-calendar:
|
|
649
|
+
enabled: false
|
|
650
|
+
calendar_id: primary
|
|
651
|
+
|
|
652
|
+
# Sync settings
|
|
653
|
+
sync:
|
|
654
|
+
auto_sync: true
|
|
655
|
+
interval: 5m
|
|
656
|
+
on_file_change: true
|
|
657
|
+
conflict_strategy: crdt_merge # crdt_merge | local_wins | remote_wins | prompt
|
|
658
|
+
|
|
659
|
+
# File settings
|
|
660
|
+
files:
|
|
661
|
+
format: markdown # markdown | yaml | json
|
|
662
|
+
naming: "{number}-{title}"
|
|
663
|
+
max_title_length: 50
|
|
664
|
+
|
|
665
|
+
# Graph settings
|
|
666
|
+
graph:
|
|
667
|
+
auto_rebuild: true
|
|
668
|
+
include_references: true
|
|
669
|
+
max_depth: 5
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## CLI Commands
|
|
675
|
+
|
|
676
|
+
```bash
|
|
677
|
+
# Initialize hardcopy in current directory
|
|
678
|
+
hardcopy init
|
|
679
|
+
|
|
680
|
+
# Configure an adapter
|
|
681
|
+
hardcopy config github-issues --owner myorg --repo myrepo
|
|
682
|
+
|
|
683
|
+
# Full sync
|
|
684
|
+
hardcopy sync
|
|
685
|
+
hardcopy sync github-issues
|
|
686
|
+
|
|
687
|
+
# Watch for changes
|
|
688
|
+
hardcopy watch
|
|
689
|
+
|
|
690
|
+
# Query data
|
|
691
|
+
hardcopy query "state:open labels:bug"
|
|
692
|
+
hardcopy query "MATCH (i:Issue)-[:BLOCKS]->(b:Issue) RETURN i, b"
|
|
693
|
+
|
|
694
|
+
# Show sync status
|
|
695
|
+
hardcopy status
|
|
696
|
+
hardcopy status github-issues
|
|
697
|
+
|
|
698
|
+
# Show dependency graph
|
|
699
|
+
hardcopy deps 42
|
|
700
|
+
|
|
701
|
+
# Export graph
|
|
702
|
+
hardcopy export graph.json
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
---
|
|
706
|
+
|
|
707
|
+
## Event Flow
|
|
708
|
+
|
|
709
|
+
```
|
|
710
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
711
|
+
│ Remote │────▶│ Adapter │────▶│ CRDT │────▶│ File │
|
|
712
|
+
│ API │ │ │ │ Merge │ │ Write │
|
|
713
|
+
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
714
|
+
│
|
|
715
|
+
▼
|
|
716
|
+
┌──────────┐
|
|
717
|
+
│ Graph │
|
|
718
|
+
│ Update │
|
|
719
|
+
└──────────┘
|
|
720
|
+
|
|
721
|
+
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
|
|
722
|
+
│ File │────▶│ Parser │────▶│ CRDT │────▶│ Adapter │
|
|
723
|
+
│ Change │ │ │ │ Update │ │ Push │
|
|
724
|
+
└──────────┘ └──────────┘ └──────────┘ └──────────┘
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
## Extension Points
|
|
730
|
+
|
|
731
|
+
1. **Custom Adapters**: Implement `SourceAdapter` interface for new data sources
|
|
732
|
+
2. **Custom File Formats**: Override `itemToFile`/`fileToItem` methods
|
|
733
|
+
3. **Custom Graph Nodes**: Add domain-specific node/edge types
|
|
734
|
+
4. **Custom Views**: Define YAML view configurations
|
|
735
|
+
5. **Webhooks**: Implement real-time sync via webhooks
|
|
736
|
+
6. **Transformers**: Add pre/post processing hooks
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
## Next Steps
|
|
741
|
+
|
|
742
|
+
1. Implement core `SyncEngine` with GitHub Issues adapter
|
|
743
|
+
2. Add file watcher for local change detection
|
|
744
|
+
3. Build graph query interface
|
|
745
|
+
4. Create VS Code extension for visual board views
|
|
746
|
+
5. Add Figma and Google Calendar adapters
|
|
747
|
+
6. Implement webhook support for real-time sync
|