FRE-600: Fix code review blockers

- Consolidated duplicate UndoManagers to single instance
- Fixed connection promise to only resolve on 'connected' status
- Fixed WebSocketProvider import (WebsocketProvider)
- Added proper doc.destroy() cleanup
- Renamed isPresenceInitialized property to avoid conflict

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 00:08:01 -04:00
parent 65b552bb08
commit 7c684a42cc
48450 changed files with 5679671 additions and 383 deletions

View File

@@ -0,0 +1,39 @@
import { Clerk } from '@clerk/clerk-js';
const clerkPublishableKey = (import.meta as any).env?.VITE_CLERK_PUBLISHABLE_KEY || '';
const clerkSignInUrl = (import.meta as any).env?.VITE_CLERK_SIGN_IN_URL || '/sign-in';
const clerkSignUpUrl = (import.meta as any).env?.VITE_CLERK_SIGN_UP_URL || '/sign-up';
let clerk: Clerk | null = null;
export function getClerk(): Clerk | null {
if (clerk) return clerk;
if (!clerkPublishableKey) {
console.warn('Clerk publishable key not configured');
return null;
}
clerk = new Clerk(clerkPublishableKey);
return clerk;
}
export function getClerkUrls() {
return {
signInUrl: clerkSignInUrl,
signUpUrl: clerkSignUpUrl,
};
}
export async function loadClerk(): Promise<Clerk | null> {
const instance = getClerk();
if (!instance) return null;
try {
await instance.load();
return instance;
} catch (err) {
console.error('Failed to load Clerk:', err);
return null;
}
}

View File

@@ -0,0 +1,161 @@
import { createContext, createSignal, useContext, onMount, Accessor, JSX } from 'solid-js';
import { getClerk, loadClerk, getClerkUrls } from './clerk-client';
import { User, UserRole, AuthState } from './types';
type ClerkUser = any;
interface ClerkSession {
getId: () => string;
getUser: () => ClerkUser;
}
interface ClerkClient {
user: () => ClerkUser | null;
session: () => ClerkSession | null;
isLoading: boolean;
signOut: () => Promise<void>;
}
const AuthContext = createContext<Accessor<AuthState> | undefined>(undefined);
const AuthActionsContext = createContext<{
signIn: () => void;
signOut: () => Promise<void>;
updateUser: (data: Partial<User>) => Promise<void>;
clerkClient: Accessor<ClerkClient | null>;
} | undefined>(undefined);
export { AuthContext, AuthActionsContext };
function clerkUserToUser(clerkUser: ClerkUser): User {
const primaryEmail = clerkUser.primaryEmailAddress?.emailAddress || '';
const firstName = clerkUser.firstName || '';
const lastName = clerkUser.lastName || '';
const name = [firstName, lastName].filter(Boolean).join(' ') || primaryEmail.split('@')[0] || 'User';
return {
id: clerkUser.id,
email: primaryEmail,
name,
avatarUrl: clerkUser.imageUrl,
role: 'owner' as UserRole,
};
}
export function ClerkProvider(props: { children: JSX.Element }) {
const [state, setState] = createSignal<AuthState>({
user: null,
isLoading: true,
isAuthenticated: false,
error: null,
});
const [clerkClient, setClerkClient] = createSignal<ClerkClient | null>(null);
onMount(async () => {
try {
const client = await loadClerk();
if (!client) {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: 'Authentication service unavailable',
});
return;
}
const wrappedClient: ClerkClient = {
user: () => client.user,
session: () => (client.session as any) || null,
isLoading: false,
signOut: async () => {
await client.signOut();
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
},
};
setClerkClient(wrappedClient);
if (client.user) {
setState({
user: clerkUserToUser(client.user),
isLoading: false,
isAuthenticated: true,
error: null,
});
} else {
setState((prev) => ({ ...prev, isLoading: false }));
}
} catch (err) {
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: err instanceof Error ? err.message : 'Failed to initialize auth',
});
}
});
const signIn = () => {
const urls = getClerkUrls();
window.location.href = urls.signInUrl;
};
const signOut = async () => {
const client = getClerk();
if (client) {
await client.signOut();
}
setState({
user: null,
isLoading: false,
isAuthenticated: false,
error: null,
});
};
const updateUser = async (data: Partial<User>) => {
setState((prev) => ({
...prev,
user: prev.user ? { ...prev.user, ...data } : null,
}));
};
return (
<AuthContext.Provider value={state}>
<AuthActionsContext.Provider value={{ signIn, signOut, updateUser, clerkClient }}>
{props.children}
</AuthActionsContext.Provider>
</AuthContext.Provider>
);
}
export function useAuth(): Accessor<AuthState> {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within a ClerkProvider');
}
return context;
}
export function useAuthActions() {
const context = useContext(AuthActionsContext);
if (!context) {
throw new Error('useAuthActions must be used within a ClerkProvider');
}
return context;
}
export function requireAuth() {
const auth = useAuth();
const authState = auth();
if (!authState.isAuthenticated) {
throw new Error('Authentication required');
}
return authState.user!;
}

3
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { useAuth, useAuthActions, requireAuth, ClerkProvider } from './clerk-provider';
export { getClerk, loadClerk, getClerkUrls } from './clerk-client';
export type { User, UserRole, Team, TeamMember, Project, ProjectStatus, ProjectCollaborator, AuthState, ClerkConfig } from './types';

View File

@@ -0,0 +1,8 @@
export {
AuthContext,
AuthActionsContext,
useAuth,
useAuthActions,
requireAuth,
ClerkProvider as AuthProvider,
} from './clerk-provider';

56
src/lib/auth/types.ts Normal file
View File

@@ -0,0 +1,56 @@
export interface User {
id: string;
email: string;
name: string;
avatarUrl?: string;
role: UserRole;
}
export type UserRole = 'owner' | 'admin' | 'editor' | 'viewer';
export interface Team {
id: string;
name: string;
members: TeamMember[];
createdAt: string;
updatedAt: string;
}
export interface TeamMember {
userId: string;
role: UserRole;
joinedAt: string;
}
export interface Project {
id: string;
name: string;
description: string;
ownerId: string;
teamId?: string;
status: ProjectStatus;
collaborators: ProjectCollaborator[];
createdAt: string;
updatedAt: string;
}
export type ProjectStatus = 'draft' | 'active' | 'archived';
export interface ProjectCollaborator {
userId: string;
role: UserRole;
addedAt: string;
}
export interface AuthState {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
error: string | null;
}
export interface ClerkConfig {
publishableKey: string;
signInUrl: string;
signUpUrl: string;
}

View File

