@rgby/collab-core 1.0.1
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 +60 -0
- package/dist/collab-provider.d.ts +561 -0
- package/dist/collab-provider.d.ts.map +1 -0
- package/dist/collab-provider.js +1389 -0
- package/dist/collab-provider.js.map +1 -0
- package/dist/extensions/collaboration-cursor.d.ts +87 -0
- package/dist/extensions/collaboration-cursor.d.ts.map +1 -0
- package/dist/extensions/collaboration-cursor.js +82 -0
- package/dist/extensions/collaboration-cursor.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/services/sw-manager.d.ts +62 -0
- package/dist/services/sw-manager.d.ts.map +1 -0
- package/dist/services/sw-manager.js +207 -0
- package/dist/services/sw-manager.js.map +1 -0
- package/dist/test-deps.d.ts +15 -0
- package/dist/test-deps.d.ts.map +1 -0
- package/dist/test-deps.js +21 -0
- package/dist/test-deps.js.map +1 -0
- package/dist/upload-queue.d.ts +82 -0
- package/dist/upload-queue.d.ts.map +1 -0
- package/dist/upload-queue.js +273 -0
- package/dist/upload-queue.js.map +1 -0
- package/dist/useCollab.d.ts +216 -0
- package/dist/useCollab.d.ts.map +1 -0
- package/dist/useCollab.js +583 -0
- package/dist/useCollab.js.map +1 -0
- package/package.json +93 -0
- package/src/collab-provider.ts +1871 -0
- package/src/extensions/collaboration-cursor.ts +188 -0
- package/src/index.ts +6 -0
- package/src/services/sw-manager.ts +255 -0
- package/src/test-deps.ts +34 -0
- package/src/upload-queue.ts +337 -0
|
@@ -0,0 +1,1871 @@
|
|
|
1
|
+
|
|
2
|
+
import { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider";
|
|
3
|
+
import * as Yjs from "yjs";
|
|
4
|
+
import { IndexeddbPersistence } from "y-indexeddb";
|
|
5
|
+
import { UploadQueueManager, type PendingUpload } from "./upload-queue.js";
|
|
6
|
+
|
|
7
|
+
// Re-export Yjs as Y for compatibility
|
|
8
|
+
export const Y = Yjs;
|
|
9
|
+
|
|
10
|
+
type HocuspocusProviderConfiguration = ConstructorParameters<typeof HocuspocusProvider>[0];
|
|
11
|
+
type HocuspocusProviderWebsocketConfiguration = ConstructorParameters<typeof HocuspocusProviderWebsocket>[0];
|
|
12
|
+
|
|
13
|
+
// ----------------------------------------------------------------------
|
|
14
|
+
// Interfaces & Types
|
|
15
|
+
// ----------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface AuthClient {
|
|
18
|
+
getSession: () => Promise<{ session: { token: string } | null } | null>;
|
|
19
|
+
subscribe: (callback: (session: { session: { token: string } | null } | null) => void) => () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ----------------------------------------------------------------------
|
|
23
|
+
// Storage Abstraction
|
|
24
|
+
// ----------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface StorageAdapter {
|
|
27
|
+
getItem(key: string): Promise<string | null> | string | null;
|
|
28
|
+
setItem(key: string, value: string): Promise<void> | void;
|
|
29
|
+
removeItem(key: string): Promise<void> | void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class LocalStorageAdapter implements StorageAdapter {
|
|
33
|
+
getItem(key: string): string | null {
|
|
34
|
+
try {
|
|
35
|
+
return localStorage.getItem(key);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
setItem(key: string, value: string): void {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(key, value);
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore errors (quota exceeded, etc.)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
removeItem(key: string): void {
|
|
50
|
+
try {
|
|
51
|
+
localStorage.removeItem(key);
|
|
52
|
+
} catch {
|
|
53
|
+
// Ignore errors
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SessionStorageAdapter implements StorageAdapter {
|
|
59
|
+
getItem(key: string): string | null {
|
|
60
|
+
try {
|
|
61
|
+
return sessionStorage.getItem(key);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setItem(key: string, value: string): void {
|
|
68
|
+
try {
|
|
69
|
+
sessionStorage.setItem(key, value);
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
removeItem(key: string): void {
|
|
76
|
+
try {
|
|
77
|
+
sessionStorage.removeItem(key);
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore errors
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class MemoryStorageAdapter implements StorageAdapter {
|
|
85
|
+
private store: Map<string, string> = new Map();
|
|
86
|
+
|
|
87
|
+
getItem(key: string): string | null {
|
|
88
|
+
return this.store.get(key) ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setItem(key: string, value: string): void {
|
|
92
|
+
this.store.set(key, value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
removeItem(key: string): void {
|
|
96
|
+
this.store.delete(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ----------------------------------------------------------------------
|
|
101
|
+
// AuthManager
|
|
102
|
+
// ----------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export interface SessionData {
|
|
105
|
+
token: string;
|
|
106
|
+
user: { id: string; name: string; email: string; image?: string };
|
|
107
|
+
expiresAt: number; // Unix timestamp (seconds)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface AuthManagerOptions {
|
|
111
|
+
baseUrl: string;
|
|
112
|
+
storage?: StorageAdapter;
|
|
113
|
+
storageKey?: string;
|
|
114
|
+
autoRefresh?: boolean;
|
|
115
|
+
refreshBuffer?: number; // Seconds before expiry to refresh
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class AuthManager {
|
|
119
|
+
private baseUrl: string;
|
|
120
|
+
private storage: StorageAdapter;
|
|
121
|
+
private storageKey: string;
|
|
122
|
+
private autoRefresh: boolean;
|
|
123
|
+
private refreshBuffer: number;
|
|
124
|
+
private session: SessionData | null = null;
|
|
125
|
+
private subscribers: Set<(session: SessionData | null) => void> = new Set();
|
|
126
|
+
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
127
|
+
|
|
128
|
+
constructor(options: AuthManagerOptions) {
|
|
129
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
130
|
+
this.storage = options.storage ?? new LocalStorageAdapter();
|
|
131
|
+
this.storageKey = options.storageKey ?? 'collab_session';
|
|
132
|
+
this.autoRefresh = options.autoRefresh ?? true;
|
|
133
|
+
this.refreshBuffer = options.refreshBuffer ?? 3600; // 1 hour default
|
|
134
|
+
|
|
135
|
+
// Restore session from storage
|
|
136
|
+
this.restoreSession();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async restoreSession(): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
const stored = await this.storage.getItem(this.storageKey);
|
|
142
|
+
if (stored) {
|
|
143
|
+
const session = JSON.parse(stored) as SessionData;
|
|
144
|
+
|
|
145
|
+
// Check if session is still valid
|
|
146
|
+
const now = Math.floor(Date.now() / 1000);
|
|
147
|
+
if (session.expiresAt > now) {
|
|
148
|
+
this.session = session;
|
|
149
|
+
this.scheduleRefresh();
|
|
150
|
+
this.notifySubscribers();
|
|
151
|
+
} else {
|
|
152
|
+
// Session expired, clear it
|
|
153
|
+
await this.storage.removeItem(this.storageKey);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.error('[AuthManager] Failed to restore session:', e);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private async persistSession(session: SessionData | null): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
if (session) {
|
|
164
|
+
await this.storage.setItem(this.storageKey, JSON.stringify(session));
|
|
165
|
+
} else {
|
|
166
|
+
await this.storage.removeItem(this.storageKey);
|
|
167
|
+
}
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error('[AuthManager] Failed to persist session:', e);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private scheduleRefresh(): void {
|
|
174
|
+
if (!this.autoRefresh || !this.session) return;
|
|
175
|
+
|
|
176
|
+
// Clear existing timer
|
|
177
|
+
if (this.refreshTimer) {
|
|
178
|
+
clearTimeout(this.refreshTimer);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const now = Math.floor(Date.now() / 1000);
|
|
182
|
+
const timeUntilRefresh = (this.session.expiresAt - this.refreshBuffer) - now;
|
|
183
|
+
|
|
184
|
+
if (timeUntilRefresh > 0) {
|
|
185
|
+
this.refreshTimer = setTimeout(() => {
|
|
186
|
+
this.refreshSession();
|
|
187
|
+
}, timeUntilRefresh * 1000);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async refreshSession(): Promise<void> {
|
|
192
|
+
if (!this.session) return;
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const res = await fetch(`${this.baseUrl}/api/auth/session`, {
|
|
196
|
+
headers: {
|
|
197
|
+
'Authorization': `Bearer ${this.session.token}`
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (res.ok) {
|
|
202
|
+
const data = await res.json();
|
|
203
|
+
if (data.session?.token && data.user) {
|
|
204
|
+
const newSession: SessionData = {
|
|
205
|
+
token: data.session.token,
|
|
206
|
+
user: {
|
|
207
|
+
id: data.user.id,
|
|
208
|
+
name: data.user.name,
|
|
209
|
+
email: data.user.email,
|
|
210
|
+
image: data.user.image
|
|
211
|
+
},
|
|
212
|
+
expiresAt: data.session.expiresAt
|
|
213
|
+
};
|
|
214
|
+
this.session = newSession;
|
|
215
|
+
await this.persistSession(newSession);
|
|
216
|
+
this.scheduleRefresh();
|
|
217
|
+
this.notifySubscribers();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
console.error('[AuthManager] Failed to refresh session:', e);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private notifySubscribers(): void {
|
|
226
|
+
this.subscribers.forEach(callback => callback(this.session));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async login(email: string, password: string): Promise<SessionData> {
|
|
230
|
+
const res = await fetch(`${this.baseUrl}/api/auth/sign-in/email`, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: {
|
|
233
|
+
'Content-Type': 'application/json',
|
|
234
|
+
'Origin': this.baseUrl
|
|
235
|
+
},
|
|
236
|
+
body: JSON.stringify({ email, password })
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
const error = await res.json().catch(() => ({ message: res.statusText }));
|
|
241
|
+
throw new Error(error.message || 'Login failed');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const data = await res.json();
|
|
245
|
+
|
|
246
|
+
// Better Auth can return token in different formats
|
|
247
|
+
const token = data.token || data.session?.token;
|
|
248
|
+
const expiresAt = data.session?.expiresAt || data.expiresAt;
|
|
249
|
+
|
|
250
|
+
if (!token || !data.user) {
|
|
251
|
+
throw new Error('Invalid response from server');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const session: SessionData = {
|
|
255
|
+
token,
|
|
256
|
+
user: {
|
|
257
|
+
id: data.user.id,
|
|
258
|
+
name: data.user.name,
|
|
259
|
+
email: data.user.email,
|
|
260
|
+
image: data.user.image
|
|
261
|
+
},
|
|
262
|
+
expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this.session = session;
|
|
266
|
+
await this.persistSession(session);
|
|
267
|
+
this.scheduleRefresh();
|
|
268
|
+
this.notifySubscribers();
|
|
269
|
+
|
|
270
|
+
return session;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async signup(email: string, password: string, name: string): Promise<SessionData> {
|
|
274
|
+
const res = await fetch(`${this.baseUrl}/api/auth/sign-up/email`, {
|
|
275
|
+
method: 'POST',
|
|
276
|
+
headers: {
|
|
277
|
+
'Content-Type': 'application/json',
|
|
278
|
+
'Origin': this.baseUrl
|
|
279
|
+
},
|
|
280
|
+
body: JSON.stringify({ email, password, name })
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (!res.ok) {
|
|
284
|
+
const error = await res.json().catch(() => ({ message: res.statusText }));
|
|
285
|
+
throw new Error(error.message || 'Signup failed');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const data = await res.json();
|
|
289
|
+
|
|
290
|
+
// Better Auth can return token in different formats
|
|
291
|
+
const token = data.token || data.session?.token;
|
|
292
|
+
const expiresAt = data.session?.expiresAt || data.expiresAt;
|
|
293
|
+
|
|
294
|
+
if (!token || !data.user) {
|
|
295
|
+
throw new Error('Invalid response from server');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const session: SessionData = {
|
|
299
|
+
token,
|
|
300
|
+
user: {
|
|
301
|
+
id: data.user.id,
|
|
302
|
+
name: data.user.name,
|
|
303
|
+
email: data.user.email,
|
|
304
|
+
image: data.user.image
|
|
305
|
+
},
|
|
306
|
+
expiresAt: expiresAt || (Math.floor(Date.now() / 1000) + 7200) // 2 hours default
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
this.session = session;
|
|
310
|
+
await this.persistSession(session);
|
|
311
|
+
this.scheduleRefresh();
|
|
312
|
+
this.notifySubscribers();
|
|
313
|
+
|
|
314
|
+
return session;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async logout(): Promise<void> {
|
|
318
|
+
if (!this.session) return;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
await fetch(`${this.baseUrl}/api/auth/sign-out`, {
|
|
322
|
+
method: 'POST',
|
|
323
|
+
headers: {
|
|
324
|
+
'Authorization': `Bearer ${this.session.token}`
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} catch (e) {
|
|
328
|
+
console.error('[AuthManager] Logout request failed:', e);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
this.session = null;
|
|
332
|
+
await this.persistSession(null);
|
|
333
|
+
|
|
334
|
+
if (this.refreshTimer) {
|
|
335
|
+
clearTimeout(this.refreshTimer);
|
|
336
|
+
this.refreshTimer = null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
this.notifySubscribers();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getSession(): SessionData | null {
|
|
343
|
+
return this.session;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
getToken(): string | null {
|
|
347
|
+
return this.session?.token ?? null;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
subscribe(callback: (session: SessionData | null) => void): () => void {
|
|
351
|
+
this.subscribers.add(callback);
|
|
352
|
+
return () => this.subscribers.delete(callback);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// AuthClient compatibility methods
|
|
356
|
+
async getSessionCompat(): Promise<{ session: { token: string } | null } | null> {
|
|
357
|
+
if (!this.session) return null;
|
|
358
|
+
return {
|
|
359
|
+
session: {
|
|
360
|
+
token: this.session.token
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
subscribeCompat(callback: (session: { session: { token: string } | null } | null) => void): () => void {
|
|
366
|
+
const wrappedCallback = (session: SessionData | null) => {
|
|
367
|
+
callback(session ? { session: { token: session.token } } : null);
|
|
368
|
+
};
|
|
369
|
+
return this.subscribe(wrappedCallback);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface ManagedAuthOptions {
|
|
374
|
+
storage?: StorageAdapter;
|
|
375
|
+
autoRefresh?: boolean;
|
|
376
|
+
refreshBuffer?: number; // Seconds before expiry to refresh (default: 3600)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export interface OfflineOptions {
|
|
380
|
+
enablePersistence?: boolean;
|
|
381
|
+
enableImageCache?: boolean;
|
|
382
|
+
enableUploadQueue?: boolean;
|
|
383
|
+
persistenceDbName?: string;
|
|
384
|
+
maxCacheSize?: number; // MB
|
|
385
|
+
maxDocuments?: number;
|
|
386
|
+
autoCleanup?: boolean;
|
|
387
|
+
cleanupThreshold?: number; // 0-1, e.g., 0.9 for 90%
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export interface CollabClientOptions {
|
|
391
|
+
baseUrl: string;
|
|
392
|
+
|
|
393
|
+
// Optional: Provide custom auth implementation
|
|
394
|
+
// If not provided, uses managed auth by default
|
|
395
|
+
authClient?: AuthClient;
|
|
396
|
+
|
|
397
|
+
// Optional: Configure managed auth (only used if authClient not provided)
|
|
398
|
+
managedAuth?: ManagedAuthOptions;
|
|
399
|
+
|
|
400
|
+
// Optional: Configure offline features
|
|
401
|
+
offline?: OfflineOptions;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export interface ClientExtensionDefinition {
|
|
405
|
+
name: string;
|
|
406
|
+
options?: Record<string, any>;
|
|
407
|
+
scriptUrl?: string;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface Document {
|
|
411
|
+
id: string;
|
|
412
|
+
name: string;
|
|
413
|
+
spaceId: string;
|
|
414
|
+
parentId: string | null;
|
|
415
|
+
position: number;
|
|
416
|
+
createdBy: string;
|
|
417
|
+
createdAt?: string;
|
|
418
|
+
updatedAt?: string;
|
|
419
|
+
childrenCount?: number;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export interface DocumentTreeNode extends Document {
|
|
423
|
+
children: DocumentTreeNode[];
|
|
424
|
+
depth: number;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export interface Space {
|
|
428
|
+
id: string;
|
|
429
|
+
name: string;
|
|
430
|
+
slug: string;
|
|
431
|
+
logo?: string | null;
|
|
432
|
+
createdAt: string;
|
|
433
|
+
role: string;
|
|
434
|
+
memberCount?: number;
|
|
435
|
+
documentCount?: number;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface SpaceMember {
|
|
439
|
+
id: string;
|
|
440
|
+
userId: string;
|
|
441
|
+
role: string;
|
|
442
|
+
user: {
|
|
443
|
+
name: string;
|
|
444
|
+
email: string;
|
|
445
|
+
image?: string;
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface FileUpload {
|
|
450
|
+
id: string;
|
|
451
|
+
url: string;
|
|
452
|
+
filename: string;
|
|
453
|
+
mimeType: string;
|
|
454
|
+
size: number;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export interface DocumentSnapshot {
|
|
458
|
+
id: string;
|
|
459
|
+
createdAt: string;
|
|
460
|
+
createdBy: string;
|
|
461
|
+
size: number;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface PaginationInfo {
|
|
465
|
+
total: number;
|
|
466
|
+
limit: number;
|
|
467
|
+
offset: number;
|
|
468
|
+
hasMore: boolean;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export interface CommentAnchor {
|
|
472
|
+
from: number;
|
|
473
|
+
to: number;
|
|
474
|
+
text: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface Comment {
|
|
478
|
+
id: string;
|
|
479
|
+
documentId: string;
|
|
480
|
+
spaceId: string;
|
|
481
|
+
parentId: string | null;
|
|
482
|
+
authorId: string;
|
|
483
|
+
authorName: string;
|
|
484
|
+
content: string;
|
|
485
|
+
anchor: CommentAnchor | null;
|
|
486
|
+
resolved: boolean;
|
|
487
|
+
resolvedBy: string | null;
|
|
488
|
+
resolvedAt: string | null;
|
|
489
|
+
createdAt: string;
|
|
490
|
+
updatedAt: string;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface CommentThread extends Comment {
|
|
494
|
+
replies: Comment[];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface CommentEvent {
|
|
498
|
+
type: "comment" | "comment_error";
|
|
499
|
+
action: string;
|
|
500
|
+
data?: any;
|
|
501
|
+
error?: string;
|
|
502
|
+
userId: string;
|
|
503
|
+
timestamp?: number;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ----------------------------------------------------------------------
|
|
507
|
+
// PersistenceManager (IndexedDB Persistence)
|
|
508
|
+
// ----------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
export interface PersistenceManagerOptions {
|
|
511
|
+
documentName: string;
|
|
512
|
+
document: Yjs.Doc;
|
|
513
|
+
enablePersistence?: boolean;
|
|
514
|
+
persistenceDbName?: string;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export class PersistenceManager {
|
|
518
|
+
private persistence: IndexeddbPersistence | null = null;
|
|
519
|
+
private isReady: boolean = false;
|
|
520
|
+
private readyPromise: Promise<void>;
|
|
521
|
+
private listeners: Set<() => void> = new Set();
|
|
522
|
+
|
|
523
|
+
constructor(options: PersistenceManagerOptions) {
|
|
524
|
+
const { documentName, document, enablePersistence = true, persistenceDbName = 'y-collab' } = options;
|
|
525
|
+
|
|
526
|
+
if (!enablePersistence || typeof indexedDB === 'undefined') {
|
|
527
|
+
this.readyPromise = Promise.resolve();
|
|
528
|
+
this.isReady = true;
|
|
529
|
+
console.log('[PersistenceManager] Persistence disabled or IndexedDB unavailable');
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Check for private browsing mode
|
|
534
|
+
try {
|
|
535
|
+
const test = indexedDB.open('test');
|
|
536
|
+
test.onerror = () => {
|
|
537
|
+
console.warn('[PersistenceManager] IndexedDB not available (private browsing?)');
|
|
538
|
+
this.readyPromise = Promise.resolve();
|
|
539
|
+
this.isReady = true;
|
|
540
|
+
};
|
|
541
|
+
} catch (e) {
|
|
542
|
+
console.warn('[PersistenceManager] IndexedDB check failed:', e);
|
|
543
|
+
this.readyPromise = Promise.resolve();
|
|
544
|
+
this.isReady = true;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
console.log(`[PersistenceManager] Initializing for ${documentName} in database ${persistenceDbName}`);
|
|
549
|
+
|
|
550
|
+
this.persistence = new IndexeddbPersistence(documentName, document);
|
|
551
|
+
|
|
552
|
+
this.readyPromise = new Promise<void>((resolve) => {
|
|
553
|
+
if (!this.persistence) {
|
|
554
|
+
resolve();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
this.persistence.on('synced', () => {
|
|
559
|
+
console.log(`[PersistenceManager] Synced from IndexedDB for ${documentName}`);
|
|
560
|
+
this.isReady = true;
|
|
561
|
+
this.notifyListeners();
|
|
562
|
+
resolve();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
public waitForReady(): Promise<void> {
|
|
568
|
+
return this.readyPromise;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
public isReadySync(): boolean {
|
|
572
|
+
return this.isReady;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
public onReady(callback: () => void): () => void {
|
|
576
|
+
this.listeners.add(callback);
|
|
577
|
+
if (this.isReady) {
|
|
578
|
+
callback();
|
|
579
|
+
}
|
|
580
|
+
return () => this.listeners.delete(callback);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private notifyListeners(): void {
|
|
584
|
+
this.listeners.forEach(callback => callback());
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
public async clearData(): Promise<void> {
|
|
588
|
+
if (this.persistence) {
|
|
589
|
+
try {
|
|
590
|
+
await this.persistence.clearData();
|
|
591
|
+
console.log('[PersistenceManager] Cleared local data');
|
|
592
|
+
} catch (e) {
|
|
593
|
+
console.error('[PersistenceManager] Failed to clear data:', e);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
public destroy(): void {
|
|
599
|
+
if (this.persistence) {
|
|
600
|
+
this.persistence.destroy();
|
|
601
|
+
this.persistence = null;
|
|
602
|
+
}
|
|
603
|
+
this.listeners.clear();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ----------------------------------------------------------------------
|
|
608
|
+
// CollabProviderWebsocket (Shared Connection)
|
|
609
|
+
// ----------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
export interface CollabProviderWebsocketOptions extends Omit<HocuspocusProviderWebsocketConfiguration, "url"> {
|
|
612
|
+
baseUrl: string;
|
|
613
|
+
spaceId: string;
|
|
614
|
+
auth?: AuthClient; // Optional for standalone usage, but Client usually provides it
|
|
615
|
+
token?: string;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export class CollabProviderWebsocket extends HocuspocusProviderWebsocket {
|
|
619
|
+
private _authUnsubscribe: (() => void) | null = null;
|
|
620
|
+
readonly spaceId: string;
|
|
621
|
+
private _token: string | undefined;
|
|
622
|
+
private _readyPromise: Promise<void>;
|
|
623
|
+
private _readyResolve!: () => void;
|
|
624
|
+
private _spaceDoc: any; // Y.Doc for the space itself
|
|
625
|
+
|
|
626
|
+
constructor(options: CollabProviderWebsocketOptions) {
|
|
627
|
+
const { baseUrl, spaceId, auth, token, ...config } = options;
|
|
628
|
+
|
|
629
|
+
// Connect to the collaboration endpoint with spaceId for routing
|
|
630
|
+
// Matches server route /collaboration/:documentName
|
|
631
|
+
const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/space:${spaceId}`;
|
|
632
|
+
console.log(`[CollabProviderWebsocket] Creating with URL: ${url}, token: ${token ? 'present' : 'missing'}`);
|
|
633
|
+
|
|
634
|
+
// HocuspocusProviderWebsocket is just a WebSocket wrapper
|
|
635
|
+
// Individual HocuspocusProvider instances will attach and send sync messages
|
|
636
|
+
super({
|
|
637
|
+
url: (token ? `${url}?token=${token}` : url),
|
|
638
|
+
...config,
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
this._spaceDoc = null; // No space doc needed for the WebSocket wrapper
|
|
642
|
+
|
|
643
|
+
this._token = token;
|
|
644
|
+
this.spaceId = spaceId;
|
|
645
|
+
|
|
646
|
+
// Set up ready promise
|
|
647
|
+
this._readyPromise = new Promise<void>((resolve) => {
|
|
648
|
+
this._readyResolve = resolve;
|
|
649
|
+
|
|
650
|
+
// Also resolve immediately if already connected
|
|
651
|
+
if ((this as any).status === 'connected') {
|
|
652
|
+
resolve();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
// Listen for connection status
|
|
657
|
+
this.on('status', ({ status }: any) => {
|
|
658
|
+
console.log(`[CollabProviderWebsocket] Status changed to: ${status}`);
|
|
659
|
+
if (status === 'connected' && this._readyResolve) {
|
|
660
|
+
this._readyResolve();
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Debug: Listen to all events
|
|
665
|
+
this.on('synced', ({ state }: any) => {
|
|
666
|
+
console.log(`[CollabProviderWebsocket] Synced state: ${state}`);
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// Message event listener removed - was causing blocking due to JSON.stringify
|
|
670
|
+
|
|
671
|
+
this.on('authenticationFailed', ({ reason }: any) => {
|
|
672
|
+
console.error(`[CollabProviderWebsocket] Authentication failed: ${reason}`);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// Awareness update listener removed - was causing blocking
|
|
676
|
+
|
|
677
|
+
if (auth && !token) {
|
|
678
|
+
// Only setup async auth if we don't have a token yet
|
|
679
|
+
console.log('[CollabProviderWebsocket] No token, setting up auth');
|
|
680
|
+
this.setupAuth(auth);
|
|
681
|
+
} else if (auth) {
|
|
682
|
+
// Setup auth subscription for token refresh, but don't fetch initially
|
|
683
|
+
console.log('[CollabProviderWebsocket] Token provided, setting up auth subscription only');
|
|
684
|
+
this._authUnsubscribe = auth.subscribe(async (session: any) => {
|
|
685
|
+
const newToken = session?.session?.token;
|
|
686
|
+
if (newToken && newToken !== this._token) {
|
|
687
|
+
this.updateToken(newToken);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Wait for the websocket to be connected and ready
|
|
695
|
+
*/
|
|
696
|
+
public async waitForReady(): Promise<void> {
|
|
697
|
+
// If already connected, resolve immediately
|
|
698
|
+
if ((this as any).status === 'connected') {
|
|
699
|
+
return Promise.resolve();
|
|
700
|
+
}
|
|
701
|
+
return this._readyPromise;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private async setupAuth(auth: AuthClient) {
|
|
705
|
+
// Subscribe to session changes
|
|
706
|
+
this._authUnsubscribe = auth.subscribe(async (session: any) => {
|
|
707
|
+
const token = session?.session?.token;
|
|
708
|
+
console.log('[CollabProviderWebsocket] Auth subscription callback, token:', token ? 'present' : 'missing');
|
|
709
|
+
if (token && token !== this._token) {
|
|
710
|
+
this.updateToken(token);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
// Initial fetch if no token provided yet
|
|
715
|
+
if (!this._token) {
|
|
716
|
+
console.log('[CollabProviderWebsocket] Fetching initial session...');
|
|
717
|
+
const session = await auth.getSession();
|
|
718
|
+
console.log('[CollabProviderWebsocket] Session fetched, token:', session?.session?.token ? 'present' : 'missing');
|
|
719
|
+
if (session?.session?.token) {
|
|
720
|
+
this.updateToken(session.session.token);
|
|
721
|
+
// Connect now that we have the token
|
|
722
|
+
console.log('[CollabProviderWebsocket] Calling connect()');
|
|
723
|
+
this.connect();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private updateToken(newToken: string) {
|
|
729
|
+
this._token = newToken;
|
|
730
|
+
// HocuspocusProviderWebsocket doesn't expose a clean way to update query params on the fly
|
|
731
|
+
// without reconnecting. We assume the server handles auth on connect.
|
|
732
|
+
// If we want to support seamless token rotation, we might need to send a message or reconnect.
|
|
733
|
+
// For now, we update the URL generic so next reconnect works.
|
|
734
|
+
const base = this.configuration.url.toString().split('?')[0];
|
|
735
|
+
this.configuration.url = `${base}?token=${newToken}`;
|
|
736
|
+
|
|
737
|
+
// If we are currently disconnected/connecting, this helps.
|
|
738
|
+
// If connected, the socket stays open until it expires or we force it.
|
|
739
|
+
// Some setups prefer forcing reconnect on token change:
|
|
740
|
+
// this.disconnect();
|
|
741
|
+
// this.connect();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
public getToken() {
|
|
745
|
+
return this._token;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
public getSpaceDoc() {
|
|
749
|
+
return this._spaceDoc;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
destroy() {
|
|
753
|
+
if (this._authUnsubscribe) {
|
|
754
|
+
this._authUnsubscribe();
|
|
755
|
+
}
|
|
756
|
+
super.destroy();
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ----------------------------------------------------------------------
|
|
761
|
+
// CollabProvider (Single Document Provider)
|
|
762
|
+
// ----------------------------------------------------------------------
|
|
763
|
+
|
|
764
|
+
export interface CollabProviderOptions {
|
|
765
|
+
// Option A: Pass a shared socket (can be base class or our extended class)
|
|
766
|
+
websocketProvider?: HocuspocusProviderWebsocket | CollabProviderWebsocket;
|
|
767
|
+
|
|
768
|
+
// Option B: Standalone mode
|
|
769
|
+
baseUrl?: string;
|
|
770
|
+
spaceId?: string;
|
|
771
|
+
documentId: string;
|
|
772
|
+
auth?: AuthClient;
|
|
773
|
+
token?: string | null | (() => string) | (() => Promise<string>);
|
|
774
|
+
|
|
775
|
+
// Offline/Persistence options
|
|
776
|
+
enablePersistence?: boolean;
|
|
777
|
+
persistenceDbName?: string;
|
|
778
|
+
|
|
779
|
+
// Additional provider options
|
|
780
|
+
document?: any;
|
|
781
|
+
awareness?: any;
|
|
782
|
+
onConnect?: () => void;
|
|
783
|
+
onSynced?: (data: any) => void;
|
|
784
|
+
onStatus?: (data: any) => void;
|
|
785
|
+
onAuthenticated?: (data: any) => void;
|
|
786
|
+
onAuthenticationFailed?: (data: any) => void;
|
|
787
|
+
onOpen?: (data: any) => void;
|
|
788
|
+
onMessage?: (data: any) => void;
|
|
789
|
+
onOutgoingMessage?: (data: any) => void;
|
|
790
|
+
onDisconnect?: (data: any) => void;
|
|
791
|
+
onClose?: (data: any) => void;
|
|
792
|
+
onDestroy?: () => void;
|
|
793
|
+
onAwarenessUpdate?: (data: any) => void;
|
|
794
|
+
onAwarenessChange?: (data: any) => void;
|
|
795
|
+
onStateless?: (data: any) => void;
|
|
796
|
+
forceSyncInterval?: number | false;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* HocuspocusProvider wrapper that handles constructing the correct name/url
|
|
801
|
+
* based on our platform's conventions.
|
|
802
|
+
*/
|
|
803
|
+
export class CollabProvider extends HocuspocusProvider {
|
|
804
|
+
private _authUnsubscribe: (() => void) | null = null;
|
|
805
|
+
readonly documentId: string;
|
|
806
|
+
private persistenceManager: PersistenceManager | null = null;
|
|
807
|
+
|
|
808
|
+
constructor(options: CollabProviderOptions) {
|
|
809
|
+
const {
|
|
810
|
+
baseUrl,
|
|
811
|
+
spaceId,
|
|
812
|
+
documentId,
|
|
813
|
+
websocketProvider,
|
|
814
|
+
auth,
|
|
815
|
+
token,
|
|
816
|
+
enablePersistence = true,
|
|
817
|
+
persistenceDbName,
|
|
818
|
+
...providerOptions
|
|
819
|
+
} = options;
|
|
820
|
+
|
|
821
|
+
let config: HocuspocusProviderConfiguration;
|
|
822
|
+
let parentToken = token;
|
|
823
|
+
|
|
824
|
+
// Mode 1: Multiplexing via Shared Socket
|
|
825
|
+
if (websocketProvider) {
|
|
826
|
+
const finalSpaceId = spaceId || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.spaceId : spaceId);
|
|
827
|
+
const fullName = `space:${finalSpaceId}:doc:${documentId}`;
|
|
828
|
+
parentToken = parentToken || (websocketProvider instanceof CollabProviderWebsocket ? websocketProvider.getToken() : undefined);
|
|
829
|
+
|
|
830
|
+
console.log(`[CollabProvider] Creating in multiplexing mode`);
|
|
831
|
+
console.log(`[CollabProvider] Document name: ${fullName}`);
|
|
832
|
+
console.log(`[CollabProvider] WebsocketProvider status:`, (websocketProvider as any).status);
|
|
833
|
+
console.log(`[CollabProvider] WebsocketProvider URL:`, (websocketProvider as any).configuration?.url);
|
|
834
|
+
console.log(`[CollabProvider] Options passed:`, providerOptions);
|
|
835
|
+
|
|
836
|
+
config = {
|
|
837
|
+
name: fullName,
|
|
838
|
+
websocketProvider: websocketProvider as any,
|
|
839
|
+
token: parentToken,
|
|
840
|
+
...providerOptions
|
|
841
|
+
} as any;
|
|
842
|
+
|
|
843
|
+
console.log(`[CollabProvider] Final config:`, config);
|
|
844
|
+
}
|
|
845
|
+
// Mode 2: Standalone Connection
|
|
846
|
+
else if (baseUrl && spaceId) {
|
|
847
|
+
const fullName = `space:${spaceId}:doc:${documentId}`;
|
|
848
|
+
const url = `${baseUrl.replace(/^http/, 'ws')}/collaboration/${fullName}`;
|
|
849
|
+
|
|
850
|
+
console.log(`[CollabProvider] Creating in standalone mode`);
|
|
851
|
+
console.log(`[CollabProvider] URL: ${url}`);
|
|
852
|
+
console.log(`[CollabProvider] Document name: ${fullName}`);
|
|
853
|
+
|
|
854
|
+
config = {
|
|
855
|
+
url,
|
|
856
|
+
name: fullName,
|
|
857
|
+
token,
|
|
858
|
+
...providerOptions
|
|
859
|
+
};
|
|
860
|
+
} else {
|
|
861
|
+
throw new Error("CollabProvider requires either 'websocketProvider' or 'baseUrl' + 'spaceId'");
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
super(config);
|
|
865
|
+
this.documentId = documentId;
|
|
866
|
+
console.log(`[CollabProvider] Created, status:`, (this as any).status);
|
|
867
|
+
console.log(`[CollabProvider] Awareness instance present:`, !!this.awareness);
|
|
868
|
+
console.log(`[CollabProvider] Awareness check:`, this.awareness);
|
|
869
|
+
|
|
870
|
+
// Initialize persistence
|
|
871
|
+
const fullName = config.name as string;
|
|
872
|
+
this.persistenceManager = new PersistenceManager({
|
|
873
|
+
documentName: fullName,
|
|
874
|
+
document: this.document,
|
|
875
|
+
enablePersistence,
|
|
876
|
+
persistenceDbName
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// When using websocketProvider (multiplexing), we must manually attach
|
|
880
|
+
// The parent constructor only calls attach() if manageSocket is true (standalone mode)
|
|
881
|
+
// The attach() method will automatically call onOpen() if the websocket is already connected
|
|
882
|
+
if (websocketProvider) {
|
|
883
|
+
console.log(`[CollabProvider] Attaching to parent websocket`);
|
|
884
|
+
console.time('[CollabProvider] attach()');
|
|
885
|
+
this.attach();
|
|
886
|
+
console.timeEnd('[CollabProvider] attach()');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Debug: Listen to events
|
|
890
|
+
this.on('status', ({ status }: any) => {
|
|
891
|
+
const timestamp = new Date().toISOString()
|
|
892
|
+
console.log(`[CollabProvider] ${timestamp} Status changed to: ${status}`, {
|
|
893
|
+
documentId: this.documentId,
|
|
894
|
+
status: status
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
this.on('synced', ({ state }: any) => {
|
|
899
|
+
const timestamp = new Date().toISOString()
|
|
900
|
+
console.log(`[CollabProvider] ${timestamp} Synced state: ${state}`, {
|
|
901
|
+
documentId: this.documentId,
|
|
902
|
+
synced: state
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
// Message event listener removed - was causing blocking
|
|
907
|
+
|
|
908
|
+
this.on('authenticationFailed', ({ reason }: any) => {
|
|
909
|
+
const timestamp = new Date().toISOString()
|
|
910
|
+
console.error(`[CollabProvider] ${timestamp} Authentication failed: ${reason}`, {
|
|
911
|
+
documentId: this.documentId,
|
|
912
|
+
reason: reason
|
|
913
|
+
});
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// Awareness logging removed - was causing blocking
|
|
917
|
+
|
|
918
|
+
// Setup auth watchers
|
|
919
|
+
if (!websocketProvider && auth) {
|
|
920
|
+
this.setupAuth(auth);
|
|
921
|
+
} else if (websocketProvider && websocketProvider instanceof CollabProviderWebsocket) {
|
|
922
|
+
// In multiplexed mode, we might want to listen to the parent socket's token changes?
|
|
923
|
+
// Actually, HocuspocusProvider reads `this.configuration.token` during authentication steps.
|
|
924
|
+
// We should ensure `this.configuration.token` is kept fresh.
|
|
925
|
+
// Currently Hocuspocus doesn't re-read it actively unless we push it.
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private async setupAuth(auth: AuthClient) {
|
|
930
|
+
this._authUnsubscribe = auth.subscribe(async (session: any) => {
|
|
931
|
+
const token = session?.session?.token;
|
|
932
|
+
if (token) {
|
|
933
|
+
this.setToken(token);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
const session = await auth.getSession();
|
|
938
|
+
if (session?.session?.token) {
|
|
939
|
+
this.setToken(session.session.token);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
public setToken(token: string) {
|
|
944
|
+
this.configuration.token = token;
|
|
945
|
+
// If connected, send auth message
|
|
946
|
+
if ((this as any).status === "connected") {
|
|
947
|
+
this.sendStateless(JSON.stringify({ type: "auth", token: token }));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
destroy() {
|
|
952
|
+
if (this._authUnsubscribe) {
|
|
953
|
+
this._authUnsubscribe();
|
|
954
|
+
}
|
|
955
|
+
if (this.persistenceManager) {
|
|
956
|
+
this.persistenceManager.destroy();
|
|
957
|
+
}
|
|
958
|
+
super.destroy();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Wait for persistence to be ready
|
|
963
|
+
*/
|
|
964
|
+
public async waitForPersistence(): Promise<void> {
|
|
965
|
+
if (this.persistenceManager) {
|
|
966
|
+
return this.persistenceManager.waitForReady();
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Check if persistence is ready
|
|
972
|
+
*/
|
|
973
|
+
public isPersistenceReady(): boolean {
|
|
974
|
+
return this.persistenceManager?.isReadySync() ?? false;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Clear local persistence data
|
|
979
|
+
*/
|
|
980
|
+
public async clearLocalData(): Promise<void> {
|
|
981
|
+
if (this.persistenceManager) {
|
|
982
|
+
return this.persistenceManager.clearData();
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* Send a comment action via stateless message
|
|
988
|
+
*/
|
|
989
|
+
public sendComment(action: "create" | "update" | "delete" | "resolve", data: any) {
|
|
990
|
+
this.sendStateless(JSON.stringify({
|
|
991
|
+
type: "comment",
|
|
992
|
+
action,
|
|
993
|
+
data
|
|
994
|
+
}));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Listen for real-time comment events
|
|
999
|
+
*/
|
|
1000
|
+
public onComment(callback: (event: CommentEvent) => void): () => void {
|
|
1001
|
+
const handler = (payload: any) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const msg = JSON.parse(payload.payload);
|
|
1004
|
+
if (msg.type === "comment" || msg.type === "comment_error") {
|
|
1005
|
+
callback(msg);
|
|
1006
|
+
}
|
|
1007
|
+
} catch (e) {
|
|
1008
|
+
// Ignore parsing errors
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
this.on("stateless", handler);
|
|
1012
|
+
return () => this.off("stateless", handler);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ----------------------------------------------------------------------
|
|
1017
|
+
// CollabClient (Full SDK)
|
|
1018
|
+
// ----------------------------------------------------------------------
|
|
1019
|
+
|
|
1020
|
+
export class CollabClient {
|
|
1021
|
+
readonly baseUrl: string;
|
|
1022
|
+
readonly auth: AuthClient;
|
|
1023
|
+
private readonly authManager: AuthManager | null = null;
|
|
1024
|
+
private readonly offlineOptions: OfflineOptions;
|
|
1025
|
+
|
|
1026
|
+
// Cache for shared websockets by spaceId
|
|
1027
|
+
private _sockets: Map<string, CollabProviderWebsocket> = new Map();
|
|
1028
|
+
private _token: string | null = null;
|
|
1029
|
+
|
|
1030
|
+
// Upload queue for offline uploads
|
|
1031
|
+
private uploadQueue: UploadQueueManager | null = null;
|
|
1032
|
+
|
|
1033
|
+
constructor(options: CollabClientOptions) {
|
|
1034
|
+
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
1035
|
+
this.offlineOptions = {
|
|
1036
|
+
enablePersistence: true,
|
|
1037
|
+
enableImageCache: true,
|
|
1038
|
+
enableUploadQueue: true,
|
|
1039
|
+
persistenceDbName: 'y-collab',
|
|
1040
|
+
maxCacheSize: 50, // MB
|
|
1041
|
+
maxDocuments: 20,
|
|
1042
|
+
autoCleanup: true,
|
|
1043
|
+
cleanupThreshold: 0.9,
|
|
1044
|
+
...options.offline
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
if (options.authClient) {
|
|
1048
|
+
// Custom auth implementation
|
|
1049
|
+
this.auth = options.authClient;
|
|
1050
|
+
} else {
|
|
1051
|
+
// Managed auth (default)
|
|
1052
|
+
this.authManager = new AuthManager({
|
|
1053
|
+
baseUrl: this.baseUrl,
|
|
1054
|
+
storage: options.managedAuth?.storage,
|
|
1055
|
+
autoRefresh: options.managedAuth?.autoRefresh,
|
|
1056
|
+
refreshBuffer: options.managedAuth?.refreshBuffer
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
this.auth = {
|
|
1060
|
+
getSession: () => this.authManager!.getSessionCompat(),
|
|
1061
|
+
subscribe: (callback) => this.authManager!.subscribeCompat(callback)
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
this.auth.subscribe((session) => {
|
|
1066
|
+
this._token = session?.session?.token || null;
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
this.auth.getSession().then(s => {
|
|
1070
|
+
this._token = s?.session?.token || null;
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// Initialize upload queue
|
|
1074
|
+
if (this.offlineOptions.enableUploadQueue && typeof indexedDB !== 'undefined') {
|
|
1075
|
+
this.uploadQueue = new UploadQueueManager();
|
|
1076
|
+
this.uploadQueue.init().catch((error: any) => {
|
|
1077
|
+
console.error('[CollabClient] Failed to initialize upload queue:', error);
|
|
1078
|
+
this.uploadQueue = null;
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// Process queue when client is created and online
|
|
1082
|
+
if (navigator.onLine) {
|
|
1083
|
+
setTimeout(() => {
|
|
1084
|
+
this.processUploadQueue().catch(error => {
|
|
1085
|
+
console.error('[CollabClient] Failed to process upload queue:', error);
|
|
1086
|
+
});
|
|
1087
|
+
}, 1000);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Convenience methods (only available when using managed auth)
|
|
1093
|
+
async login(email: string, password: string): Promise<SessionData> {
|
|
1094
|
+
if (!this.authManager) {
|
|
1095
|
+
throw new Error('login() is only available in managed auth mode');
|
|
1096
|
+
}
|
|
1097
|
+
return this.authManager.login(email, password);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
async signup(email: string, password: string, name: string): Promise<SessionData> {
|
|
1101
|
+
if (!this.authManager) {
|
|
1102
|
+
throw new Error('signup() is only available in managed auth mode');
|
|
1103
|
+
}
|
|
1104
|
+
return this.authManager.signup(email, password, name);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
async logout(): Promise<void> {
|
|
1108
|
+
if (!this.authManager) {
|
|
1109
|
+
throw new Error('logout() is only available in managed auth mode');
|
|
1110
|
+
}
|
|
1111
|
+
return this.authManager.logout();
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
getSession(): SessionData | null {
|
|
1115
|
+
if (!this.authManager) {
|
|
1116
|
+
throw new Error('getSession() is only available in managed auth mode');
|
|
1117
|
+
}
|
|
1118
|
+
return this.authManager.getSession();
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
private async getToken(): Promise<string> {
|
|
1122
|
+
if (this._token) return this._token;
|
|
1123
|
+
const session = await this.auth.getSession();
|
|
1124
|
+
this._token = session?.session?.token || null;
|
|
1125
|
+
if (!this._token) {
|
|
1126
|
+
throw new Error("Not authenticated");
|
|
1127
|
+
}
|
|
1128
|
+
return this._token;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
1132
|
+
const token = await this.getToken();
|
|
1133
|
+
const headers = new Headers(options.headers);
|
|
1134
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
1135
|
+
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
|
|
1136
|
+
headers.set("Content-Type", "application/json");
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const res = await fetch(`${this.baseUrl}/api${path}`, {
|
|
1140
|
+
...options,
|
|
1141
|
+
headers,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
if (!res.ok) {
|
|
1145
|
+
const error = await res.json().catch(() => ({ message: res.statusText }));
|
|
1146
|
+
throw new Error(error.message || error.error || `Request failed: ${res.status}`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Return empty for 204
|
|
1150
|
+
if (res.status === 204) return {} as T;
|
|
1151
|
+
|
|
1152
|
+
return res.json();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// --- Extensions ---
|
|
1156
|
+
|
|
1157
|
+
public async getExtensions(deps?: Record<string, any>): Promise<any[]> {
|
|
1158
|
+
// Make dependencies available for extension scripts that use bare specifiers
|
|
1159
|
+
if (deps) {
|
|
1160
|
+
(globalThis as any).__collab_deps = { ...(globalThis as any).__collab_deps, ...deps };
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const res = await this.fetch<{ extensions: ClientExtensionDefinition[] }>("/extensions/config");
|
|
1164
|
+
const defs = res.extensions || [];
|
|
1165
|
+
const loadedExtensions: any[] = [];
|
|
1166
|
+
|
|
1167
|
+
for (const def of defs) {
|
|
1168
|
+
if (def.scriptUrl) {
|
|
1169
|
+
try {
|
|
1170
|
+
const fullUrl = new URL(def.scriptUrl, this.baseUrl).toString();
|
|
1171
|
+
// Fetch script text and rewrite bare specifiers to use provided deps
|
|
1172
|
+
const response = await fetch(fullUrl);
|
|
1173
|
+
const scriptText = await response.text();
|
|
1174
|
+
|
|
1175
|
+
// Replace bare module specifiers with global deps lookup
|
|
1176
|
+
const rewritten = scriptText.replace(
|
|
1177
|
+
/import\s*\{([^}]+)\}\s*from\s*['"]([^./][^'"]*)['"]/g,
|
|
1178
|
+
(_match, imports, specifier) => {
|
|
1179
|
+
return `const {${imports}} = globalThis.__collab_deps?.['${specifier}'] ?? (() => { throw new Error('Missing dependency: ${specifier}') })()`;
|
|
1180
|
+
}
|
|
1181
|
+
);
|
|
1182
|
+
|
|
1183
|
+
const blob = new Blob([rewritten], { type: 'application/javascript' });
|
|
1184
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
1185
|
+
|
|
1186
|
+
try {
|
|
1187
|
+
// @ts-ignore
|
|
1188
|
+
const module = await import(/* @vite-ignore */ blobUrl);
|
|
1189
|
+
|
|
1190
|
+
if (module.default) {
|
|
1191
|
+
if (typeof module.default.configure === 'function') {
|
|
1192
|
+
loadedExtensions.push(module.default.configure(def.options || {}));
|
|
1193
|
+
} else {
|
|
1194
|
+
loadedExtensions.push(module.default);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
} finally {
|
|
1198
|
+
URL.revokeObjectURL(blobUrl);
|
|
1199
|
+
}
|
|
1200
|
+
} catch (e) {
|
|
1201
|
+
console.error(`[CollabClient] Failed to load extension ${def.name}`, e);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return loadedExtensions;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// --- WebSocket / Provider Factory ---
|
|
1209
|
+
|
|
1210
|
+
public async getWebsocketProvider(spaceId: string): Promise<CollabProviderWebsocket> {
|
|
1211
|
+
const cached = this._sockets.get(spaceId);
|
|
1212
|
+
|
|
1213
|
+
// Check if cached socket exists and is still valid (not destroyed)
|
|
1214
|
+
if (cached) {
|
|
1215
|
+
const status = (cached as any).status;
|
|
1216
|
+
const shouldConnect = (cached as any).shouldConnect;
|
|
1217
|
+
|
|
1218
|
+
// Only reuse if the websocket hasn't been destroyed (shouldConnect should be true for active sockets)
|
|
1219
|
+
if (shouldConnect !== false && status !== 'destroyed') {
|
|
1220
|
+
console.log(`[CollabClient] Reusing cached websocket for space: ${spaceId}`);
|
|
1221
|
+
return cached;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Remove destroyed websocket from cache
|
|
1225
|
+
console.log(`[CollabClient] Removing destroyed websocket from cache for space: ${spaceId}`);
|
|
1226
|
+
this._sockets.delete(spaceId);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
console.log(`[CollabClient] Creating new websocket for space: ${spaceId}`);
|
|
1230
|
+
|
|
1231
|
+
// Get token first before creating websocket
|
|
1232
|
+
const token = await this.getToken();
|
|
1233
|
+
console.log(`[CollabClient] Token obtained:`, token ? 'present' : 'missing');
|
|
1234
|
+
|
|
1235
|
+
const ws = new CollabProviderWebsocket({
|
|
1236
|
+
baseUrl: this.baseUrl,
|
|
1237
|
+
spaceId,
|
|
1238
|
+
auth: this.auth,
|
|
1239
|
+
token, // Pass token directly so it connects immediately
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
this._sockets.set(spaceId, ws);
|
|
1243
|
+
return ws;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Get a CollabProvider for a specific document.
|
|
1248
|
+
* Uses a shared WebSocket for the space if possible.
|
|
1249
|
+
*/
|
|
1250
|
+
public async getProvider(spaceId: string, documentId: string, options: Partial<HocuspocusProviderConfiguration> = {}): Promise<CollabProvider> {
|
|
1251
|
+
const documentName = `space:${spaceId}:doc:${documentId}`;
|
|
1252
|
+
|
|
1253
|
+
// Check if this document is already attached to the websocket
|
|
1254
|
+
const socket = await this.getWebsocketProvider(spaceId);
|
|
1255
|
+
const providerMap = (socket as any).configuration?.providerMap;
|
|
1256
|
+
|
|
1257
|
+
// CRITICAL: If the same document is already attached, create a NEW websocket
|
|
1258
|
+
// because providerMap.set() overwrites - only one provider per document name per socket
|
|
1259
|
+
if (providerMap && providerMap.has(documentName)) {
|
|
1260
|
+
console.log(`[CollabClient] Document ${documentId} already attached to this websocket - creating separate websocket for this tab`);
|
|
1261
|
+
|
|
1262
|
+
// Create a unique websocket for this tab
|
|
1263
|
+
const token = await this.getToken();
|
|
1264
|
+
const newSocket = new CollabProviderWebsocket({
|
|
1265
|
+
baseUrl: this.baseUrl,
|
|
1266
|
+
spaceId,
|
|
1267
|
+
auth: this.auth,
|
|
1268
|
+
token,
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
// Don't cache this one - it's tab-specific
|
|
1272
|
+
console.log(`[CollabClient] Created separate websocket for tab accessing doc ${documentId}`);
|
|
1273
|
+
await newSocket.waitForReady();
|
|
1274
|
+
|
|
1275
|
+
return new CollabProvider({
|
|
1276
|
+
websocketProvider: newSocket,
|
|
1277
|
+
spaceId,
|
|
1278
|
+
documentId,
|
|
1279
|
+
enablePersistence: this.offlineOptions.enablePersistence,
|
|
1280
|
+
persistenceDbName: this.offlineOptions.persistenceDbName,
|
|
1281
|
+
...options
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Normal path: document not yet attached, safe to use shared websocket
|
|
1286
|
+
console.log(`[CollabClient] Waiting for websocket to be ready before creating provider for doc ${documentId}`);
|
|
1287
|
+
await socket.waitForReady();
|
|
1288
|
+
console.log(`[CollabClient] Websocket ready, creating provider for doc ${documentId}`);
|
|
1289
|
+
|
|
1290
|
+
return new CollabProvider({
|
|
1291
|
+
websocketProvider: socket,
|
|
1292
|
+
spaceId,
|
|
1293
|
+
documentId,
|
|
1294
|
+
enablePersistence: this.offlineOptions.enablePersistence,
|
|
1295
|
+
persistenceDbName: this.offlineOptions.persistenceDbName,
|
|
1296
|
+
...options
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// --- REST API: Documents ---
|
|
1301
|
+
|
|
1302
|
+
public readonly documents = {
|
|
1303
|
+
/**
|
|
1304
|
+
* List documents in a space
|
|
1305
|
+
* @param parentId - null for root only, undefined for all, string for specific parent
|
|
1306
|
+
*/
|
|
1307
|
+
list: (spaceId: string, params?: { parentId?: string | null; limit?: number; offset?: number }) => {
|
|
1308
|
+
const searchParams = new URLSearchParams();
|
|
1309
|
+
if (params?.parentId === null) {
|
|
1310
|
+
searchParams.set('parentId', 'null');
|
|
1311
|
+
} else if (params?.parentId) {
|
|
1312
|
+
searchParams.set('parentId', params.parentId);
|
|
1313
|
+
}
|
|
1314
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1315
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1316
|
+
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
|
1317
|
+
return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
|
|
1318
|
+
`/spaces/${spaceId}/documents${qs}`
|
|
1319
|
+
).then(r => ({ items: r.data, pagination: r.pagination }));
|
|
1320
|
+
},
|
|
1321
|
+
|
|
1322
|
+
get: (spaceId: string, docId: string) =>
|
|
1323
|
+
this.fetch<{ success: true; data: Document }>(`/spaces/${spaceId}/documents/${docId}`).then(r => r.data),
|
|
1324
|
+
|
|
1325
|
+
create: (spaceId: string, options?: { name?: string; parentId?: string | null }) =>
|
|
1326
|
+
this.fetch<Document & { wsUrl: string }>(`/spaces/${spaceId}/documents`, {
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
body: JSON.stringify(options || {}),
|
|
1329
|
+
}),
|
|
1330
|
+
|
|
1331
|
+
update: (spaceId: string, docId: string, data: { name: string }) =>
|
|
1332
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}`, {
|
|
1333
|
+
method: "PATCH",
|
|
1334
|
+
body: JSON.stringify(data),
|
|
1335
|
+
}),
|
|
1336
|
+
|
|
1337
|
+
delete: (spaceId: string, docId: string) =>
|
|
1338
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}`, {
|
|
1339
|
+
method: "DELETE",
|
|
1340
|
+
}),
|
|
1341
|
+
|
|
1342
|
+
getContent: (spaceId: string, docId: string) =>
|
|
1343
|
+
this.fetch<{ id: string; spaceId: string; content: Record<string, unknown> }>(
|
|
1344
|
+
`/spaces/${spaceId}/documents/${docId}/content`
|
|
1345
|
+
).then(r => r.content),
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Update document content via patch (merge or replace strategy)
|
|
1349
|
+
*/
|
|
1350
|
+
patch: (spaceId: string, docId: string, content: any, options?: {
|
|
1351
|
+
merge?: boolean;
|
|
1352
|
+
strategy?: "replace" | "merge" | "append" | "prepend";
|
|
1353
|
+
path?: number[];
|
|
1354
|
+
}) =>
|
|
1355
|
+
this.fetch<{ success: true; document: Document }>(`/spaces/${spaceId}/documents/${docId}/content`, {
|
|
1356
|
+
method: "PATCH",
|
|
1357
|
+
body: JSON.stringify({ content, ...options }),
|
|
1358
|
+
}).then(r => r.document),
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Apply transformation operations to document
|
|
1362
|
+
*/
|
|
1363
|
+
transform: (spaceId: string, docId: string, operations: any[]) =>
|
|
1364
|
+
this.fetch<{ success: true; document: Document }>(`/spaces/${spaceId}/documents/${docId}/transform`, {
|
|
1365
|
+
method: "POST",
|
|
1366
|
+
body: JSON.stringify({ operations }),
|
|
1367
|
+
}).then(r => r.document),
|
|
1368
|
+
|
|
1369
|
+
// Hierarchy methods
|
|
1370
|
+
tree: (spaceId: string) =>
|
|
1371
|
+
this.fetch<{ success: true; data: DocumentTreeNode[] }>(`/spaces/${spaceId}/documents/tree`).then(r => r.data),
|
|
1372
|
+
|
|
1373
|
+
children: (spaceId: string, docId: string, params?: { limit?: number; offset?: number }) => {
|
|
1374
|
+
const searchParams = new URLSearchParams();
|
|
1375
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1376
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1377
|
+
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
|
1378
|
+
return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
|
|
1379
|
+
`/spaces/${spaceId}/documents/${docId}/children${qs}`
|
|
1380
|
+
).then(r => ({ items: r.data, pagination: r.pagination }));
|
|
1381
|
+
},
|
|
1382
|
+
|
|
1383
|
+
ancestors: (spaceId: string, docId: string) =>
|
|
1384
|
+
this.fetch<{ success: true; data: Document[] }>(`/spaces/${spaceId}/documents/${docId}/ancestors`).then(r => r.data),
|
|
1385
|
+
|
|
1386
|
+
move: (spaceId: string, docId: string, parentId: string | null, position?: number) =>
|
|
1387
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/move`, {
|
|
1388
|
+
method: "POST",
|
|
1389
|
+
body: JSON.stringify({ parentId, position }),
|
|
1390
|
+
}),
|
|
1391
|
+
|
|
1392
|
+
// History / Snapshots
|
|
1393
|
+
getSnapshots: (spaceId: string, docId: string) =>
|
|
1394
|
+
this.fetch<{ snapshots: DocumentSnapshot[] }>(`/spaces/${spaceId}/documents/${docId}/snapshots`).then(r => r.snapshots),
|
|
1395
|
+
|
|
1396
|
+
restoreSnapshot: (spaceId: string, docId: string, snapshotId: string) =>
|
|
1397
|
+
this.fetch<{ success: true; message: string }>(`/spaces/${spaceId}/documents/${docId}/restore/${snapshotId}`, {
|
|
1398
|
+
method: "POST",
|
|
1399
|
+
}),
|
|
1400
|
+
|
|
1401
|
+
// Maintenance / Recovery
|
|
1402
|
+
listDeleted: (spaceId: string, params?: { limit?: number; offset?: number }) => {
|
|
1403
|
+
const searchParams = new URLSearchParams();
|
|
1404
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1405
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1406
|
+
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
|
1407
|
+
return this.fetch<{ success: true; data: Document[]; pagination: PaginationInfo }>(
|
|
1408
|
+
`/spaces/${spaceId}/documents/deleted${qs}`
|
|
1409
|
+
).then(r => ({ items: r.data, pagination: r.pagination }));
|
|
1410
|
+
},
|
|
1411
|
+
|
|
1412
|
+
restore: (spaceId: string, docId: string) =>
|
|
1413
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/restore`, {
|
|
1414
|
+
method: "POST",
|
|
1415
|
+
}),
|
|
1416
|
+
|
|
1417
|
+
deletePermanent: (spaceId: string, docId: string) =>
|
|
1418
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/permanent`, {
|
|
1419
|
+
method: "DELETE",
|
|
1420
|
+
}),
|
|
1421
|
+
|
|
1422
|
+
// Export - returns raw HTML string or JSON object depending on format
|
|
1423
|
+
export: async (spaceId: string, docId: string, format: 'html' | 'json' = 'html') => {
|
|
1424
|
+
const token = await this.getToken();
|
|
1425
|
+
const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/export?format=${format}`, {
|
|
1426
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1427
|
+
});
|
|
1428
|
+
if (!res.ok) throw new Error(`Export failed: ${res.status}`);
|
|
1429
|
+
return format === 'json' ? res.json() : res.text();
|
|
1430
|
+
},
|
|
1431
|
+
};
|
|
1432
|
+
|
|
1433
|
+
// --- REST API: Spaces ---
|
|
1434
|
+
|
|
1435
|
+
public readonly spaces = {
|
|
1436
|
+
list: () =>
|
|
1437
|
+
this.fetch<{ success: true; data: Space[] }>("/spaces").then(r => r.data),
|
|
1438
|
+
|
|
1439
|
+
get: (spaceId: string) =>
|
|
1440
|
+
this.fetch<{ success: true; data: Space }>(`/spaces/${spaceId}`).then(r => r.data),
|
|
1441
|
+
|
|
1442
|
+
getMembers: (spaceId: string) =>
|
|
1443
|
+
this.fetch<{ success: true; data: SpaceMember[] }>(`/spaces/${spaceId}/members`).then(r => r.data),
|
|
1444
|
+
|
|
1445
|
+
create: (data: { name: string; slug?: string }) =>
|
|
1446
|
+
this.fetch<Space>("/organizations", {
|
|
1447
|
+
method: "POST",
|
|
1448
|
+
headers: { "Content-Type": "application/json" },
|
|
1449
|
+
body: JSON.stringify(data),
|
|
1450
|
+
}),
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Add a member to a space/organization
|
|
1454
|
+
*/
|
|
1455
|
+
addMember: (spaceId: string, email: string, role: string) =>
|
|
1456
|
+
this.fetch<{ success: true; memberId: string }>(`/organizations/${spaceId}/members`, {
|
|
1457
|
+
method: "POST",
|
|
1458
|
+
body: JSON.stringify({ email, role }),
|
|
1459
|
+
}),
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Update a member's role
|
|
1463
|
+
*/
|
|
1464
|
+
updateRole: (spaceId: string, userId: string, role: string) =>
|
|
1465
|
+
this.fetch<{ success: true }>(`/organizations/${spaceId}/members/${userId}`, {
|
|
1466
|
+
method: "PATCH",
|
|
1467
|
+
body: JSON.stringify({ role }),
|
|
1468
|
+
}),
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* Remove a member from a space/organization
|
|
1472
|
+
*/
|
|
1473
|
+
removeMember: (spaceId: string, userId: string) =>
|
|
1474
|
+
this.fetch<{ success: true }>(`/organizations/${spaceId}/members/${userId}`, {
|
|
1475
|
+
method: "DELETE",
|
|
1476
|
+
}),
|
|
1477
|
+
};
|
|
1478
|
+
|
|
1479
|
+
// --- REST API: Uploads ---
|
|
1480
|
+
|
|
1481
|
+
public readonly uploads = {
|
|
1482
|
+
list: (spaceId: string, documentId?: string, params?: { limit?: number; offset?: number }) => {
|
|
1483
|
+
const searchParams = new URLSearchParams();
|
|
1484
|
+
if (documentId) searchParams.set('documentId', documentId);
|
|
1485
|
+
if (params?.limit) searchParams.set('limit', String(params.limit));
|
|
1486
|
+
if (params?.offset) searchParams.set('offset', String(params.offset));
|
|
1487
|
+
const qs = searchParams.toString() ? `?${searchParams}` : '';
|
|
1488
|
+
return this.fetch<{ success: true; data: FileUpload[]; pagination: PaginationInfo }>(
|
|
1489
|
+
`/spaces/${spaceId}/files${qs}`
|
|
1490
|
+
).then(r => ({ items: r.data, pagination: r.pagination }));
|
|
1491
|
+
},
|
|
1492
|
+
|
|
1493
|
+
upload: async (spaceId: string, file: File, documentId?: string) => {
|
|
1494
|
+
// Check if online
|
|
1495
|
+
if (!navigator.onLine && this.uploadQueue) {
|
|
1496
|
+
console.log('[CollabClient] Offline - queuing upload:', file.name);
|
|
1497
|
+
|
|
1498
|
+
// Add to queue and return temp data
|
|
1499
|
+
const pendingUpload = await this.uploadQueue.add({
|
|
1500
|
+
spaceId,
|
|
1501
|
+
documentId: documentId || null,
|
|
1502
|
+
file,
|
|
1503
|
+
filename: file.name,
|
|
1504
|
+
mimeType: file.type
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
// Return temporary file info that looks like a FileUpload
|
|
1508
|
+
return {
|
|
1509
|
+
id: pendingUpload.id,
|
|
1510
|
+
url: pendingUpload.tempUrl,
|
|
1511
|
+
filename: pendingUpload.filename,
|
|
1512
|
+
mimeType: pendingUpload.mimeType,
|
|
1513
|
+
size: file.size
|
|
1514
|
+
} as FileUpload;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Online - upload directly
|
|
1518
|
+
const formData = new FormData();
|
|
1519
|
+
formData.append("file", file);
|
|
1520
|
+
const qs = documentId ? `?documentId=${documentId}` : "";
|
|
1521
|
+
return this.fetch<{ success: true; data: FileUpload }>(`/spaces/${spaceId}/upload${qs}`, {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
body: formData,
|
|
1524
|
+
}).then(r => r.data);
|
|
1525
|
+
},
|
|
1526
|
+
|
|
1527
|
+
delete: (spaceId: string, fileId: string) =>
|
|
1528
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/files/${fileId}`, {
|
|
1529
|
+
method: "DELETE",
|
|
1530
|
+
}),
|
|
1531
|
+
|
|
1532
|
+
deletePermanent: (spaceId: string, fileId: string) =>
|
|
1533
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/files/${fileId}/permanent`, {
|
|
1534
|
+
method: "DELETE",
|
|
1535
|
+
}),
|
|
1536
|
+
};
|
|
1537
|
+
|
|
1538
|
+
// --- REST API: Comments ---
|
|
1539
|
+
|
|
1540
|
+
public readonly comments = {
|
|
1541
|
+
/**
|
|
1542
|
+
* List comments for a document (threaded)
|
|
1543
|
+
*/
|
|
1544
|
+
list: (spaceId: string, docId: string) =>
|
|
1545
|
+
this.fetch<{ success: true; data: CommentThread[]; meta: { total: number; resolved: number } }>(
|
|
1546
|
+
`/spaces/${spaceId}/documents/${docId}/comments`
|
|
1547
|
+
).then(r => ({ threads: r.data, meta: r.meta })),
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Get a single comment
|
|
1551
|
+
*/
|
|
1552
|
+
get: (spaceId: string, docId: string, commentId: string) =>
|
|
1553
|
+
this.fetch<{ success: true; data: Comment }>(
|
|
1554
|
+
`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`
|
|
1555
|
+
).then(r => r.data),
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* Create a new comment
|
|
1559
|
+
*/
|
|
1560
|
+
create: (spaceId: string, docId: string, data: { content: string; anchor?: CommentAnchor; parentId?: string }) =>
|
|
1561
|
+
this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments`, {
|
|
1562
|
+
method: "POST",
|
|
1563
|
+
body: JSON.stringify(data),
|
|
1564
|
+
}).then(r => r.data),
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Update a comment's content
|
|
1568
|
+
*/
|
|
1569
|
+
update: (spaceId: string, docId: string, commentId: string, content: string) =>
|
|
1570
|
+
this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
|
|
1571
|
+
method: "PATCH",
|
|
1572
|
+
body: JSON.stringify({ content }),
|
|
1573
|
+
}).then(r => r.data),
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Delete a comment
|
|
1577
|
+
*/
|
|
1578
|
+
delete: (spaceId: string, docId: string, commentId: string) =>
|
|
1579
|
+
this.fetch<{ success: true }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}`, {
|
|
1580
|
+
method: "DELETE",
|
|
1581
|
+
}),
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Resolve or unresolve a comment thread
|
|
1585
|
+
*/
|
|
1586
|
+
resolve: (spaceId: string, docId: string, commentId: string, resolved: boolean) =>
|
|
1587
|
+
this.fetch<{ success: true; data: Comment }>(`/spaces/${spaceId}/documents/${docId}/comments/${commentId}/resolve`, {
|
|
1588
|
+
method: "POST",
|
|
1589
|
+
body: JSON.stringify({ resolved }),
|
|
1590
|
+
}).then(r => r.data),
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// --- REST API: Sharing ---
|
|
1594
|
+
|
|
1595
|
+
public readonly share = {
|
|
1596
|
+
/**
|
|
1597
|
+
* Get share status for a document
|
|
1598
|
+
*/
|
|
1599
|
+
get: async (spaceId: string, docId: string) => {
|
|
1600
|
+
const token = await this.getToken();
|
|
1601
|
+
const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
|
|
1602
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1603
|
+
});
|
|
1604
|
+
if (!res.ok) throw new Error("Failed to get share status");
|
|
1605
|
+
return res.json() as Promise<{ success: boolean; data: { enabled: boolean; token?: string; access?: string; url?: string } }>;
|
|
1606
|
+
},
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Enable sharing for a document
|
|
1610
|
+
*/
|
|
1611
|
+
enable: async (spaceId: string, docId: string) => {
|
|
1612
|
+
const token = await this.getToken();
|
|
1613
|
+
const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
|
|
1614
|
+
method: "POST",
|
|
1615
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1616
|
+
});
|
|
1617
|
+
if (!res.ok) throw new Error("Failed to enable sharing");
|
|
1618
|
+
return res.json() as Promise<{ success: boolean; data: { token: string; access: string; url: string } }>;
|
|
1619
|
+
},
|
|
1620
|
+
|
|
1621
|
+
/**
|
|
1622
|
+
* Disable sharing for a document
|
|
1623
|
+
*/
|
|
1624
|
+
disable: async (spaceId: string, docId: string) => {
|
|
1625
|
+
const token = await this.getToken();
|
|
1626
|
+
const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/share`, {
|
|
1627
|
+
method: "DELETE",
|
|
1628
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
1629
|
+
});
|
|
1630
|
+
if (!res.ok) throw new Error("Failed to disable sharing");
|
|
1631
|
+
return res.json() as Promise<{ success: boolean }>;
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* Process the upload queue
|
|
1637
|
+
*/
|
|
1638
|
+
private async processUploadQueue(): Promise<void> {
|
|
1639
|
+
if (!this.uploadQueue) return;
|
|
1640
|
+
|
|
1641
|
+
await this.uploadQueue.processQueue(async (upload: PendingUpload) => {
|
|
1642
|
+
console.log('[CollabClient] Processing queued upload:', upload.filename);
|
|
1643
|
+
|
|
1644
|
+
// Re-create File from Blob
|
|
1645
|
+
const file = new File([upload.file], upload.filename, { type: upload.mimeType });
|
|
1646
|
+
|
|
1647
|
+
// Upload to server
|
|
1648
|
+
const formData = new FormData();
|
|
1649
|
+
formData.append("file", file);
|
|
1650
|
+
const qs = upload.documentId ? `?documentId=${upload.documentId}` : "";
|
|
1651
|
+
|
|
1652
|
+
const result = await this.fetch<{ success: true; data: FileUpload }>(
|
|
1653
|
+
`/spaces/${upload.spaceId}/upload${qs}`,
|
|
1654
|
+
{
|
|
1655
|
+
method: "POST",
|
|
1656
|
+
body: formData,
|
|
1657
|
+
}
|
|
1658
|
+
);
|
|
1659
|
+
|
|
1660
|
+
return result.data;
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/**
|
|
1665
|
+
* Get pending uploads count
|
|
1666
|
+
*/
|
|
1667
|
+
public async getPendingUploadsCount(): Promise<number> {
|
|
1668
|
+
if (!this.uploadQueue) return 0;
|
|
1669
|
+
return this.uploadQueue.count();
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* Get all pending uploads
|
|
1674
|
+
*/
|
|
1675
|
+
public async getPendingUploads(): Promise<PendingUpload[]> {
|
|
1676
|
+
if (!this.uploadQueue) return [];
|
|
1677
|
+
return this.uploadQueue.getAll();
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Subscribe to upload queue events
|
|
1682
|
+
*/
|
|
1683
|
+
public onUploadQueue(event: 'queue-changed' | 'upload-complete' | 'upload-failed', callback: (data: any) => void): () => void {
|
|
1684
|
+
if (!this.uploadQueue) return () => { };
|
|
1685
|
+
return this.uploadQueue.on(event, callback);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* Get storage quota information
|
|
1690
|
+
*/
|
|
1691
|
+
public async getStorageInfo(): Promise<{
|
|
1692
|
+
usage: number;
|
|
1693
|
+
quota: number;
|
|
1694
|
+
percentUsed: number;
|
|
1695
|
+
}> {
|
|
1696
|
+
if (!navigator.storage || !navigator.storage.estimate) {
|
|
1697
|
+
return { usage: 0, quota: 0, percentUsed: 0 };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
try {
|
|
1701
|
+
const estimate = await navigator.storage.estimate();
|
|
1702
|
+
const usage = estimate.usage || 0;
|
|
1703
|
+
const quota = estimate.quota || 0;
|
|
1704
|
+
const percentUsed = quota > 0 ? (usage / quota) * 100 : 0;
|
|
1705
|
+
|
|
1706
|
+
return { usage, quota, percentUsed };
|
|
1707
|
+
} catch (error) {
|
|
1708
|
+
console.error('[CollabClient] Failed to get storage info:', error);
|
|
1709
|
+
return { usage: 0, quota: 0, percentUsed: 0 };
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Clear all local data (IndexedDB and caches)
|
|
1715
|
+
*/
|
|
1716
|
+
public async clearAllLocalData(): Promise<void> {
|
|
1717
|
+
console.log('[CollabClient] Clearing all local data...');
|
|
1718
|
+
|
|
1719
|
+
// Clear upload queue
|
|
1720
|
+
if (this.uploadQueue) {
|
|
1721
|
+
await this.uploadQueue.clear();
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// Clear all IndexedDB databases
|
|
1725
|
+
if (typeof indexedDB !== 'undefined') {
|
|
1726
|
+
try {
|
|
1727
|
+
// Get all database names
|
|
1728
|
+
const databases = await indexedDB.databases();
|
|
1729
|
+
for (const db of databases) {
|
|
1730
|
+
if (db.name) {
|
|
1731
|
+
console.log('[CollabClient] Deleting database:', db.name);
|
|
1732
|
+
await new Promise<void>((resolve, reject) => {
|
|
1733
|
+
const request = indexedDB.deleteDatabase(db.name!);
|
|
1734
|
+
request.onsuccess = () => resolve();
|
|
1735
|
+
request.onerror = () => reject(request.error);
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
} catch (error) {
|
|
1740
|
+
console.error('[CollabClient] Failed to clear IndexedDB:', error);
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Clear caches
|
|
1745
|
+
if (typeof caches !== 'undefined') {
|
|
1746
|
+
try {
|
|
1747
|
+
const cacheNames = await caches.keys();
|
|
1748
|
+
for (const name of cacheNames) {
|
|
1749
|
+
console.log('[CollabClient] Deleting cache:', name);
|
|
1750
|
+
await caches.delete(name);
|
|
1751
|
+
}
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
console.error('[CollabClient] Failed to clear caches:', error);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
console.log('[CollabClient] Local data cleared');
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Clear specific document cache
|
|
1762
|
+
*/
|
|
1763
|
+
public async clearDocumentCache(spaceId: string, docId: string): Promise<void> {
|
|
1764
|
+
const dbName = this.offlineOptions.persistenceDbName || 'y-collab';
|
|
1765
|
+
const documentName = `space:${spaceId}:doc:${docId}`;
|
|
1766
|
+
|
|
1767
|
+
try {
|
|
1768
|
+
// Open the database and clear the specific document
|
|
1769
|
+
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
|
1770
|
+
const request = indexedDB.open(dbName);
|
|
1771
|
+
request.onsuccess = () => resolve(request.result);
|
|
1772
|
+
request.onerror = () => reject(request.error);
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// Check if the store exists
|
|
1776
|
+
if (!db.objectStoreNames.contains(documentName)) {
|
|
1777
|
+
db.close();
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
// Clear the document store
|
|
1782
|
+
await new Promise<void>((resolve, reject) => {
|
|
1783
|
+
const transaction = db.transaction([documentName], 'readwrite');
|
|
1784
|
+
const store = transaction.objectStore(documentName);
|
|
1785
|
+
const request = store.clear();
|
|
1786
|
+
request.onsuccess = () => resolve();
|
|
1787
|
+
request.onerror = () => reject(request.error);
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
db.close();
|
|
1791
|
+
console.log('[CollabClient] Document cache cleared:', documentName);
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
console.error('[CollabClient] Failed to clear document cache:', error);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Perform cleanup when storage quota is exceeded
|
|
1799
|
+
*/
|
|
1800
|
+
private async performCleanup(): Promise<void> {
|
|
1801
|
+
console.log('[CollabClient] Performing storage cleanup...');
|
|
1802
|
+
|
|
1803
|
+
const { percentUsed } = await this.getStorageInfo();
|
|
1804
|
+
const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
|
|
1805
|
+
|
|
1806
|
+
if (percentUsed < threshold) {
|
|
1807
|
+
console.log('[CollabClient] Storage usage below threshold, no cleanup needed');
|
|
1808
|
+
return;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Strategy: Clear image cache first (non-critical data)
|
|
1812
|
+
if (typeof caches !== 'undefined') {
|
|
1813
|
+
try {
|
|
1814
|
+
const cacheNames = await caches.keys();
|
|
1815
|
+
for (const name of cacheNames) {
|
|
1816
|
+
if (name.includes('image')) {
|
|
1817
|
+
console.log('[CollabClient] Clearing image cache:', name);
|
|
1818
|
+
await caches.delete(name);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
} catch (error) {
|
|
1822
|
+
console.error('[CollabClient] Failed to clear image caches:', error);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// Check again
|
|
1827
|
+
const { percentUsed: newPercentUsed } = await this.getStorageInfo();
|
|
1828
|
+
if (newPercentUsed < threshold) {
|
|
1829
|
+
console.log('[CollabClient] Cleanup complete, storage usage:', newPercentUsed, '%');
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// If still over threshold, implement LRU eviction for documents
|
|
1834
|
+
// This is a placeholder - would need to track document access times
|
|
1835
|
+
console.warn('[CollabClient] Storage still over threshold after cleanup');
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* Check storage quota and perform cleanup if needed
|
|
1840
|
+
*/
|
|
1841
|
+
public async checkStorageQuota(): Promise<void> {
|
|
1842
|
+
if (!this.offlineOptions.autoCleanup) {
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const { percentUsed } = await this.getStorageInfo();
|
|
1847
|
+
const threshold = (this.offlineOptions.cleanupThreshold || 0.9) * 100;
|
|
1848
|
+
|
|
1849
|
+
if (percentUsed >= threshold) {
|
|
1850
|
+
console.warn('[CollabClient] Storage quota exceeded:', percentUsed, '%');
|
|
1851
|
+
await this.performCleanup();
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
/**
|
|
1856
|
+
* Get document content using a share token (public access)
|
|
1857
|
+
*/
|
|
1858
|
+
public async getPublicContent(spaceId: string, docId: string, shareToken: string) {
|
|
1859
|
+
const res = await fetch(`${this.baseUrl}/api/spaces/${spaceId}/documents/${docId}/content`, {
|
|
1860
|
+
headers: {
|
|
1861
|
+
"Share-Token": shareToken
|
|
1862
|
+
},
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
if (!res.ok) {
|
|
1866
|
+
throw new Error(`Failed to load public document: ${res.status}`);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
return res.json() as Promise<{ id: string; spaceId: string; content: Record<string, unknown> }>;
|
|
1870
|
+
}
|
|
1871
|
+
}
|