FRE-594: Implement revision tracking and version history system

Add complete revision tracking system for scripts with:
- Database schema for revisions and revision_changes tables
- Diff engine with color-coded change types (addition/deletion/modification)
- tRPC router with 14 endpoints (create/list/compare/rollback/branch/merge)
- SolidJS components: RevisionTimeline, DiffViewer, RevisionReview
- Unit tests for diff engine and router

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-24 05:54:06 -04:00
parent 8dc4827597
commit ccbf3039d9
12 changed files with 1751 additions and 3 deletions

View File

@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { computeDiff, getChangeColor, getChangeBackgroundColor, formatChangeSummary } from './diff';
describe('computeDiff', () => {
it('should detect additions', () => {
const oldContent = 'line1\nline2';
const newContent = 'line1\nline2\nline3';
const result = computeDiff(oldContent, newContent, 1);
expect(result.additions).toBe(1);
expect(result.deletions).toBe(0);
expect(result.modifications).toBe(0);
expect(result.changes).toHaveLength(1);
expect(result.changes[0]!.changeType).toBe('addition');
expect(result.changes[0]!.newContent).toBe('line3');
});
it('should detect deletions', () => {
const oldContent = 'line1\nline2\nline3';
const newContent = 'line1\nline2';
const result = computeDiff(oldContent, newContent, 1);
expect(result.additions).toBe(0);
expect(result.deletions).toBe(1);
expect(result.modifications).toBe(0);
expect(result.changes[0]!.changeType).toBe('deletion');
expect(result.changes[0]!.oldContent).toBe('line3');
});
it('should detect modifications', () => {
const oldContent = 'line1\nline2\nline3';
const newContent = 'line1\nchanged\nline3';
const result = computeDiff(oldContent, newContent, 1);
expect(result.additions).toBe(0);
expect(result.deletions).toBe(0);
expect(result.modifications).toBe(1);
expect(result.changes[0]!.changeType).toBe('modification');
expect(result.changes[0]!.oldContent).toBe('line2');
expect(result.changes[0]!.newContent).toBe('changed');
});
it('should return no changes for identical content', () => {
const content = 'line1\nline2\nline3';
const result = computeDiff(content, content, 1);
expect(result.additions).toBe(0);
expect(result.deletions).toBe(0);
expect(result.modifications).toBe(0);
expect(result.changes).toHaveLength(0);
});
it('should track line numbers correctly', () => {
const oldContent = 'a\nb\nc\nd';
const newContent = 'a\nx\nc\nd';
const result = computeDiff(oldContent, newContent, 1);
expect(result.changes[0]!.lineNumber).toBe(2);
});
it('should handle empty old content', () => {
const newContent = 'line1\nline2';
const result = computeDiff('', newContent, 1);
expect(result.additions).toBeGreaterThan(0);
expect(result.deletions).toBe(0);
});
it('should handle empty new content', () => {
const oldContent = 'line1\nline2';
const result = computeDiff(oldContent, '', 1);
expect(result.deletions).toBeGreaterThan(0);
expect(result.additions).toBe(0);
});
it('should set revision ID on all changes', () => {
const result = computeDiff('old', 'new', 42);
expect(result.changes[0]!.revisionId).toBe(42);
});
});
describe('getChangeColor', () => {
it('should return green for additions', () => {
expect(getChangeColor('addition')).toBe('#22c55e');
});
it('should return red for deletions', () => {
expect(getChangeColor('deletion')).toBe('#ef4444');
});
it('should return amber for modifications', () => {
expect(getChangeColor('modification')).toBe('#f59e0b');
});
});
describe('getChangeBackgroundColor', () => {
it('should return green background for additions', () => {
expect(getChangeBackgroundColor('addition')).toBe('rgba(34, 197, 94, 0.15)');
});
it('should return red background for deletions', () => {
expect(getChangeBackgroundColor('deletion')).toBe('rgba(239, 68, 68, 0.15)');
});
it('should return amber background for modifications', () => {
expect(getChangeBackgroundColor('modification')).toBe('rgba(245, 158, 11, 0.15)');
});
});
describe('formatChangeSummary', () => {
it('should format additions only', () => {
const summary = formatChangeSummary({ additions: 3, deletions: 0, modifications: 0, changes: [] });
expect(summary).toBe('+3 added');
});
it('should format deletions only', () => {
const summary = formatChangeSummary({ additions: 0, deletions: 2, modifications: 0, changes: [] });
expect(summary).toBe('-2 removed');
});
it('should format modifications only', () => {
const summary = formatChangeSummary({ additions: 0, deletions: 0, modifications: 1, changes: [] });
expect(summary).toBe('~1 modified');
});
it('should format multiple change types', () => {
const summary = formatChangeSummary({ additions: 2, deletions: 1, modifications: 3, changes: [] });
expect(summary).toBe('+2 added, -1 removed, ~3 modified');
});
it('should return no changes message for empty diff', () => {
const summary = formatChangeSummary({ additions: 0, deletions: 0, modifications: 0, changes: [] });
expect(summary).toBe('No changes');
});
});

143
src/lib/revisions/diff.ts Normal file
View File