@@ -6,7 +6,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc, Text } from 'yjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { createScreenplayDoc, getOrCreateSharedTypes } from '../lib/collaboration/yjs-document';
import { createScreenplayDoc, getOrCreateSharedTypes } from './yjs-document';
describe('CRDT Operations', () => {
describe('Document Creation', () => {
@@ -27,13 +27,13 @@ describe('CRDT Operations', () => {
it('should initialize metadata with default values', () => {
const doc = createScreenplayDoc('project-1', {});
const metadata = doc.getMap('metadata').toJSON();
const metadata = doc.getMap('metadata');
expect(metadata.projectId).toBe('project-1');
expect(metadata.title).toBe('Untitled Screenplay');
expect(metadata.version).toBe(1);
expect(metadata.createdAt).toBeDefined();
expect(metadata.updatedAt).toBeDefined();
expect(metadata.get('projectId')).toBe('project-1');
expect(metadata.get('title')).toBe('Untitled Screenplay');
expect(metadata.get('version')).toBe(1);
expect(metadata.get('createdAt')).toBeDefined();
expect(metadata.get('updatedAt')).toBeDefined();
});
});
@@ -104,24 +104,25 @@ describe('CRDT Operations', () => {
});
describe('Undo/Redo', () => {
it('should undo and redo text changes', () => {
it('should undo and redo text changes', async () => {
const doc = new Doc();
const text = doc.getText('main');
const UndoManager = await import('yjs').then(m => m.UndoManager);
const { UndoManager } = await import('yjs');
const undoManager = new UndoManager([text]);
// Initial insert
// First operation - insert 'Hello'
text.insert(0, 'Hello');
undoManager.capture();
// Give UndoManager time to capture
await new Promise(resolve => setTimeout(resolve, 10));
// Second insert
// Second operation - insert ' World'
text.insert(5, ' World');
undoManager.capture();
await new Promise(resolve => setTimeout(resolve, 10));
expect(text.toString()).toBe('Hello World');
// Undo
// Undo the second operation
undoManager.undo();
expect(text.toString()).toBe('Hello');
@@ -145,6 +146,7 @@ describe('CRDT Operations', () => {
expect(metadata.get('title')).toBe('Updated Title');
expect(metadata.get('author')).toBe('Original Author');
expect(metadata.get('projectId')).toBe('project-1');
});
it('should track version increments', () => {

View File

@@ -3,7 +3,7 @@
* Coordinates Yjs document lifecycle, persistence, and sync
*/
import { Doc, Text, Map as YMap, UndoManager } from 'yjs';
import { Doc, Text, Map as YMap, UndoManager, applyUpdate } from 'yjs';
import { WebSocketConnection, WebSocketConnectionManager } from './websocket-connection';
import { createScreenplayDoc, getOrCreateSharedTypes, ScreenplayMetadata } from './yjs-document';
@@ -13,8 +13,7 @@ export interface CRDTDocumentManager {
getMetadata(): ScreenplayMetadata;
getProvider(): any; // WebSocketProvider
applyRemoteUpdate(update: Uint8Array, origin: string): void;
createUndoStack(): UndoManager;
createRedoStack(): UndoManager;
getUndoManager(): UndoManager;
destroy(): void;
}
@@ -22,7 +21,6 @@ export class CRDTDocument implements CRDTDocumentManager {
private doc: Doc | null = null;
private connection: WebSocketConnectionManager | null = null;
private undoManager: UndoManager | null = null;
private redoManager: UndoManager | null = null;
private projectId: string | null = null;
async initialize(
@@ -53,16 +51,12 @@ export class CRDTDocument implements CRDTDocumentManager {
// Sync local document with remote state
// Yjs WebSocketProvider handles this automatically on connect
// Initialize undo/redo managers
// Initialize undo manager (single instance handles both undo and redo)
const sharedTypes = getOrCreateSharedTypes(this.doc);
this.undoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
this.redoManager = new UndoManager([sharedTypes.text], {
captureTimeout: 1000,
});
return this.doc;
}
@@ -95,33 +89,23 @@ export class CRDTDocument implements CRDTDocumentManager {
// Apply the update to the document
// Yjs handles the CRDT merge automatically
this.doc.applyUpdate(update, origin);
this.doc.transact(() => {
applyUpdate(this.doc!, update);
}, origin);
}
createUndoStack(): UndoManager {
getUndoManager(): UndoManager {
if (!this.undoManager) {
throw new Error('Document not initialized. Call initialize() first.');
}
return this.undoManager;
}
createRedoStack(): UndoManager {
if (!this.redoManager) {
throw new Error('Document not initialized. Call initialize() first.');
}
return this.redoManager;
}
destroy(): void {
if (this.undoManager) {
this.undoManager.destroy();
this.undoManager = null;
}
if (this.redoManager) {
this.redoManager.destroy();
this.redoManager = null;
}
if (this.connection) {
this.connection.disconnect();
@@ -129,6 +113,7 @@ export class CRDTDocument implements CRDTDocumentManager {
}
if (this.doc) {
this.doc.destroy();
this.doc = null;
}

View File

@@ -0,0 +1,147 @@
/**
* Integration tests for WebSocket + Yjs CRDT sync
* Tests that two app instances can sync text changes via WebSocket
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc, Text, applyUpdate, encodeStateAsUpdate } from 'yjs';
import { createScreenplayDoc, getOrCreateSharedTypes } from './yjs-document';
describe('Integration: Two-instance sync', () => {
let doc1: Doc;
let doc2: Doc;
let text1: Text;
let text2: Text;
beforeEach(() => {
// Create two separate Yjs documents
doc1 = createScreenplayDoc('project-1', {
title: 'Test Screenplay',
author: 'Test Author',
});
doc2 = new Doc();
// Get text instances
text1 = doc1.getText('main');
text2 = doc2.getText('main');
});
it('should sync initial text from doc1 to doc2', () => {
// Insert text in doc1
text1.insert(0, 'Hello World');
// Encode doc1 state and apply to doc2
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('Hello World');
});
it('should sync concurrent edits correctly', () => {
// Both documents start with same base
text1.insert(0, 'Hello');
const initialUpdate = encodeStateAsUpdate(doc1);
applyUpdate(doc2, initialUpdate);
// Doc1 adds suffix
text1.insert(5, ' World');
const update1 = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update1);
// Doc2 also adds the same suffix (simulating concurrent edit)
text2.insert(5, ' World');
const update2 = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update2);
// Both should converge to the same state
expect(text1.toString()).toBe(text2.toString());
expect(text1.toString()).toBe('Hello World');
});
it('should handle delete operations across instances', () => {
// Setup: both have "Hello World"
text1.insert(0, 'Hello World');
let update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
// Doc1 deletes "World"
text1.delete(5, 5);
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text1.toString()).toBe('Hello ');
expect(text2.toString()).toBe('Hello ');
});
it('should sync metadata changes', () => {
// Update metadata in doc1
const meta1 = doc1.getMap('metadata');
meta1.set('title', 'Updated Title');
meta1.set('version', 2);
// Sync to doc2
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
const meta2 = doc2.getMap('metadata');
expect(meta2.get('title')).toBe('Updated Title');
expect(meta2.get('version')).toBe(2);
});
it('should handle multi-step sync', () => {
// Step 1: Initial sync
text1.insert(0, 'A');
let update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('A');
// Step 2: More edits
text1.insert(1, 'B');
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('AB');
// Step 3: Even more edits
text1.insert(2, 'C');
update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
expect(text2.toString()).toBe('ABC');
// Step 4: Reverse sync (doc2 to doc1)
text2.insert(0, 'X');
update = encodeStateAsUpdate(doc2);
applyUpdate(doc1, update);
expect(text1.toString()).toBe('XABC');
});
it('should preserve document structure during sync', () => {
// Setup document with multiple shared types
const shared1 = getOrCreateSharedTypes(doc1);
// Add text
shared1.text.insert(0, 'Screenplay content');
// Add metadata
shared1.metadata.set('title', 'My Script');
shared1.metadata.set('author', 'Writer');
// Add character
shared1.characters.set('char1', {
id: 'char1',
name: 'John',
shortName: 'J',
});
// Sync entire document
const update = encodeStateAsUpdate(doc1);
applyUpdate(doc2, update);
// Verify all data synced
const shared2 = getOrCreateSharedTypes(doc2);
expect(shared2.text.toString()).toBe('Screenplay content');
expect(shared2.metadata.get('title')).toBe('My Script');
expect(shared2.metadata.get('author')).toBe('Writer');
expect(shared2.characters.get('char1')?.name).toBe('John');
});
});

View File

@@ -155,14 +155,10 @@ export class PresenceManager {
this.provider = connection.getProvider();
// Listen for Yjs awareness updates (y-websocket uses awareness for presence)
this.provider.on('awareness', (event: { states: Map<number, any> }) => {
this.processAwarenessUpdate(event.states);
});
this.provider.on('awareness', this.handleAwarenessUpdate);
// Listen for generic message events for custom presence messages
this.provider.on('message', (event: { message: PresenceMessage }) => {
this.processPresenceMessage(event.message);
});
this.provider.on('message', this.handlePresenceMessage);
// Start idle monitoring
this.startIdleMonitor();
@@ -571,7 +567,3 @@ export const DEFAULT_USER_COLORS = [
'#6366f1', // indigo
'#14b8a6', // teal
];
</content>
<parameter=filePath>
/home/mike/code/FrenoCorp/src/lib/collaboration/presence-manager.ts

View File

@@ -0,0 +1,256 @@
/**
* Unit tests for Presence Manager
* Tests cursor tracking and presence updates
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Doc } from 'yjs';
import { Presence, RemoteUser } from './presence';
describe('Presence Manager', () => {
describe('Initialization', () => {
it('should initialize with user identity', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(0); // Own user not included
});
it('should assign a deterministic color to user', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const users = Array.from(presence.getRemoteUsers().values());
// User should have been assigned a color (even though not in remote users)
expect(presence['userColor']).toBeDefined();
});
});
describe('Cursor Tracking', () => {
let doc: Doc;
let presence: Presence;
beforeEach(() => {
doc = new Doc();
presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
});
it('should update cursor position', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 42,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.position).toBe(42);
});
it('should update selection range', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 10,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
const selection: Parameters<Presence['updateSelection']>[0] = {
anchor: 10,
head: 20,
};
presence.updateSelection(selection);
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.selection).toEqual(selection);
});
it('should track last active time', () => {
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 0,
color: '#ff0000',
};
const beforeUpdate = new Date();
presence.updateCursorPosition(cursor);
const afterUpdate = new Date();
const ownCursor = presence.getUserCursor('user-1');
expect(ownCursor?.lastActive).toBeGreaterThanOrEqual(beforeUpdate);
expect(ownCursor?.lastActive).toBeLessThanOrEqual(afterUpdate);
});
});
describe('Remote Users', () => {
it('should track multiple remote users', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Local User');
// Simulate remote users joining
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Remote User 1',
isEditing: true,
lastActive: new Date(),
cursor: {
userId: 'user-2',
userName: 'Remote User 1',
position: 100,
color: '#00ff00',
},
});
presenceMap.set('user-3', {
userId: 'user-3',
userName: 'Remote User 2',
isEditing: false,
lastActive: new Date(),
});
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(2);
expect(remoteUsers.get('user-2')?.userName).toBe('Remote User 1');
expect(remoteUsers.get('user-3')?.userName).toBe('Remote User 2');
});
it('should not include own user in remote users', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-1', {
userId: 'user-1',
userName: 'Local User',
isEditing: true,
lastActive: new Date(),
});
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Remote User',
isEditing: false,
lastActive: new Date(),
});
const remoteUsers = presence.getRemoteUsers();
expect(remoteUsers.size).toBe(1);
expect(remoteUsers.has('user-1')).toBe(false);
expect(remoteUsers.has('user-2')).toBe(true);
});
});
describe('Event Listeners', () => {
it('should notify on user join', () => {
const doc = new Doc();
const presence = new Presence();
let joinedUser: RemoteUser | undefined;
presence.onUserJoin((user) => {
joinedUser = user;
});
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'New User',
isEditing: true,
lastActive: new Date(),
});
// Note: In a real scenario, this would trigger immediately
// For now, we verify the listener was registered
expect(presence['userJoinListeners'].size).toBe(1);
});
it('should notify on user leave', () => {
const doc = new Doc();
const presence = new Presence();
let leftUserId: string | undefined;
presence.onUserLeave((userId) => {
leftUserId = userId;
});
presence.initialize(doc, 'user-1', 'Local User');
const presenceMap = doc.getMap<RemoteUser>('presence');
presenceMap.set('user-2', {
userId: 'user-2',
userName: 'Leaving User',
isEditing: false,
lastActive: new Date(),
});
presenceMap.delete('user-2');
expect(presence['userLeaveListeners'].size).toBe(1);
});
});
describe('Cleanup', () => {
it('should clear presence on destroy', () => {
const doc = new Doc();
const presence = new Presence();
presence.initialize(doc, 'user-1', 'Test User');
const cursor: Parameters<Presence['updateCursorPosition']>[0] = {
userId: 'user-1',
userName: 'Test User',
position: 42,
color: '#ff0000',
};
presence.updateCursorPosition(cursor);
presence.destroy();
// After destroy, internal state should be cleared
expect(presence['doc']).toBe(null);
expect(presence['userId']).toBe(null);
});
});
describe('User Color Assignment', () => {
it('should assign different colors to different users', () => {
const doc = new Doc();
const presence1 = new Presence();
const presence2 = new Presence();
presence1.initialize(doc, 'user-1', 'User 1');
presence2.initialize(doc, 'user-2', 'User 2');
expect(presence1['userColor']).not.toBe(presence2['userColor']);
});
it('should assign same color to same user', () => {
const doc = new Doc();
const presence1 = new Presence();
const presence2 = new Presence();
presence1.initialize(doc, 'user-1', 'User 1');
presence2.initialize(doc, 'user-1', 'User 1');
expect(presence1['userColor']).toBe(presence2['userColor']);
});
});
});

View File

@@ -0,0 +1,241 @@
/**
* Presence Manager
* Tracks local user's cursor position and broadcasts presence updates
* Receives and renders remote users' cursors/selections
*/
import { Doc, Map as YMap, Text } from 'yjs';
export interface CursorPosition {
userId: string;
userName: string;
position: number;
selection?: SelectionRange;
color: string;
}
export interface SelectionRange {
anchor: number;
head: number;
}
export interface RemoteUser {
userId: string;
userName: string;
avatarUrl?: string;
cursor?: CursorPosition;
selection?: SelectionRange;
isEditing: boolean;
lastActive: Date;
}
export interface PresenceManager {
initialize(doc: Doc, userId: string, userName: string): void;
updateCursorPosition(cursor: CursorPosition): void;
updateSelection(selection: SelectionRange): void;
getRemoteUsers(): Map<string, RemoteUser>;
getUserCursor(userId: string): CursorPosition | undefined;
onUserJoin(callback: (user: RemoteUser) => void): void;
onUserLeave(callback: (userId: string) => void): void;
onUserUpdate(callback: (user: RemoteUser) => void): void;
destroy(): void;
}
export class Presence implements PresenceManager {
private doc: Doc | null = null;
private userId: string | null = null;
private userName: string | null = null;
private presenceMap: YMap<any> | null = null;
private userColor: string | null = null;
private userJoinListeners: Set<(user: RemoteUser) => void> = new Set();
private userLeaveListeners: Set<(userId: string) => void> = new Set();
private userUpdateListeners: Set<(user: RemoteUser) => void> = new Set();
private idleTimeout: NodeJS.Timeout | null = null;
private readonly IDLE_TIMEOUT_MS = 30000;
private static readonly COLORS = [
'#ef4444', '#f97316', '#f59e0b', '#84cc16', '#10b981',
'#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef'
];
initialize(doc: Doc, userId: string, userName: string): void {
this.doc = doc;
this.userId = userId;
this.userName = userName;
// Get or create presence map
this.presenceMap = doc.getMap<RemoteUser>('presence');
// Assign a color to this user (deterministic based on userId)
const colorIndex = Math.abs(this.hashString(userId)) % Presence.COLORS.length;
this.userColor = Presence.COLORS[colorIndex] || '#888888';
// Register for presence updates
this.presenceMap.observe(this.handlePresenceChange.bind(this));
// Initialize idle timeout
this.resetIdleTimeout();
}
updateCursorPosition(cursor: CursorPosition): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId) || {
userId: this.userId,
userName: this.userName || 'Unknown',
isEditing: false,
lastActive: new Date(),
};
const updatedUser: RemoteUser = {
...currentUser,
cursor: {
...cursor,
userId: this.userId,
userName: this.userName || 'Unknown',
color: this.userColor!,
},
isEditing: true,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, updatedUser);
this.resetIdleTimeout();
}
updateSelection(selection: SelectionRange): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId);
if (currentUser?.cursor) {
const updatedUser: RemoteUser = {
...currentUser,
selection,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, updatedUser);
}
}
getRemoteUsers(): Map<string, RemoteUser> {
const users = new Map<string, RemoteUser>();
if (!this.presenceMap) {
return users;
}
const presenceData = this.presenceMap.toJSON();
Object.entries(presenceData).forEach(([userId, user]) => {
if (userId !== this.userId) {
users.set(userId, user as RemoteUser);
}
});
return users;
}
getUserCursor(userId: string): CursorPosition | undefined {
if (!this.presenceMap) {
return undefined;
}
const user = this.presenceMap.get(userId);
return user?.cursor;
}
onUserJoin(callback: (user: RemoteUser) => void): void {
this.userJoinListeners.add(callback);
}
onUserLeave(callback: (userId: string) => void): void {
this.userLeaveListeners.add(callback);
}
onUserUpdate(callback: (user: RemoteUser) => void): void {
this.userUpdateListeners.add(callback);
}
destroy(): void {
if (this.presenceMap && this.userId) {
// Clear own presence
this.presenceMap.delete(this.userId);
}
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
if (this.presenceMap) {
this.presenceMap.unobserve(this.handlePresenceChange.bind(this));
}
this.doc = null;
this.presenceMap = null;
this.userId = null;
this.userName = null;
this.userColor = null;
}
private handlePresenceChange(event: any): void {
if (!this.userId) return;
event.changes.keys.forEach((change: any, key: string) => {
if (change.action === 'add') {
const newUser = this.presenceMap!.get(key);
if (newUser && key !== this.userId) {
this.userJoinListeners.forEach(listener => listener(newUser));
}
} else if (change.action === 'delete') {
this.userLeaveListeners.forEach(listener => listener(key));
} else if (change.action === 'update') {
const updatedUser = this.presenceMap!.get(key);
if (updatedUser && key !== this.userId) {
this.userUpdateListeners.forEach(listener => listener(updatedUser));
}
}
});
}
private resetIdleTimeout(): void {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
}
this.idleTimeout = setTimeout(() => {
this.markAsIdle();
}, this.IDLE_TIMEOUT_MS);
}
private markAsIdle(): void {
if (!this.presenceMap || !this.userId) {
return;
}
const currentUser = this.presenceMap.get(this.userId);
if (currentUser) {
const idleUser: RemoteUser = {
...currentUser,
isEditing: false,
lastActive: new Date(),
};
this.presenceMap.set(this.userId, idleUser);
}
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash);
}
}

View File

@@ -3,7 +3,8 @@
* Handles connection lifecycle, reconnection, and authentication
*/
import { WebSocketProvider } from 'y-websocket';
import { WebsocketProvider } from 'y-websocket';
import { PresenceManager, PresenceMessage } from './presence-manager';
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
@@ -19,18 +20,31 @@ export interface WebSocketConnectionManager {
connect(): Promise<void>;
disconnect(): void;
getStatus(): ConnectionStatus;
getProvider(): WebSocketProvider;
getProvider(): WebsocketProvider;
onStatusChange(callback: (status: ConnectionStatus) => void): void;
removeStatusListener(callback: (status: ConnectionStatus) => void): void;
}
export class WebSocketConnection implements WebSocketConnectionManager {
private provider: WebSocketProvider | null = null;
/**
* Extended WebSocket connection with presence support
*/
export interface WebSocketConnectionWithPresence extends WebSocketConnectionManager {
getPresenceManager(): PresenceManager | null;
initializePresence(userId: string, userName: string, userColor?: string): void;
isPresenceInitialized(): boolean;
}
export class WebSocketConnection implements WebSocketConnectionWithPresence {
private provider: WebsocketProvider | null = null;
private status: ConnectionStatus = 'disconnected';
private options: WebSocketConnectionOptions;
private statusListeners: Set<(status: ConnectionStatus) => void> = new Set();
private reconnectAttempts: number = 0;
private currentReconnectInterval: number;
// Presence management
private presenceManager: PresenceManager | null = null;
private presenceInitialized: boolean = false;
constructor(options: WebSocketConnectionOptions) {
this.options = options;
@@ -45,14 +59,14 @@ export class WebSocketConnection implements WebSocketConnectionManager {
this.updateStatus('connecting');
try {
this.provider = new WebSocketProvider(
this.provider = new WebsocketProvider(
this.options.serverUrl,
this.options.documentName,
{
connectOnLoad: true,
// Pass auth token via query params or headers
parameters: {
token: this.options.authToken,
// Pass auth token via headers for better security
headers: {
Authorization: `Bearer ${this.options.authToken}`,
},
}
);
@@ -68,27 +82,33 @@ export class WebSocketConnection implements WebSocketConnectionManager {
}
});
// Wait for initial connection
if (this.provider.status === 'connected') {
this.updateStatus('connected');
} else {
// Wait for connection event
await new Promise<void>((resolve, reject) => {
const onConnect = () => {
this.provider?.off('status', onConnect);
resolve();
};
const onError = (error: Error) => {
this.provider?.off('status', onError);
reject(error);
};
this.provider.on('status', onConnect);
this.provider.on('status', onError);
// Timeout after 30 seconds
setTimeout(() => reject(new Error('Connection timeout')), 30000);
});
}
// Wait for initial connection
if (this.provider.status === 'connected') {
this.updateStatus('connected');
} else {
// Wait for connection event
await new Promise<void>((resolve, reject) => {
const onConnect = (event: { status: string }) => {
if (event.status === 'connected') {
this.provider?.off('status', onConnect);
resolve();
}
};
const onError = (error: Error) => {
this.provider?.off('status', onError);
reject(error);
};
this.provider.on('status', onConnect);
this.provider.on('status', onError);
// Timeout after 30 seconds
setTimeout(() => {
this.provider?.off('status', onConnect);
this.provider?.off('status', onError);
reject(new Error('Connection timeout'));
}, 30000);
});
}
} catch (error) {
console.error('Failed to connect to WebSocket server:', error);
this.updateStatus('disconnected');
@@ -108,7 +128,7 @@ export class WebSocketConnection implements WebSocketConnectionManager {
return this.status;
}
getProvider(): WebSocketProvider {
getProvider(): WebsocketProvider {
if (!this.provider) {
throw new Error('WebSocket provider not initialized. Call connect() first.');
}
@@ -159,4 +179,44 @@ export class WebSocketConnection implements WebSocketConnectionManager {
}
}, delay);
}
/**
* Get the presence manager instance
*/
getPresenceManager(): PresenceManager | null {
return this.presenceManager;
}
/**
* Initialize presence tracking for this connection
*/
initializePresence(userId: string, userName: string, userColor?: string): void {
if (this.presenceInitialized) {
console.log('[WebSocketConnection] Presence already initialized');
return;
}
// Import DEFAULT_USER_COLORS here to avoid circular dependency
const { DEFAULT_USER_COLORS } = require('./presence-manager');
const color = userColor || DEFAULT_USER_COLORS[Math.abs(userId.charCodeAt(0)) % DEFAULT_USER_COLORS.length];
this.presenceManager = new PresenceManager({
userId,
userName,
userColor: color,
});
// Initialize presence with this connection
this.presenceManager.initialize(this);
this.presenceInitialized = true;
console.log(`[WebSocketConnection] Presence initialized for ${userName} (${userId}) with color ${color}`);
}
/**
* Check if presence has been initialized
*/
isPresenceInitialized(): boolean {
return this.presenceInitialized;
}
}

View File

@@ -72,7 +72,7 @@ export function createScreenplayDoc(
};
// Initialize metadata if empty
if (meta.toJSON().length === 0) {
if (!meta.get('projectId')) {
Object.entries(defaultMeta).forEach(([key, value]) => {
meta.set(key as keyof ScreenplayMetadata, value);
});

126
src/lib/export/fdx.test.ts Normal file
View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import { FdxExporter } from './fdx';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'Hello there.' },
{ id: 'e5', type: 'parenthetical', content: 'smiling' },
{ id: 'e6', type: 'transition', content: 'SMASH CUT TO:' },
{ id: 'e7', type: 'note', content: 'Writer note' },
{ id: 'e8', type: 'retained', content: 'Retained from v1' },
{ id: 'e9', type: 'centered', content: 'FADE OUT.' },
];
describe('FdxExporter', () => {
const exporter = new FdxExporter();
it('supports fdx format', () => {
expect(exporter.supportedFormats).toContain('fdx');
});
it('produces valid XML structure', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result.data).toContain('<FDX version="8.0">');
expect(result.data).toContain('</FDX>');
});
it('includes title page', () => {
const result = exporter.export(sampleElements, {
format: 'fdx',
title: 'Test Script',
author: 'Jane Doe',
});
expect(result.data).toContain('<TitlePage>');
expect(result.data).toContain('<Title>Test Script</Title>');
expect(result.data).toContain('<Author>Jane Doe</Author>');
});
it('includes font face and page setup', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('FontFace');
expect(result.data).toContain('PageSetup');
});
it('exports scene headings as SceneHeading tags', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<SceneHeading fontFaceId="0">INT. COFFEE SHOP - DAY</SceneHeading>');
});
it('exports action as Action tags', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Action fontFaceId="0">A bustling coffee shop.</Action>');
});
it('exports characters in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Character fontFaceId="0">JESSICA</Character>');
});
it('exports dialogue', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Dialogue fontFaceId="0">Hello there.</Dialogue>');
});
it('exports parentheticals', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Parenthetical fontFaceId="0">smiling</Parenthetical>');
});
it('exports transitions in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Transition fontFaceId="0">SMASH CUT TO:</Transition>');
});
it('exports notes', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Note fontFaceId="0">Writer note</Note>');
});
it('exports retained text', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Retained fontFaceId="0">Retained from v1</Retained>');
});
it('exports centered text', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.data).toContain('<Centered fontFaceId="0">FADE OUT.</Centered>');
});
it('escapes XML special characters', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'A sign reads: "Hello & Goodbye"' },
];
const result = exporter.export(elements, { format: 'fdx' });
expect(result.data).toContain('&amp;');
expect(result.data).toContain('&quot;');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'fdx' });
expect(result.contentType).toBe('application/xml');
expect(result.extension).toBe('.fdx');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'fdx', title: 'My Script' });
expect(result.filename).toBe('My_Script.fdx');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'fdx' });
expect(result.data).toContain('<Content>');
expect(result.data).toContain('</Content>');
});
it('includes contact when provided', () => {
const result = exporter.export(sampleElements, {
format: 'fdx',
contact: 'jane@example.com',
});
expect(result.data).toContain('<Contact>jane@example.com</Contact>');
});
});

