MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 29s
272 lines
8.2 KiB
TypeScript
272 lines
8.2 KiB
TypeScript
import * as Y from 'yjs';
|
|
import { BlockType, DocumentBlock } from './types';
|
|
|
|
// Convert Uint8Array to Base64
|
|
export function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
let binary = '';
|
|
const len = bytes.byteLength;
|
|
for (let i = 0; i < len; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
// Convert Base64 to Uint8Array
|
|
export function base64ToUint8Array(base64: string): Uint8Array {
|
|
const binaryString = atob(base64);
|
|
const len = binaryString.length;
|
|
const bytes = new Uint8Array(len);
|
|
for (let i = 0; i < len; i++) {
|
|
bytes[i] = binaryString.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
const COLLAB_COLORS = [
|
|
'#3B82F6', // Blue
|
|
'#10B981', // Emerald
|
|
'#F59E0B', // Amber
|
|
'#8B5CF6', // Purple
|
|
'#EC4899', // Pink
|
|
'#06B6D4', // Cyan
|
|
'#F97316', // Orange
|
|
'#14B8A6', // Teal
|
|
];
|
|
|
|
export function getCollaboratorColor(userId: string): string {
|
|
if (!userId) return COLLAB_COLORS[0];
|
|
let hash = 0;
|
|
for (let i = 0; i < userId.length; i++) {
|
|
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
|
hash |= 0;
|
|
}
|
|
const idx = Math.abs(hash) % COLLAB_COLORS.length;
|
|
return COLLAB_COLORS[idx];
|
|
}
|
|
|
|
export interface CollaboratorAwareness {
|
|
userId: string;
|
|
userName: string;
|
|
activeBlockId: string | null;
|
|
color: string;
|
|
lastActive: number;
|
|
}
|
|
|
|
export class DocumentCRDTSession {
|
|
public docId: string;
|
|
public doc: Y.Doc;
|
|
private order: Y.Array<string>;
|
|
private blocks: Y.Map<Y.Map<any>>;
|
|
private destroyed: boolean = false;
|
|
|
|
public onLocalUpdate?: (base64Update: string) => void;
|
|
public onRemoteChange?: (blocks: DocumentBlock[]) => void;
|
|
|
|
constructor(docId: string) {
|
|
this.docId = docId;
|
|
this.doc = new Y.Doc({ guid: docId });
|
|
this.order = this.doc.getArray<string>('blockOrder');
|
|
this.blocks = this.doc.getMap<Y.Map<any>>('blocks');
|
|
|
|
this.doc.on('update', (update: Uint8Array, origin: any) => {
|
|
if (this.destroyed) return;
|
|
if (origin !== 'remote') {
|
|
const b64 = uint8ArrayToBase64(update);
|
|
if (this.onLocalUpdate) {
|
|
this.onLocalUpdate(b64);
|
|
}
|
|
} else {
|
|
if (this.onRemoteChange) {
|
|
this.onRemoteChange(this.getBlocks());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public destroy(): void {
|
|
this.destroyed = true;
|
|
this.doc.destroy();
|
|
}
|
|
|
|
public getBlocks(): DocumentBlock[] {
|
|
const orderList = this.order.toArray();
|
|
const result: DocumentBlock[] = [];
|
|
for (const id of orderList) {
|
|
const bMap = this.blocks.get(id);
|
|
if (bMap) {
|
|
result.push({
|
|
id,
|
|
docId: this.docId,
|
|
type: (bMap.get('type') || 'bullet') as BlockType,
|
|
content: bMap.get('content') || '',
|
|
level: typeof bMap.get('level') === 'number' ? bMap.get('level') : 0,
|
|
checked: !!bMap.get('checked'),
|
|
collapsed: !!bMap.get('collapsed'),
|
|
lang: bMap.get('lang') || '',
|
|
sortOrder: typeof bMap.get('sortOrder') === 'number' ? bMap.get('sortOrder') : 0,
|
|
updatedAt: bMap.get('updatedAt') || undefined,
|
|
});
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public initFromBlocks(initialBlocks: DocumentBlock[]): void {
|
|
this.doc.transact(() => {
|
|
if (this.order.length === 0 && initialBlocks.length > 0) {
|
|
const ids: string[] = [];
|
|
for (const b of initialBlocks) {
|
|
ids.push(b.id);
|
|
const bMap = new Y.Map<any>();
|
|
bMap.set('id', b.id);
|
|
bMap.set('type', b.type || 'bullet');
|
|
bMap.set('content', b.content || '');
|
|
bMap.set('level', b.level || 0);
|
|
bMap.set('checked', !!b.checked);
|
|
bMap.set('collapsed', !!b.collapsed);
|
|
bMap.set('lang', b.lang || '');
|
|
bMap.set('sortOrder', b.sortOrder || 0);
|
|
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
|
this.blocks.set(b.id, bMap);
|
|
}
|
|
this.order.push(ids);
|
|
}
|
|
}, 'init');
|
|
}
|
|
|
|
public updateBlock(id: string, updates: Partial<DocumentBlock>): void {
|
|
this.doc.transact(() => {
|
|
const bMap = this.blocks.get(id);
|
|
if (bMap) {
|
|
if (updates.type !== undefined) bMap.set('type', updates.type);
|
|
if (updates.content !== undefined) bMap.set('content', updates.content);
|
|
if (updates.level !== undefined) bMap.set('level', updates.level);
|
|
if (updates.checked !== undefined) bMap.set('checked', updates.checked);
|
|
if (updates.collapsed !== undefined) bMap.set('collapsed', updates.collapsed);
|
|
if (updates.lang !== undefined) bMap.set('lang', updates.lang);
|
|
if (updates.sortOrder !== undefined) bMap.set('sortOrder', updates.sortOrder);
|
|
bMap.set('updatedAt', new Date().toISOString());
|
|
}
|
|
}, 'local');
|
|
}
|
|
|
|
public insertBlock(index: number, block: DocumentBlock): void {
|
|
this.doc.transact(() => {
|
|
const bMap = new Y.Map<any>();
|
|
bMap.set('id', block.id);
|
|
bMap.set('type', block.type || 'bullet');
|
|
bMap.set('content', block.content || '');
|
|
bMap.set('level', block.level || 0);
|
|
bMap.set('checked', !!block.checked);
|
|
bMap.set('collapsed', !!block.collapsed);
|
|
bMap.set('lang', block.lang || '');
|
|
bMap.set('sortOrder', block.sortOrder || 0);
|
|
bMap.set('updatedAt', new Date().toISOString());
|
|
this.blocks.set(block.id, bMap);
|
|
|
|
if (index >= 0 && index <= this.order.length) {
|
|
this.order.insert(index, [block.id]);
|
|
} else {
|
|
this.order.push([block.id]);
|
|
}
|
|
}, 'local');
|
|
}
|
|
|
|
public deleteBlock(id: string): void {
|
|
this.doc.transact(() => {
|
|
const currentOrder = this.order.toArray();
|
|
const idx = currentOrder.indexOf(id);
|
|
if (idx !== -1) {
|
|
this.order.delete(idx, 1);
|
|
}
|
|
this.blocks.delete(id);
|
|
}, 'local');
|
|
}
|
|
|
|
public deleteMultipleBlocks(ids: string[]): void {
|
|
if (ids.length === 0) return;
|
|
const toDeleteSet = new Set(ids);
|
|
this.doc.transact(() => {
|
|
const currentOrder = this.order.toArray();
|
|
// Delete from order backwards to preserve indices
|
|
for (let i = currentOrder.length - 1; i >= 0; i--) {
|
|
if (toDeleteSet.has(currentOrder[i])) {
|
|
this.order.delete(i, 1);
|
|
}
|
|
}
|
|
for (const id of ids) {
|
|
this.blocks.delete(id);
|
|
}
|
|
}, 'local');
|
|
}
|
|
|
|
public reorderBlocks(newOrder: string[]): void {
|
|
this.doc.transact(() => {
|
|
this.order.delete(0, this.order.length);
|
|
this.order.push(newOrder);
|
|
}, 'local');
|
|
}
|
|
|
|
public syncFullBlocks(blocks: DocumentBlock[]): void {
|
|
this.doc.transact(() => {
|
|
const nextIds = blocks.map((b) => b.id);
|
|
const currentIds = new Set(this.order.toArray());
|
|
|
|
// Upsert all blocks
|
|
for (const b of blocks) {
|
|
let bMap = this.blocks.get(b.id);
|
|
if (!bMap) {
|
|
bMap = new Y.Map<any>();
|
|
this.blocks.set(b.id, bMap);
|
|
}
|
|
bMap.set('id', b.id);
|
|
bMap.set('type', b.type || 'bullet');
|
|
bMap.set('content', b.content || '');
|
|
bMap.set('level', b.level || 0);
|
|
bMap.set('checked', !!b.checked);
|
|
bMap.set('collapsed', !!b.collapsed);
|
|
bMap.set('lang', b.lang || '');
|
|
bMap.set('sortOrder', b.sortOrder || 0);
|
|
bMap.set('updatedAt', b.updatedAt || new Date().toISOString());
|
|
}
|
|
|
|
// Delete blocks no longer present
|
|
const nextIdSet = new Set(nextIds);
|
|
currentIds.forEach((id) => {
|
|
if (!nextIdSet.has(id)) {
|
|
this.blocks.delete(id);
|
|
}
|
|
});
|
|
|
|
// Update order if different
|
|
const currentOrder = this.order.toArray();
|
|
const orderChanged =
|
|
currentOrder.length !== nextIds.length || currentOrder.some((id, idx) => id !== nextIds[idx]);
|
|
if (orderChanged) {
|
|
this.order.delete(0, this.order.length);
|
|
this.order.push(nextIds);
|
|
}
|
|
}, 'local');
|
|
}
|
|
|
|
public applyRemoteUpdate(base64Update: string): void {
|
|
try {
|
|
const bytes = base64ToUint8Array(base64Update);
|
|
Y.applyUpdate(this.doc, bytes, 'remote');
|
|
} catch (err) {
|
|
console.error('[CRDT] Failed to apply remote update:', err);
|
|
}
|
|
}
|
|
|
|
public applyRemoteUpdates(base64Updates: string[]): void {
|
|
for (const b64 of base64Updates) {
|
|
this.applyRemoteUpdate(b64);
|
|
}
|
|
}
|
|
|
|
public encodeState(): string {
|
|
const bytes = Y.encodeStateAsUpdate(this.doc);
|
|
return uint8ArrayToBase64(bytes);
|
|
}
|
|
}
|