@@ -0,0 +1,143 @@
import type {
ChangeType,
DiffResult,
RevisionChangeData,
} from './types';
import type { ScreenplayElementType } from '../screenplay/types';
import { detectElementType } from '../screenplay/detect';
interface LineDiffItem {
type: ChangeType;
line: string;
oldLine?: string;
}
function computeLineDiff(
oldLines: string[],
newLines: string[]
): LineDiffItem[] {
const result: LineDiffItem[] = [];
let oldIdx = 0;
let newIdx = 0;
while (oldIdx < oldLines.length || newIdx < newLines.length) {
if (oldIdx < oldLines.length && newIdx < newLines.length) {
const oldLine = oldLines[oldIdx]!;
const newLine = newLines[newIdx]!;
if (oldLine === newLine) {
oldIdx++;
newIdx++;
continue;
}
result.push({
type: 'modification',
line: newLine,
oldLine: oldLine,
});
oldIdx++;
newIdx++;
} else if (oldIdx < oldLines.length) {
const oldLine = oldLines[oldIdx]!;
result.push({
type: 'deletion',
line: oldLine,
oldLine: oldLine,
});
oldIdx++;
} else {
const newLine = newLines[newIdx]!;
result.push({ type: 'addition', line: newLine });
newIdx++;
}
}
return result;
}
export function computeDiff(
oldContent: string,
newContent: string,
revisionId: number
): DiffResult {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const lineDiffs = computeLineDiff(oldLines, newLines);
const changes: RevisionChangeData[] = [];
let sceneCounter = 0;
const linesPerPage = 55;
for (let i = 0; i < lineDiffs.length; i++) {
const diff = lineDiffs[i];
if (!diff) continue;
const lineToDetect = diff.line.trim();
const elementType = lineToDetect
? (detectElementType(lineToDetect) as ScreenplayElementType)
: null;
if (elementType === 'sceneHeading') {
sceneCounter++;
}
const lineNumber = i + 1;
const currentPage = Math.ceil(lineNumber / linesPerPage);
const change: RevisionChangeData = {
id: 0,
revisionId,
changeType: diff.type,
elementType,
oldContent: diff.oldLine || null,
newContent: diff.type !== 'deletion' ? diff.line : null,
sceneNumber: sceneCounter || null,
lineNumber,
pageNumber: currentPage,
createdAt: new Date(),
};
changes.push(change);
}
const additions = changes.filter((c) => c.changeType === 'addition').length;
const deletions = changes.filter((c) => c.changeType === 'deletion').length;
const modifications = changes.filter(
(c) => c.changeType === 'modification'
).length;
return { additions, deletions, modifications, changes };
}
export function getChangeColor(changeType: ChangeType): string {
switch (changeType) {
case 'addition':
return '#22c55e';
case 'deletion':
return '#ef4444';
case 'modification':
return '#f59e0b';
}
}
export function getChangeBackgroundColor(changeType: ChangeType): string {
switch (changeType) {
case 'addition':
return 'rgba(34, 197, 94, 0.15)';
case 'deletion':
return 'rgba(239, 68, 68, 0.15)';
case 'modification':
return 'rgba(245, 158, 11, 0.15)';
}
}
export function formatChangeSummary(diff: DiffResult): string {
const parts: string[] = [];
if (diff.additions > 0) parts.push(`+${diff.additions} added`);
if (diff.deletions > 0) parts.push(`-${diff.deletions} removed`);
if (diff.modifications > 0) parts.push(`~${diff.modifications} modified`);
return parts.join(', ') || 'No changes';
}

View File

@@ -0,0 +1,2 @@
export * from './types';
export * from './diff';

View File

@@ -0,0 +1,91 @@
import type { ScreenplayElementType } from '../screenplay/types';
export type RevisionStatus = 'draft' | 'pending_review' | 'accepted' | 'rejected';
export type ChangeType = 'addition' | 'deletion' | 'modification';
export interface RevisionData {
id: number;
scriptId: number;
versionNumber: number;
branchName: string;
parentRevisionId: number | null;
title: string;
summary: string | null;
content: string;
authorId: number;
status: RevisionStatus;
reviewedById: number | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
export interface RevisionChangeData {
id: number;
revisionId: number;
changeType: ChangeType;
elementType: ScreenplayElementType | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}
export interface DiffResult {
additions: number;
deletions: number;
modifications: number;
changes: RevisionChangeData[];
}
export interface RevisionComparison {
baseRevision: RevisionData;
targetRevision: RevisionData;
diff: DiffResult;
baseContent: string;
targetContent: string;
}
export interface CreateRevisionInput {
scriptId: number;
title: string;
summary?: string;
content: string;
branchName?: string;
parentRevisionId?: number;
authorId: number;
}
export interface AcceptRevisionInput {
revisionId: number;
reviewedById: number;
}
export interface RejectRevisionInput {
revisionId: number;
reviewedById: number;
reason?: string;
}
export interface RollbackRevisionInput {
scriptId: number;
revisionId: number;
authorId: number;
}
export interface RevisionTimelineEntry {
revision: RevisionData;
changeCount: number;
additions: number;
deletions: number;
modifications: number;
}
export interface BranchInfo {
branchName: string;
revisionCount: number;
latestVersion: number;
latestRevision: RevisionData | null;
}