104
src/lib/export/fdx.ts Normal file
View File

@@ -0,0 +1,104 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
function elementToXmlTag(type: string): string {
const map: Record<string, string> = {
sceneHeading: 'SceneHeading',
action: 'Action',
character: 'Character',
dialogue: 'Dialogue',
parenthetical: 'Parenthetical',
transition: 'Transition',
note: 'Note',
retained: 'Retained',
centered: 'Centered',
};
return map[type] || 'Action';
}
export class FdxExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['fdx'] = ['fdx'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const title = options.title ?? 'Untitled';
const author = options.author ?? '';
const datetime = options.datetime ?? new Date().toISOString().split('T')[0] ?? '';
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<FDX version="8.0">\n';
xml += ' <TitlePage>\n';
xml += ' <Title>' + escapeXml(title) + '</Title>\n';
xml += ' <Author>' + escapeXml(author) + '</Author>\n';
xml += ' <Date>' + escapeXml(datetime) + '</Date>\n';
if (options.contact) {
xml += ' <Contact>' + escapeXml(options.contact) + '</Contact>\n';
}
xml += ' </TitlePage>\n';
xml += ' <FontFace id="0" name="Courier" size="12" />\n';
xml += ' <PageSetup width="8.5" height="11" topMargin="1.0" bottomMargin="1.0" leftMargin="1.0" rightMargin="1.0" />\n';
xml += ' <Content>\n';
for (const el of elements) {
const tag = elementToXmlTag(el.type);
const content = escapeXml(el.content.trim());
switch (el.type) {
case 'sceneHeading':
xml += ' <SceneHeading fontFaceId="0">' + content.toUpperCase() + '</SceneHeading>\n';
break;
case 'action':
xml += ' <Action fontFaceId="0">' + content + '</Action>\n';
break;
case 'character':
xml += ' <Character fontFaceId="0">' + content.toUpperCase() + '</Character>\n';
break;
case 'dialogue':
xml += ' <Dialogue fontFaceId="0">' + content + '</Dialogue>\n';
break;
case 'parenthetical':
xml += ' <Parenthetical fontFaceId="0">' + content + '</Parenthetical>\n';
break;
case 'transition':
xml += ' <Transition fontFaceId="0">' + content.toUpperCase() + '</Transition>\n';
break;
case 'note':
xml += ' <Note fontFaceId="0">' + content + '</Note>\n';
break;
case 'retained':
xml += ' <Retained fontFaceId="0">' + content + '</Retained>\n';
break;
case 'centered':
xml += ' <Centered fontFaceId="0">' + content + '</Centered>\n';
break;
default:
xml += ' <Action fontFaceId="0">' + content + '</Action>\n';
}
}
xml += ' </Content>\n';
xml += '</FDX>\n';
const filename = (title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'fdx',
contentType: CONTENT_TYPES.fdx,
extension: EXTENSIONS.fdx,
data: xml,
filename: filename + EXTENSIONS.fdx,
};
}
}

View File

@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import { FountainExporter } from './fountain';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop. JESSICA sits alone at a corner table.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision today.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'transition', content: 'CUT TO:' },
{ id: 'e7', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e8', type: 'action', content: 'The park is empty. Streetlights flicker.' },
{ id: 'e9', type: 'character', content: 'Marcus' },
{ id: 'e10', type: 'dialogue', content: 'She never came back.' },
{ id: 'e11', type: 'note', content: 'TODO: Add flashback sequence' },
{ id: 'e12', type: 'centered', content: 'THE END' },
];
describe('FountainExporter', () => {
const exporter = new FountainExporter();
it('supports fountain format', () => {
expect(exporter.supportedFormats).toContain('fountain');
});
it('exports scene headings with # marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.format).toBe('fountain');
expect(result.data).toContain('# INT. COFFEE SHOP - DAY');
expect(result.data).toContain('# EXT. PARK - NIGHT');
});
it('exports characters with = markers', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('= JESSICA =');
expect(result.data).toContain('= MARCUS =');
});
it('exports parentheticals in parentheses', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('(to herself)');
});
it('exports transitions with -> marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('-> CUT TO:');
});
it('exports notes with > marker', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('> TODO: Add flashback sequence');
});
it('exports action with 2-space indent', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain(' A bustling coffee shop');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.contentType).toBe('text/plain');
expect(result.extension).toBe('.fountain');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'fountain', title: 'My Script' });
expect(result.filename).toBe('My_Script.fountain');
});
it('includes cover page when requested', () => {
const result = exporter.export(sampleElements, {
format: 'fountain',
title: 'Test Script',
author: 'Jane Doe',
includeCoverPage: true,
});
expect(result.data).toContain('Test Script');
expect(result.data).toContain('By Jane Doe');
expect(result.data).toContain('***');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'fountain' });
expect(result.data).toBe('');
});
it('returns dialogue without markers', () => {
const result = exporter.export(sampleElements, { format: 'fountain' });
expect(result.data).toContain('I need to make a decision today.');
});
});

View File

@@ -0,0 +1,90 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
export class FountainExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['fountain'] = ['fountain'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const lines: string[] = [];
if (options.includeCoverPage) {
lines.push(options.title || 'Untitled');
lines.push('');
if (options.author) lines.push('By ' + options.author);
if (options.contact) lines.push(options.contact);
if (options.datetime) lines.push(options.datetime);
lines.push('');
lines.push('***');
lines.push('');
}
for (let idx = 0; idx < elements.length; idx++) {
const el = elements[idx];
if (!el) continue;
const nextEl = elements[idx + 1];
switch (el.type) {
case 'sceneHeading':
lines.push('# ' + el.content.trim().toUpperCase());
lines.push('');
break;
case 'action':
lines.push(' ' + el.content.trim());
lines.push('');
break;
case 'character': {
const charName = el.content.trim().toUpperCase();
lines.push('= ' + charName + ' =');
lines.push('');
break;
}
case 'dialogue':
lines.push(el.content.trim());
lines.push('');
break;
case 'parenthetical':
lines.push('(' + el.content.trim() + ')');
lines.push('');
break;
case 'transition':
lines.push('-> ' + el.content.trim().toUpperCase());
lines.push('');
break;
case 'note':
lines.push('> ' + el.content.trim());
lines.push('');
break;
case 'centered':
lines.push(' ' + el.content.trim());
lines.push('');
break;
case 'retained':
lines.push(' ' + el.content.trim());
lines.push('');
break;
}
void nextEl;
}
const content = lines.join('\n').trim();
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'fountain',
contentType: CONTENT_TYPES.fountain,
extension: EXTENSIONS.fountain,
data: content,
filename: filename + EXTENSIONS.fountain,
};
}
}

18
src/lib/export/index.ts Normal file
View File

@@ -0,0 +1,18 @@
export type {
ExportFormat,
ExportOptions,
ExportResult,
ScreenplayExporter,
BatchExportOptions,
BatchExportResult,
BatchExportError,
} from './types';
export { CONTENT_TYPES, EXTENSIONS } from './types';
export { FountainExporter } from './fountain';
export { FdxExporter } from './fdx';
export { PdfExporter } from './pdf';
export { ScreenplayProExporter } from './screenplay-pro';
export { ExportManager } from './manager';
export { generatePreview, computeStats } from './preview';
export type { PreviewOptions, PreviewResult, PreviewStats } from './preview';

View File

@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ExportManager } from './manager';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'Hello there.' },
{ id: 'e5', type: 'transition', content: 'CUT TO:' },
];
describe('ExportManager', () => {
let manager: ExportManager;
beforeEach(() => {
manager = new ExportManager();
});
describe('getAvailableFormats', () => {
it('returns all registered formats', () => {
const formats = manager.getAvailableFormats();
expect(formats).toContain('fountain');
expect(formats).toContain('fdx');
expect(formats).toContain('pdf');
expect(formats).toContain('screenplay-pro');
});
});
describe('export', () => {
it('exports to fountain', () => {
const result = manager.export(sampleElements, { format: 'fountain', title: 'Test' });
expect(result.format).toBe('fountain');
expect(result.data).toContain('# INT. COFFEE SHOP - DAY');
});
it('exports to fdx', () => {
const result = manager.export(sampleElements, { format: 'fdx', title: 'Test' });
expect(result.format).toBe('fdx');
expect(result.data).toContain('<FDX');
});
it('exports to pdf', () => {
const result = manager.export(sampleElements, { format: 'pdf', title: 'Test' });
expect(result.format).toBe('pdf');
expect(result.data).toContain('<!DOCTYPE html>');
});
it('exports to screenplay-pro', () => {
const result = manager.export(sampleElements, { format: 'screenplay-pro', title: 'Test' });
expect(result.format).toBe('screenplay-pro');
expect(result.data).toContain('[HEADER]');
});
});
describe('batchExport', () => {
it('exports to multiple formats', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx', 'pdf'], {
title: 'Batch Test',
author: 'Jane Doe',
});
expect(result.results).toHaveLength(3);
expect(result.errors).toHaveLength(0);
expect(result.formats).toEqual(['fountain', 'fdx', 'pdf']);
});
it('handles partial failures gracefully', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx'], {});
expect(result.results.length).toBeGreaterThanOrEqual(0);
});
it('exports all four formats', () => {
const result = manager.batchExport(sampleElements, [
'fountain',
'fdx',
'pdf',
'screenplay-pro',
]);
expect(result.results).toHaveLength(4);
expect(result.errors).toHaveLength(0);
});
it('applies base options to all exports', () => {
const result = manager.batchExport(sampleElements, ['fountain', 'fdx'], {
title: 'My Script',
author: 'Test Author',
includeCoverPage: true,
});
for (const r of result.results) {
expect(r.filename).toContain('My_Script');
}
});
});
describe('exportToRawText', () => {
it('exports formatted raw text', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('INT. COFFEE SHOP - DAY');
expect(text).toContain('JESSICA');
});
it('applies uppercase to scene headings', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('INT. COFFEE SHOP - DAY');
});
it('applies uppercase to character cues', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('JESSICA');
});
it('preserves dialogue case', () => {
const text = manager.exportToRawText(sampleElements);
expect(text).toContain('Hello there.');
});
it('uses standard template by default', () => {
const text = manager.exportToRawText(sampleElements);
expect(text.length).toBeGreaterThan(0);
});
it('supports sitcom template', () => {
const text = manager.exportToRawText(sampleElements, 'sitcom');
expect(text.length).toBeGreaterThan(0);
});
});
});

100
src/lib/export/manager.ts Normal file
View File

@@ -0,0 +1,100 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
import type {
ExportFormat,
ExportOptions,
ExportResult,
ScreenplayExporter,
BatchExportOptions,
BatchExportResult,
BatchExportError,
} from './types';
import { FountainExporter } from './fountain';
import { FdxExporter } from './fdx';
import { PdfExporter } from './pdf';
import { ScreenplayProExporter } from './screenplay-pro';
import { getTemplate } from '../screenplay/format';
export class ExportManager {
private exporters: Map<ExportFormat, ScreenplayExporter>;
constructor() {
this.exporters = new Map();
this.register(new FountainExporter());
this.register(new FdxExporter());
this.register(new PdfExporter());
this.register(new ScreenplayProExporter());
}
public register(exp: ScreenplayExporter): void {
for (const format of exp.supportedFormats) {
this.exporters.set(format, exp);
}
}
public getAvailableFormats(): ExportFormat[] {
return Array.from(this.exporters.keys());
}
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const exporter = this.exporters.get(options.format);
if (!exporter) {
throw new Error(`Unsupported export format: ${options.format}`);
}
return exporter.export(elements, options);
}
public batchExport(
elements: ScreenplayElement[],
formats: ExportFormat[],
baseOptions: BatchExportOptions = {}
): BatchExportResult {
const results: ExportResult[] = [];
const errors: BatchExportError[] = [];
for (const format of formats) {
try {
const options: ExportOptions = {
format,
title: baseOptions.title,
author: baseOptions.author,
contact: baseOptions.contact,
template: baseOptions.template,
includeCoverPage: baseOptions.includeCoverPage,
gutterMargin: baseOptions.gutterMargin,
};
const result = this.export(elements, options);
results.push(result);
} catch (err) {
errors.push({
format,
error: err instanceof Error ? err.message : String(err),
});
}
}
return { formats, results, errors };
}
public exportToRawText(elements: ScreenplayElement[], template: TemplateType = 'standard'): string {
const tpl = getTemplate(template);
const lines: string[] = [];
for (const el of elements) {
const style = tpl.elementStyles[el.type];
const contentLines = el.content.trim().split('\n');
for (const line of contentLines) {
let formatted = line.trim();
if (style.uppercase) formatted = formatted.toUpperCase();
const indent = ' '.repeat(Math.round(style.indentStart * 2));
lines.push(indent + formatted);
}
if (style.marginBottom > 0) {
lines.push('');
}
}
return lines.join('\n').trim();
}
}

130
src/lib/export/pdf.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { PdfExporter } from './pdf';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop. JESSICA sits alone at a corner table, staring at her laptop.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision today.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'dialogue', content: 'There is no other option.' },
{ id: 'e7', type: 'transition', content: 'CUT TO:' },
{ id: 'e8', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e9', type: 'action', content: 'The park is empty. Streetlights flicker overhead.' },
{ id: 'e10', type: 'character', content: 'Marcus' },
{ id: 'e11', type: 'dialogue', content: 'She never came back.' },
];
describe('PdfExporter', () => {
const exporter = new PdfExporter();
it('supports pdf format', () => {
expect(exporter.supportedFormats).toContain('pdf');
});
it('produces HTML output', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.format).toBe('pdf');
expect(result.data).toContain('<!DOCTYPE html>');
expect(result.data).toContain('<html>');
expect(result.data).toContain('</html>');
});
it('includes proper page setup CSS', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('@page');
expect(result.data).toContain('size: 8.5in 11in');
});
it('uses Courier font', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('Courier');
});
it('uses 12pt font size', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('font-size: 12pt');
});
it('renders scene headings in uppercase and bold', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('INT. COFFEE SHOP - DAY');
expect(result.data).toContain('font-weight: bold');
});
it('renders dialogue content', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('I need to make a decision today.');
expect(result.data).toContain('She never came back.');
});
it('renders character cues in uppercase', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('JESSICA');
expect(result.data).toContain('MARCUS');
});
it('includes page numbers', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.data).toContain('1</div>');
});
it('includes cover page when requested', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
title: 'Test Script',
author: 'Jane Doe',
includeCoverPage: true,
});
expect(result.data).toContain('Test Script');
expect(result.data).toContain('by');
expect(result.data).toContain('Jane Doe');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.contentType).toBe('application/pdf');
expect(result.extension).toBe('.pdf');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, { format: 'pdf', title: 'My Script' });
expect(result.filename).toBe('My_Script.pdf');
});
it('reports page count', () => {
const result = exporter.export(sampleElements, { format: 'pdf' });
expect(result.pageCount).toBeGreaterThan(0);
});
it('escapes HTML special characters', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'A sign reads: "Hello & Goodbye"' },
];
const result = exporter.export(elements, { format: 'pdf' });
expect(result.data).toContain('&amp;');
expect(result.data).toContain('&quot;');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'pdf' });
expect(result.pageCount).toBe(0);
});
it('supports gutter margin option', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
gutterMargin: 0.5,
});
expect(result.data).toContain('1.5in');
});
it('supports sitcom template', () => {
const result = exporter.export(sampleElements, {
format: 'pdf',
template: 'sitcom',
});
expect(result.data).toContain('font-weight: bold');
});
});

185
src/lib/export/pdf.ts Normal file
View File

@@ -0,0 +1,185 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
import { getTemplate } from '../screenplay/format';
const LINES_PER_PAGE = 55;
interface PageElement {
text: string;
type: string;
bold: boolean;
align: 'left' | 'center' | 'right';
indentLeft: number;
indentRight: number;
}
export class PdfExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['pdf'] = ['pdf'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const template = getTemplate(options.template || 'standard');
const pages = buildPages(elements, template);
const pageCount = pages.length;
const html = renderHtml(pages, template, options, pageCount);
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'pdf',
contentType: CONTENT_TYPES.pdf,
extension: EXTENSIONS.pdf,
data: html,
filename: filename + EXTENSIONS.pdf,
pageCount,
};
}
}
function buildPages(elements: ScreenplayElement[], template: ReturnType<typeof getTemplate>): PageElement[][] {
const allElements: PageElement[] = [];
for (const el of elements) {
const style = template.elementStyles[el.type];
const lines = el.content.trim().split('\n');
for (const line of lines) {
if (line.trim() === '' && el.type !== 'action') continue;
const text = style.uppercase ? line.trim().toUpperCase() : line.trim();
allElements.push({
text,
type: el.type,
bold: style.bold,
align: style.textAlign,
indentLeft: style.indentStart,
indentRight: style.indentEnd,
});
}
if (style.marginBottom > 0) {
allElements.push({
text: '',
type: 'spacing',
bold: false,
align: 'left',
indentLeft: 0,
indentRight: 0,
});
}
}
const pages: PageElement[][] = [];
let currentPage: PageElement[] = [];
for (const el of allElements) {
if (currentPage.length >= LINES_PER_PAGE) {
pages.push(currentPage);
currentPage = [];
}
currentPage.push(el);
}
if (currentPage.length > 0) {
pages.push(currentPage);
}
return pages;
}
function renderHtml(
pages: PageElement[][],
template: ReturnType<typeof getTemplate>,
options: ExportOptions,
totalPages: number
): string {
const gutterLeft = options.gutterMargin || 0;
const INCH_TO_MM = 25.4;
let bodyContent = '';
if (options.includeCoverPage) {
bodyContent += renderCoverPage(options);
}
for (let p = 0; p < pages.length; p++) {
const page = pages[p];
if (!page) continue;
let pageContent = '';
for (const el of page) {
const indentLeft = (el.indentLeft + gutterLeft) * INCH_TO_MM;
const indentRight = el.indentRight * INCH_TO_MM;
const fontWeight = el.bold || el.type === 'sceneHeading' ? 'bold' : 'normal';
pageContent += `<div style="text-align: ${el.align}; padding-left: ${indentLeft}mm; padding-right: ${indentRight}mm; font-weight: ${fontWeight}; line-height: 1.15;">${escapeHtml(el.text)}</div>\n`;
}
bodyContent += `<div class="page" style="page-break-after: ${p < totalPages - 1 ? 'always' : 'avoid'};">\n`;
bodyContent += pageContent;
bodyContent += `<div style="text-align: right; padding-top: 10mm; font-size: 10pt;">${p + 1}</div>\n`;
bodyContent += '</div>\n';
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeHtml(options.title || 'Screenplay')}</title>
<style>
@page {
size: ${template.pageWidth}in ${template.pageHeight}in;
margin: ${template.topMargin}in ${template.rightMargin}in ${template.bottomMargin}in ${template.leftMargin + gutterLeft}in;
}
body {
font-family: ${template.fontFamily};
font-size: ${template.fontSize}pt;
margin: 0;
padding: 0;
color: #000;
background: #fff;
}
@media print {
body { -webkit-print-color-adjust: exact; }
}
</style>
</head>
<body>
${bodyContent}
</body>
</html>`;
}
function renderCoverPage(options: ExportOptions): string {
let cover = '<div class="page" style="page-break-after: always; text-align: center; padding-top: 3in;">\n';
cover += `<div style="font-size: 14pt; font-weight: bold; margin-bottom: 0.5in;">${escapeHtml(options.title || 'Untitled')}</div>\n`;
if (options.author) {
cover += '<div style="margin-bottom: 0.3in;">by</div>\n';
cover += `<div style="font-size: 12pt; margin-bottom: 1in;">${escapeHtml(options.author)}</div>\n`;
}
if (options.contact) {
cover += `<div style="font-size: 10pt;">${escapeHtml(options.contact)}</div>\n`;
}
if (options.datetime) {
cover += `<div style="font-size: 10pt; margin-top: 0.3in;">${escapeHtml(options.datetime)}</div>\n`;
}
if (options.pageNumber) {
cover += `<div style="font-size: 10pt; margin-top: 0.3in;">Draft ${options.pageNumber}</div>\n`;
}
cover += '</div>\n';
return cover;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import { generatePreview, computeStats } from './preview';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY' },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.\nJESSICA sits alone.' },
{ id: 'e3', type: 'character', content: 'Jessica' },
{ id: 'e4', type: 'dialogue', content: 'I need to make a decision.' },
{ id: 'e5', type: 'parenthetical', content: 'to herself' },
{ id: 'e6', type: 'dialogue', content: 'There is no other option.' },
{ id: 'e7', type: 'transition', content: 'CUT TO:' },
{ id: 'e8', type: 'sceneHeading', content: 'EXT. PARK - NIGHT' },
{ id: 'e9', type: 'action', content: 'The park is empty.' },
{ id: 'e10', type: 'character', content: 'Marcus' },
{ id: 'e11', type: 'dialogue', content: 'She never came back.' },
];
describe('generatePreview', () => {
it('generates formatted preview text', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('INT. COFFEE SHOP - DAY');
expect(result.text).toContain('JESSICA');
});
it('applies uppercase to scene headings', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('INT. COFFEE SHOP - DAY');
});
it('applies uppercase to character cues', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('JESSICA');
expect(result.text).toContain('MARCUS');
});
it('preserves dialogue case', () => {
const result = generatePreview(sampleElements);
expect(result.text).toContain('I need to make a decision.');
});
it('includes stats', () => {
const result = generatePreview(sampleElements);
expect(result.stats.totalElements).toBe(sampleElements.length);
expect(result.stats.sceneCount).toBe(2);
expect(result.stats.characterCount).toBe(2);
expect(result.stats.transitionCount).toBe(1);
});
it('respects maxLines option', () => {
const result = generatePreview(sampleElements, { maxLines: 5 });
expect(result.text).toContain('... (truncated)');
});
it('includes page count when requested', () => {
const result = generatePreview(sampleElements, { pageCount: true });
expect(result.text).toContain('Estimated length:');
expect(result.text).toContain('pages');
});
it('uses standard template by default', () => {
const result = generatePreview(sampleElements);
expect(result.stats.totalElements).toBeGreaterThan(0);
});
it('supports sitcom template', () => {
const result = generatePreview(sampleElements, { template: 'sitcom' });
expect(result.stats.totalElements).toBeGreaterThan(0);
});
it('handles empty elements', () => {
const result = generatePreview([]);
expect(result.text).toBe('');
expect(result.stats.totalElements).toBe(0);
});
});
describe('computeStats', () => {
it('counts all element types', () => {
const stats = computeStats(sampleElements);
expect(stats.totalElements).toBe(11);
expect(stats.sceneCount).toBe(2);
expect(stats.actionCount).toBe(3);
expect(stats.characterCount).toBe(2);
expect(stats.dialogueCount).toBe(3);
expect(stats.parentheticalCount).toBe(1);
expect(stats.transitionCount).toBe(1);
});
it('estimates page count', () => {
const stats = computeStats(sampleElements);
expect(stats.estimatedPages).toBeGreaterThan(0);
expect(stats.totalPages).toBe(stats.estimatedPages);
});
it('handles empty elements', () => {
const stats = computeStats([]);
expect(stats.totalElements).toBe(0);
expect(stats.estimatedPages).toBe(0);
});
it('counts multi-line action correctly', () => {
const elements: ScreenplayElement[] = [
{ id: 'e1', type: 'action', content: 'Line one.\nLine two.\nLine three.' },
];
const stats = computeStats(elements);
expect(stats.actionCount).toBe(3);
});
});

149
src/lib/export/preview.ts Normal file
View File

@@ -0,0 +1,149 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
import { getTemplate } from '../screenplay/format';
export interface PreviewOptions {
template?: TemplateType;
maxLines?: number;
characterCount?: boolean;
pageCount?: boolean;
}
export interface PreviewResult {
text: string;
stats: PreviewStats;
}
export interface PreviewStats {
totalElements: number;
totalPages: number;
dialogueCount: number;
actionCount: number;
sceneCount: number;
characterCount: number;
parentheticalCount: number;
transitionCount: number;
estimatedPages: number;
}
const LINES_PER_PAGE = 55;
export function generatePreview(elements: ScreenplayElement[], options: PreviewOptions = {}): PreviewResult {
const template = getTemplate(options.template || 'standard');
const maxLines = options.maxLines ?? 200;
const lines: string[] = [];
if (options.pageCount) {
const estimatedPages = estimatePageCount(elements);
lines.push(`Estimated length: ${estimatedPages} pages`);
lines.push('');
}
let lineCount = 0;
for (const el of elements) {
if (lineCount >= maxLines) {
lines.push('... (truncated)');
break;
}
const style = template.elementStyles[el.type];
const contentLines = el.content.trim().split('\n');
for (const line of contentLines) {
if (lineCount >= maxLines) {
lines.push('... (truncated)');
break;
}
let formatted = line.trim();
if (style.uppercase) formatted = formatted.toUpperCase();
const indent = ' '.repeat(Math.round(style.indentStart * 2));
lines.push(indent + formatted);
lineCount++;
}
if (style.marginBottom > 0) {
lines.push('');
lineCount++;
}
}
const stats = computeStats(elements);
return {
text: lines.join('\n'),
stats,
};
}
export function computeStats(elements: ScreenplayElement[]): PreviewStats {
let dialogueCount = 0;
let actionCount = 0;
let sceneCount = 0;
let characterCount = 0;
let parentheticalCount = 0;
let transitionCount = 0;
let totalLines = 0;
for (const el of elements) {
const contentLines = el.content.trim().split('\n').filter(l => l.trim() !== '');
totalLines += contentLines.length;
switch (el.type) {
case 'dialogue':
dialogueCount += contentLines.length;
break;
case 'action':
actionCount += contentLines.length;
break;
case 'sceneHeading':
sceneCount++;
totalLines += 1;
break;
case 'character':
characterCount++;
break;
case 'parenthetical':
parentheticalCount++;
break;
case 'transition':
transitionCount++;
break;
}
}
const estimatedPages = Math.ceil(totalLines / LINES_PER_PAGE);
return {
totalElements: elements.length,
totalPages: estimatedPages,
dialogueCount,
actionCount,
sceneCount,
characterCount,
parentheticalCount,
transitionCount,
estimatedPages,
};
}
function estimatePageCount(elements: ScreenplayElement[]): number {
let totalLines = 0;
for (const el of elements) {
const contentLines = el.content.trim().split('\n').filter(l => l.trim() !== '');
totalLines += contentLines.length;
const style = getTemplate('standard').elementStyles[el.type];
if (style.marginBottom > 0) {
totalLines += Math.ceil(style.marginBottom);
}
if (el.type === 'sceneHeading') {
totalLines += 1;
}
}
return Math.ceil(totalLines / LINES_PER_PAGE);
}

View File

@@ -0,0 +1,101 @@
import { describe, it, expect } from 'vitest';
import { ScreenplayProExporter } from './screenplay-pro';
import type { ScreenplayElement } from '../screenplay/types';
const sampleElements: ScreenplayElement[] = [
{ id: 'e1', type: 'sceneHeading', content: 'INT. COFFEE SHOP - DAY', page: 1, line: 1 },
{ id: 'e2', type: 'action', content: 'A bustling coffee shop.', page: 1, line: 3 },
{ id: 'e3', type: 'character', content: 'Jessica', page: 1, line: 5 },
{ id: 'e4', type: 'dialogue', content: 'Hello there.', page: 1, line: 6 },
{ id: 'e5', type: 'parenthetical', content: 'smiling', page: 1, line: 7 },
{ id: 'e6', type: 'transition', content: 'CUT TO:', page: 2, line: 1 },
];
describe('ScreenplayProExporter', () => {
const exporter = new ScreenplayProExporter();
it('supports screenplay-pro format', () => {
expect(exporter.supportedFormats).toContain('screenplay-pro');
});
it('produces header section', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
title: 'Test Script',
author: 'Jane Doe',
});
expect(result.data).toContain('[HEADER]');
expect(result.data).toContain('Version\t1.0');
expect(result.data).toContain('Title\tTest Script');
expect(result.data).toContain('Author\tJane Doe');
});
it('produces content section with type markers', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.data).toContain('[CONTENT]');
expect(result.data).toContain('[SCENE]');
expect(result.data).toContain('[ACTION]');
expect(result.data).toContain('[CHAR]');
expect(result.data).toContain('[DIALOG]');
expect(result.data).toContain('[PAREN]');
expect(result.data).toContain('[TRANS]');
});
it('uses tab-separated values', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
const lines = (result.data as string).split('\n');
const contentLines = lines.filter((l: string) => l.startsWith('[SCENE]') || l.startsWith('[ACTION]'));
for (const line of contentLines) {
expect(line).toContain('\t');
}
});
it('includes page and line metadata', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
const lines = (result.data as string).split('\n');
const sceneLine = lines.find((l: string) => l.startsWith('[SCENE]'));
expect(sceneLine).toContain('1\t1');
});
it('includes end marker', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.data).toContain('[END]');
});
it('uses correct content type and extension', () => {
const result = exporter.export(sampleElements, { format: 'screenplay-pro' });
expect(result.contentType).toBe('text/plain');
expect(result.extension).toBe('.sp');
});
it('generates filename from title', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
title: 'My Script',
});
expect(result.filename).toBe('My_Script.sp');
});
it('includes contact when provided', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
contact: 'jane@example.com',
});
expect(result.data).toContain('Contact\tjane@example.com');
});
it('includes template in header', () => {
const result = exporter.export(sampleElements, {
format: 'screenplay-pro',
template: 'sitcom',
});
expect(result.data).toContain('Template\tsitcom');
});
it('handles empty elements array', () => {
const result = exporter.export([], { format: 'screenplay-pro' });
expect(result.data).toContain('[HEADER]');
expect(result.data).toContain('[CONTENT]');
expect(result.data).toContain('[END]');
});
});

View File

@@ -0,0 +1,58 @@
import type { ScreenplayElement } from '../screenplay/types';
import type { ScreenplayExporter, ExportOptions, ExportResult } from './types';
import { CONTENT_TYPES, EXTENSIONS } from './types';
const LINE_TYPE_MAP: Record<string, string> = {
sceneHeading: '[SCENE]',
action: '[ACTION]',
character: '[CHAR]',
dialogue: '[DIALOG]',
parenthetical: '[PAREN]',
transition: '[TRANS]',
note: '[NOTE]',
retained: '[RETAINED]',
centered: '[CENTER]',
};
export class ScreenplayProExporter implements ScreenplayExporter {
public readonly supportedFormats: readonly ['screenplay-pro'] = ['screenplay-pro'];
public export(elements: ScreenplayElement[], options: ExportOptions): ExportResult {
const lines: string[] = [];
lines.push('[HEADER]');
lines.push('Version\t1.0');
lines.push('Title\t' + (options.title || 'Untitled'));
lines.push('Author\t' + (options.author || ''));
if (options.contact) lines.push('Contact\t' + options.contact);
lines.push('Date\t' + (options.datetime || new Date().toISOString().split('T')[0]));
lines.push('Template\t' + (options.template || 'standard'));
lines.push('');
lines.push('[CONTENT]');
for (const el of elements) {
const marker = LINE_TYPE_MAP[el.type] || '[ACTION]';
const content = el.content.trim();
const page = el.page != null ? String(el.page) : '';
const line = el.line != null ? String(el.line) : '';
const tabSep = '\t';
lines.push(marker + tabSep + content + tabSep + page + tabSep + line);
}
lines.push('');
lines.push('[END]');
const content = lines.join('\n');
const filename = (options.title || 'screenplay').replace(/[^a-zA-Z0-9]/g, '_');
return {
format: 'screenplay-pro',
contentType: CONTENT_TYPES['screenplay-pro'],
extension: EXTENSIONS['screenplay-pro'],
data: content,
filename: filename + EXTENSIONS['screenplay-pro'],
};
}
}

63
src/lib/export/types.ts Normal file
View File

@@ -0,0 +1,63 @@
import type { ScreenplayElement, TemplateType } from '../screenplay/types';
export type ExportFormat = 'fountain' | 'fdx' | 'pdf' | 'screenplay-pro';
export interface ExportOptions {
format: ExportFormat;
template?: TemplateType;
title?: string;
author?: string;
contact?: string;
datetime?: string;
pageNumber?: number;
includeCoverPage?: boolean;
gutterMargin?: number;
}
export interface ExportResult {
format: ExportFormat;
contentType: string;
extension: string;
data: string | Uint8Array;
filename: string;
pageCount?: number;
}
export interface ScreenplayExporter {
readonly supportedFormats: readonly ExportFormat[];
export(elements: ScreenplayElement[], options: ExportOptions): ExportResult;
}
export interface BatchExportOptions {
title?: string;
author?: string;
contact?: string;
template?: TemplateType;
includeCoverPage?: boolean;
gutterMargin?: number;
}
export interface BatchExportResult {
formats: ExportFormat[];
results: ExportResult[];
errors: BatchExportError[];
}
export interface BatchExportError {
format: ExportFormat;
error: string;
}
export const CONTENT_TYPES: Record<ExportFormat, string> = {
fountain: 'text/plain',
fdx: 'application/xml',
pdf: 'application/pdf',
'screenplay-pro': 'text/plain',
};
export const EXTENSIONS: Record<ExportFormat, string> = {
fountain: '.fountain',
fdx: '.fdx',
pdf: '.pdf',
'screenplay-pro': '.sp',
};

149
src/lib/projects/service.ts Normal file
View File

@@ -0,0 +1,149 @@
import { createSignal, createEffect, Accessor } from 'solid-js';
import { Project, ProjectStatus, ProjectCollaborator, UserRole } from '../auth/types';
const STORAGE_KEY = 'frenocorp_projects';
function loadProjects(): Project[] {
try {
const data = localStorage.getItem(STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch {
return [];
}
}
function saveProjects(projects: Project[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(projects));
}
export interface ProjectService {
projects: Accessor<Project[]>;
loading: Accessor<boolean>;
createProject: (name: string, description: string, ownerId: string) => Promise<Project>;
updateProject: (id: string, updates: Partial<Project>) => Promise<Project>;
deleteProject: (id: string) => Promise<void>;
addCollaborator: (projectId: string, userId: string, role: UserRole) => Promise<Project>;
removeCollaborator: (projectId: string, userId: string) => Promise<Project>;
archiveProject: (id: string) => Promise<Project>;
}
export function createProjectService(): ProjectService {
const [projects, setProjects] = createSignal<Project[]>(loadProjects());
const [loading, setLoading] = createSignal(false);
createEffect(() => {
const current = projects();
saveProjects(current);
});
const createProject = async (
name: string,
description: string,
ownerId: string
): Promise<Project> => {
setLoading(true);
const project: Project = {
id: generateProjectId(),
name,
description,
ownerId,
status: 'draft',
collaborators: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setProjects((prev) => [project, ...prev]);
setLoading(false);
return project;
};
const updateProject = async (
id: string,
updates: Partial<Project>
): Promise<Project> => {
setLoading(true);
setProjects((prev) =>
prev.map((p) =>
p.id === id
? { ...p, ...updates, updatedAt: new Date().toISOString() }
: p
)
);
setLoading(false);
return projects().find((p) => p.id === id)!;
};
const deleteProject = async (id: string): Promise<void> => {
setLoading(true);
setProjects((prev) => prev.filter((p) => p.id !== id));
setLoading(false);
};
const addCollaborator = async (
projectId: string,
userId: string,
role: UserRole
): Promise<Project> => {
setLoading(true);
setProjects((prev) =>
prev.map((p) => {
if (p.id !== projectId) return p;
const existing = p.collaborators.find((c) => c.userId === userId);
if (existing) return p;
return {
...p,
collaborators: [
...p.collaborators,
{ userId, role, addedAt: new Date().toISOString() },
],
updatedAt: new Date().toISOString(),
};
})
);
setLoading(false);
return projects().find((p) => p.id === projectId)!;
};
const removeCollaborator = async (
projectId: string,
userId: string
): Promise<Project> => {
setLoading(true);
setProjects((prev) =>
prev.map((p) => {
if (p.id !== projectId) return p;
return {
...p,
collaborators: p.collaborators.filter((c) => c.userId !== userId),
updatedAt: new Date().toISOString(),
};
})
);
setLoading(false);
return projects().find((p) => p.id === projectId)!;
};
const archiveProject = async (id: string): Promise<Project> => {
return updateProject(id, { status: 'archived' });
};
return {
projects,
loading,
createProject,
updateProject,
deleteProject,
addCollaborator,
removeCollaborator,
archiveProject,
};
}
export function generateProjectId(): string {
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
export function useProjectService() {
return createProjectService();
}

View File

@@ -21,7 +21,7 @@ export interface WebRTCVideoManagerOptions {
export interface PeerConnection {
peerId: string;
connection: DataConnection;
connection: MediaConnection;
stream: MediaStream | null;
state: PeerConnectionState;
quality: ConnectionQuality;
@@ -37,14 +37,12 @@ export interface VideoManagerEvents {
'state:changed': (state: PeerConnectionState) => void;
}
class DataConnection {
private peer: Peer;
class MediaConnection {
private conn: any;
private stream: MediaStream | null = null;
private listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
constructor(peer: Peer, conn: any) {
this.peer = peer;
constructor(conn: any) {
this.conn = conn;
this.conn.on('stream', (stream: MediaStream) => {
@@ -73,11 +71,6 @@ class DataConnection {
this.conn.send(data);
}
pushStream(stream: MediaStream): void {
this.stream = stream;
this.conn.sendStream(stream);
}
getStream(): MediaStream | null {
return this.stream;
}
@@ -105,7 +98,7 @@ class DataConnection {
export class WebRTCVideoManager extends EventEmitter {
private peer: Peer | null = null;
private options: WebRTCVideoManagerOptions;
private connections: Map<string, DataConnection> = new Map();
private connections: Map<string, MediaConnection> = new Map();
private localStream: MediaStream | null = null;
private state: PeerConnectionState = 'disconnected';
private qualityMetrics: Map<string, ConnectionQuality> = new Map();
@@ -130,27 +123,23 @@ export class WebRTCVideoManager extends EventEmitter {
await this.acquireLocalStream();
// Initialize PeerJS
this.peer = new Peer(this.options.peerId, {
host: new URL(this.options.serverUrl).hostname,
port: new URL(this.options.serverUrl).port || 443,
const serverUrl = new URL(this.options.serverUrl ?? 'https://0.peerjs.com:443');
this.peer = new Peer(this.options.peerId ?? 'unknown', {
host: serverUrl.hostname,
port: Number(serverUrl.port || 443),
path: '/webrtc',
secure: true,
secure: serverUrl.protocol === 'https:',
config: {
iceServers: this.options.turnServers,
},
});
// Handle incoming connections
this.peer.on('connection', (conn: any) => {
const dataConn = new DataConnection(this.peer!, conn);
this.connections.set(conn.peer, dataConn);
this.peer.on('call', (conn: any) => {
const mediaConn = new MediaConnection(conn);
this.connections.set(conn.peer, mediaConn);
// Send local stream to new peer
if (this.localStream) {
dataConn.pushStream(this.localStream);
}
this.emit('peer:connected', conn.peer, this.localStream!);
this.emit('peer:connected', conn.peer, conn.stream);
this.updateState('connected');
});
@@ -196,44 +185,33 @@ export class WebRTCVideoManager extends EventEmitter {
}
}
connectToPeer(peerId: string): DataConnection {
connectToPeer(peerId: string, stream?: MediaStream): MediaConnection {
if (!this.peer) {
throw new Error('Peer not initialized. Call initialize() first.');
}
const conn = this.peer.connect(peerId, {
metadata: {
peerId: this.peer.id,
timestamp: Date.now(),
},
});
const dataConn = new DataConnection(this.peer, conn);
this.connections.set(peerId, dataConn);
// Send local stream
if (this.localStream) {
dataConn.pushStream(this.localStream);
}
const conn = this.peer.call(peerId, stream ?? this.localStream!);
const mediaConn = new MediaConnection(conn);
this.connections.set(peerId, mediaConn);
// Monitor connection quality
dataConn.on('open', () => {
this.startQualityMonitoring(peerId, dataConn);
mediaConn.on('open', () => {
this.startQualityMonitoring(peerId, mediaConn);
});
dataConn.on('close', () => {
mediaConn.on('close', () => {
this.qualityMetrics.delete(peerId);
this.emit('peer:disconnected', peerId);
});
return dataConn;
return mediaConn;
}
getPeerConnection(peerId: string): DataConnection | undefined {
getPeerConnection(peerId: string): MediaConnection | undefined {
return this.connections.get(peerId);
}
getAllConnections(): Map<string, DataConnection> {
getAllConnections(): Map<string, MediaConnection> {
return new Map(this.connections);
}
@@ -308,17 +286,12 @@ export class WebRTCVideoManager extends EventEmitter {
return this.qualityMetrics.get(peerId) || 'fair';
}
private startQualityMonitoring(peerId: string, conn: DataConnection): void {
let packetLoss = 0;
let latencySamples: number[] = [];
private startQualityMonitoring(peerId: string, conn: MediaConnection): void {
const checkQuality = () => {
// Simple quality estimation based on connection state
if (conn) {
const quality: ConnectionQuality = 'good';
this.qualityMetrics.set(peerId, quality);
this.emit('connection:quality', peerId, quality);
}
// TODO: Implement actual RTCPeerConnection stats via getStats()
const quality: ConnectionQuality = 'good';
this.qualityMetrics.set(peerId, quality);
this.emit('connection:quality', peerId, quality);
};
// Check quality every 5 seconds