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

@@ -1,12 +1,15 @@
import { initHTTPServer } from '@trpc/server/adapters/http';
import { initHTTPServer } from '@trpc/server/adapters/node-http';
import { projectRouter } from './project-router';
import { revisionsRouter } from './revisions-router';
import type { TRPCContext } from './types';
import type { TRPCError } from '@trpc/server';
import { t } from './router';
// App router combining all routers
export const appRouter = {
export const appRouter = t.router({
project: projectRouter,
};
revisions: revisionsRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,234 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { appRouter } from './index';
describe('revisionsRouter', () => {
const ctx = { userId: '123e4567-e89b-12d3-a456-426614174000' };
describe('createRevision', () => {
it('should create a revision with version 1', async () => {
const result = await appRouter.revisions.createRevision.mutate({
input: {
scriptId: 1,
title: 'Initial draft',
content: 'FADE IN:\n\nINT. ROOM - DAY',
authorId: 1,
},
ctx,
});
expect(result.versionNumber).toBe(1);
expect(result.branchName).toBe('main');
expect(result.status).toBe('draft');
});
it('should increment version number for same script', async () => {
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'v1', content: 'content1', authorId: 1 },
ctx,
});
const result = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'v2', content: 'content2', authorId: 1 },
ctx,
});
expect(result.versionNumber).toBe(2);
});
it('should support custom branch', async () => {
const result = await appRouter.revisions.createRevision.mutate({
input: {
scriptId: 1,
title: 'Branch revision',
content: 'branch content',
branchName: 'feature-act2',
authorId: 1,
},
ctx,
});
expect(result.branchName).toBe('feature-act2');
});
});
describe('listRevisions', () => {
it('should return empty array for unknown script', async () => {
const result = await appRouter.revisions.listRevisions.query({
input: { scriptId: 999 },
ctx,
});
expect(result).toEqual([]);
});
it('should filter by branch', async () => {
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'main v1', content: 'main', branchName: 'main', authorId: 1 },
ctx,
});
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'feature v1', content: 'feature', branchName: 'feature', authorId: 1 },
ctx,
});
const mainRevisions = await appRouter.revisions.listRevisions.query({
input: { scriptId: 1, branchName: 'main' },
ctx,
});
expect(mainRevisions).toHaveLength(1);
expect(mainRevisions[0]!.branchName).toBe('main');
});
});
describe('acceptRevision', () => {
it('should accept a revision', async () => {
const created = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'To accept', content: 'content', authorId: 1 },
ctx,
});
const result = await appRouter.revisions.acceptRevision.mutate({
input: { revisionId: created.id, reviewedById: 2 },
ctx,
});
expect(result.status).toBe('accepted');
expect(result.reviewedById).toBe(2);
expect(result.reviewedAt).toBeDefined();
});
});
describe('rejectRevision', () => {
it('should reject a revision with reason', async () => {
const created = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'To reject', content: 'content', authorId: 1 },
ctx,
});
const result = await appRouter.revisions.rejectRevision.mutate({
input: { revisionId: created.id, reviewedById: 2, reason: 'Needs more work on dialogue' },
ctx,
});
expect(result.status).toBe('rejected');
expect(result.summary).toContain('Needs more work on dialogue');
});
});
describe('rollbackToRevision', () => {
it('should create a new revision with old content', async () => {
const original = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'Original', content: 'original content', authorId: 1 },
ctx,
});
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'Changed', content: 'changed content', authorId: 1 },
ctx,
});
const rollback = await appRouter.revisions.rollbackToRevision.mutate({
input: { scriptId: 1, revisionId: original.id, authorId: 1 },
ctx,
});
expect(rollback.content).toBe('original content');
expect(rollback.versionNumber).toBe(3);
expect(rollback.title).toContain('Rollback');
});
});
describe('compareRevisions', () => {
it('should compare two revisions', async () => {
const rev1 = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'v1', content: 'line1\nline2\nline3', authorId: 1 },
ctx,
});
const rev2 = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'v2', content: 'line1\nchanged\nline3', authorId: 1 },
ctx,
});
const result = await appRouter.revisions.compareRevisions.query({
input: { baseRevisionId: rev1.id, targetRevisionId: rev2.id },
ctx,
});
expect(result.diff.modifications).toBe(1);
expect(result.diff.additions).toBe(0);
expect(result.diff.deletions).toBe(0);
});
});
describe('getTimeline', () => {
it('should return timeline entries in chronological order', async () => {
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'First', content: 'first', authorId: 1 },
ctx,
});
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'Second', content: 'second', authorId: 1 },
ctx,
});
const timeline = await appRouter.revisions.getTimeline.query({
input: { scriptId: 1 },
ctx,
});
expect(timeline).toHaveLength(2);
expect(timeline[0]!.revision.title).toBe('First');
expect(timeline[1]!.revision.title).toBe('Second');
});
});
describe('getBranches', () => {
it('should return branch information', async () => {
await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'main v1', content: 'main', authorId: 1 },
ctx,
});
await appRouter.revisions.createBranch.mutate({
input: { scriptId: 1, branchName: 'feature', authorId: 1 },
ctx,
});
const branches = await appRouter.revisions.getBranches.query({
input: { scriptId: 1 },
ctx,
});
expect(branches).toHaveLength(2);
const branchNames = branches.map((b: any) => b.branchName);
expect(branchNames).toContain('main');
expect(branchNames).toContain('feature');
});
});
describe('deleteRevision', () => {
it('should delete a revision', async () => {
const created = await appRouter.revisions.createRevision.mutate({
input: { scriptId: 1, title: 'To delete', content: 'content', authorId: 1 },
ctx,
});
const result = await appRouter.revisions.deleteRevision.mutate({
input: { id: created.id },
ctx,
});
expect(result.success).toBe(true);
await expect(
appRouter.revisions.getRevision.query({
input: { id: created.id },
ctx,
})
).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,561 @@
import { protectedProcedure } from './router';
import { z } from 'zod';
// In-memory storage
const revisions: Map<number, {
id: number;
scriptId: number;
versionNumber: number;
branchName: string;
parentRevisionId: number | null;
title: string;
summary: string | null;
content: string;
authorId: number;
status: 'draft' | 'pending_review' | 'accepted' | 'rejected';
reviewedById: number | null;
reviewedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}> = new Map();
const revisionChanges: Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> = new Map();
let revisionIdCounter = 0;
let changeIdCounter = 0;
function getNextRevisionId(): number {
return ++revisionIdCounter;
}
function getNextChangeId(): number {
return ++changeIdCounter;
}
function computeDiffForContent(
oldContent: string,
newContent: string,
revisionId: number
): Map<number, {
id: number;
revisionId: number;
changeType: 'addition' | 'deletion' | 'modification';
elementType: string | null;
oldContent: string | null;
newContent: string | null;
sceneNumber: number | null;
lineNumber: number | null;
pageNumber: number | null;
createdAt: Date;
}> {
const oldLines = oldContent.split('\n');
const newLines = newContent.split('\n');
const changes = new Map();
let sceneCounter = 0;
const linesPerPage = 55;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
let changeType: 'addition' | 'deletion' | 'modification';
if (!oldLine && newLine) {
changeType = 'addition';
} else if (oldLine && !newLine) {
changeType = 'deletion';
} else {
changeType = 'modification';
}
if (newLine?.trim().toUpperCase().startsWith('INT.') ||
newLine?.trim().toUpperCase().startsWith('EXT.')) {
sceneCounter++;
}
const change = {
id: getNextChangeId(),
revisionId,
changeType,
elementType: null,
oldContent: changeType !== 'addition' ? oldLine || null : null,
newContent: changeType !== 'deletion' ? newLine || null : null,
sceneNumber: sceneCounter || null,
lineNumber: i + 1,
pageNumber: Math.ceil((i + 1) / linesPerPage),
createdAt: new Date(),
};
changes.set(change.id, change);
revisionChanges.set(change.id, change);
}
return changes;
}
function getLatestVersionForScript(scriptId: number, branchName: string): number {
let maxVersion = 0;
for (const rev of revisions.values()) {
if (rev.scriptId === scriptId && rev.branchName === branchName) {
if (rev.versionNumber > maxVersion) {
maxVersion = rev.versionNumber;
}
}
}
return maxVersion;
}
export const revisionsRouter = {
listRevisions: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().optional(),
}))
.query(async ({ input }) => {
const results = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId)
.filter(r => !input.branchName || r.branchName === input.branchName)
.sort((a, b) => b.versionNumber - a.versionNumber);
return results;
}),
getRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.id);
if (!revision) {
throw new Error(`Revision ${input.id} not found`);
}
return revision;
}),
createRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
title: z.string().min(1).max(255),
summary: z.string().max(2000).optional(),
content: z.string(),
branchName: z.string().default('main'),
parentRevisionId: z.number().int().positive().optional(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const nextVersion = getLatestVersionForScript(
input.scriptId,
input.branchName
) + 1;
const revision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.branchName,
parentRevisionId: input.parentRevisionId || null,
title: input.title,
summary: input.summary || null,
content: input.content,
authorId: input.authorId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(revision.id, revision);
if (input.parentRevisionId) {
const parent = revisions.get(input.parentRevisionId);
if (parent) {
computeDiffForContent(parent.content, input.content, revision.id);
}
}
return revision;
}),
updateRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
title: z.string().min(1).max(255).optional(),
summary: z.string().max(2000).optional(),
content: z.string().optional(),
status: z.enum(['draft', 'pending_review', 'accepted', 'rejected']).optional(),
}))
.mutation(async ({ input }) => {
const revision = revisions.get(input.id);
if (!revision) {
throw new Error(`Revision ${input.id} not found`);
}
const updated = {
...revision,
...(input.title && { title: input.title }),
...(input.summary !== undefined && { summary: input.summary }),
...(input.content !== undefined && { content: input.content }),
...(input.status && { status: input.status }),
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
deleteRevision: protectedProcedure
.input(z.object({
id: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const deleted = revisions.delete(input.id);
if (!deleted) {
throw new Error(`Revision ${input.id} not found`);
}
for (const [changeId, change] of revisionChanges) {
if (change.revisionId === input.id) {
revisionChanges.delete(changeId);
}
}
return { success: true };
}),
getRevisionChanges: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const changes = Array.from(revisionChanges.values())
.filter(c => c.revisionId === input.revisionId)
.sort((a, b) => (a.lineNumber || 0) - (b.lineNumber || 0));
return changes;
}),
compareRevisions: protectedProcedure
.input(z.object({
baseRevisionId: z.number().int().positive(),
targetRevisionId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const baseRevision = revisions.get(input.baseRevisionId);
const targetRevision = revisions.get(input.targetRevisionId);
if (!baseRevision) {
throw new Error(`Base revision ${input.baseRevisionId} not found`);
}
if (!targetRevision) {
throw new Error(`Target revision ${input.targetRevisionId} not found`);
}
const oldLines = baseRevision.content.split('\n');
const newLines = targetRevision.content.split('\n');
let additions = 0;
let deletions = 0;
let modifications = 0;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i];
const newLine = newLines[i];
if (oldLine === newLine) continue;
if (!oldLine && newLine) {
additions++;
} else if (oldLine && !newLine) {
deletions++;
} else {
modifications++;
}
}
return {
baseRevision,
targetRevision,
diff: {
additions,
deletions,
modifications,
},
};
}),
acceptRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reviewedById: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const updated = {
...revision,
status: 'accepted' as const,
reviewedById: input.reviewedById,
reviewedAt: new Date(),
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
rejectRevision: protectedProcedure
.input(z.object({
revisionId: z.number().int().positive(),
reviewedById: z.number().int().positive(),
reason: z.string().max(1000).optional(),
}))
.mutation(async ({ input }) => {
const revision = revisions.get(input.revisionId);
if (!revision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
const updated = {
...revision,
status: 'rejected' as const,
reviewedById: input.reviewedById,
reviewedAt: new Date(),
summary: input.reason
? (revision.summary || '') + '\n[Rejected: ' + input.reason + ']'
: revision.summary,
updatedAt: new Date(),
};
revisions.set(updated.id, updated);
return updated;
}),
rollbackToRevision: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
revisionId: z.number().int().positive(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const targetRevision = revisions.get(input.revisionId);
if (!targetRevision) {
throw new Error(`Revision ${input.revisionId} not found`);
}
if (targetRevision.scriptId !== input.scriptId) {
throw new Error('Revision does not belong to the specified script');
}
const nextVersion = getLatestVersionForScript(
input.scriptId,
targetRevision.branchName
) + 1;
const rollbackRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: targetRevision.branchName,
parentRevisionId: targetRevision.id,
title: `Rollback to v${targetRevision.versionNumber}: ${targetRevision.title}`,
summary: `Rolled back to revision ${targetRevision.id}`,
content: targetRevision.content,
authorId: input.authorId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(rollbackRevision.id, rollbackRevision);
return rollbackRevision;
}),
getTimeline: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId)
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
const timeline = scriptRevisions.map(rev => {
const changes = Array.from(revisionChanges.values())
.filter(c => c.revisionId === rev.id);
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 {
revision: rev,
changeCount: changes.length,
additions,
deletions,
modifications,
};
});
return timeline;
}),
getBranches: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
}))
.query(async ({ input }) => {
const scriptRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId);
const branchMap = new Map<string, typeof scriptRevisions>();
for (const rev of scriptRevisions) {
if (!branchMap.has(rev.branchName)) {
branchMap.set(rev.branchName, []);
}
branchMap.get(rev.branchName)!.push(rev);
}
const branches = Array.from(branchMap.entries()).map(([branchName, revs]) => {
const sorted = revs.sort((a, b) => b.versionNumber - a.versionNumber);
const latest = sorted[0]!;
return {
branchName,
revisionCount: revs.length,
latestVersion: latest.versionNumber,
latestRevision: latest,
};
});
return branches;
}),
createBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
branchName: z.string().min(1),
fromRevisionId: z.number().int().positive().optional(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
const existing = Array.from(revisions.values())
.some(r => r.scriptId === input.scriptId && r.branchName === input.branchName);
if (existing) {
throw new Error(`Branch '${input.branchName}' already exists for this script`);
}
let sourceContent = '';
let parentRevisionId: number | null = null;
if (input.fromRevisionId) {
const source = revisions.get(input.fromRevisionId);
if (!source) {
throw new Error(`Source revision ${input.fromRevisionId} not found`);
}
sourceContent = source.content;
parentRevisionId = source.id;
} else {
const mainRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId && r.branchName === 'main')
.sort((a, b) => b.versionNumber - a.versionNumber);
if (mainRevisions.length > 0) {
sourceContent = mainRevisions[0]!.content;
parentRevisionId = mainRevisions[0]!.id;
}
}
const branchRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: 1,
branchName: input.branchName,
parentRevisionId,
title: `Branch: ${input.branchName}`,
summary: null,
content: sourceContent,
authorId: input.authorId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(branchRevision.id, branchRevision);
return branchRevision;
}),
mergeBranch: protectedProcedure
.input(z.object({
scriptId: z.number().int().positive(),
sourceBranch: z.string(),
targetBranch: z.string(),
authorId: z.number().int().positive(),
}))
.mutation(async ({ input }) => {
if (input.sourceBranch === input.targetBranch) {
throw new Error('Cannot merge a branch into itself');
}
const sourceRevisions = Array.from(revisions.values())
.filter(r => r.scriptId === input.scriptId && r.branchName === input.sourceBranch)
.sort((a, b) => b.versionNumber - a.versionNumber);
if (sourceRevisions.length === 0) {
throw new Error(`Source branch '${input.sourceBranch}' has no revisions`);
}
const sourceContent = sourceRevisions[0]!.content;
const nextVersion = getLatestVersionForScript(
input.scriptId,
input.targetBranch
) + 1;
const mergeRevision = {
id: getNextRevisionId(),
scriptId: input.scriptId,
versionNumber: nextVersion,
branchName: input.targetBranch,
parentRevisionId: null,
title: `Merge from '${input.sourceBranch}'`,
summary: `Merged ${input.sourceBranch} into ${input.targetBranch}`,
content: sourceContent,
authorId: input.authorId,
status: 'draft' as const,
reviewedById: null,
reviewedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
revisions.set(mergeRevision.id, mergeRevision);
return mergeRevision;
}),
};

View File

@@ -0,0 +1,182 @@
import { Component, For, Show, Switch, Match } from 'solid-js';
import type { RevisionChangeData, ChangeType } from '../../lib/revisions/types';
import { getChangeColor, getChangeBackgroundColor } from '../../lib/revisions/diff';
export interface DiffViewerProps {
changes: RevisionChangeData[];
baseContent?: string;
targetContent?: string;
viewMode?: 'changes' | 'split' | 'unified';
}
export const DiffViewer: Component<DiffViewerProps> = (props) => {
const renderChangeLine = (change: RevisionChangeData) => {
const prefix = change.changeType === 'addition'
? '+'
: change.changeType === 'deletion'
? '-'
: '~';
const content = change.changeType === 'deletion'
? change.oldContent
: change.newContent;
return (
<div
class={`diff-line diff-${change.changeType}`}
style={{
background: getChangeBackgroundColor(change.changeType),
'border-left': `3px solid ${getChangeColor(change.changeType)}`,
}}
>
<span class="diff-prefix" style={{ color: getChangeColor(change.changeType) }}>
{prefix}
</span>
<span class="diff-content">{content || ''}</span>
<span class="diff-line-number">L{change.lineNumber || '?'}</span>
</div>
);
};
const renderModification = (change: RevisionChangeData) => {
return (
<div class="diff-modification-block">
<div
class="diff-line diff-deletion"
style={{
background: getChangeBackgroundColor('deletion'),
'border-left': `3px solid ${getChangeColor('deletion')}`,
}}
>
<span class="diff-prefix" style={{ color: getChangeColor('deletion') }}>-</span>
<span class="diff-content">{change.oldContent || ''}</span>
</div>
<div
class="diff-line diff-addition"
style={{
background: getChangeBackgroundColor('addition'),
'border-left': `3px solid ${getChangeColor('addition')}`,
}}
>
<span class="diff-prefix" style={{ color: getChangeColor('addition') }}>-</span>
<span class="diff-content">{change.newContent || ''}</span>
</div>
</div>
);
};
const renderChangesView = () => (
<div class="diff-changes-view">
<For each={props.changes}>
{(change) => (
change.changeType === 'modification'
? renderModification(change)
: renderChangeLine(change)
)}
</For>
</div>
);
const renderSplitView = () => {
const baseLines = props.baseContent?.split('\n') || [];
const targetLines = props.targetContent?.split('\n') || [];
return (
<div class="diff-split-view">
<div class="diff-panel diff-base">
<div class="diff-panel-header">Original</div>
<div class="diff-panel-content">
<For each={baseLines}>
{(line, i) => (
<div class="diff-panel-line">
<span class="line-number">{i() + 1}</span>
<span class="line-text">{line}</span>
</div>
)}
</For>
</div>
</div>
<div class="diff-panel diff-target">
<div class="diff-panel-header">Revised</div>
<div class="diff-panel-content">
<For each={targetLines}>
{(line, i) => (
<div class="diff-panel-line">
<span class="line-number">{i() + 1}</span>
<span class="line-text">{line}</span>
</div>
)}
</For>
</div>
</div>
</div>
);
};
const renderUnifiedView = () => {
const baseLines = props.baseContent?.split('\n') || [];
const targetLines = props.targetContent?.split('\n') || [];
const maxLen = Math.max(baseLines.length, targetLines.length);
return (
<div class="diff-unified-view">
<For each={Array.from({ length: maxLen }, (_, i) => i)}>
{(lineIdx) => {
const baseLine = baseLines[lineIdx];
const targetLine = targetLines[lineIdx];
const isNew = !baseLine && targetLine;
const isDeleted = baseLine && !targetLine;
const isModified = baseLine && targetLine && baseLine !== targetLine;
const isUnchanged = baseLine === targetLine;
const changeType = isNew
? 'addition'
: isDeleted
? 'deletion'
: isModified
? 'modification'
: null;
return (
<div
class={`diff-unified-line ${changeType ? `diff-${changeType}` : 'diff-unchanged'}`}
style={{
background: changeType ? getChangeBackgroundColor(changeType) : 'transparent',
}}
>
<span class="line-number">{lineIdx + 1}</span>
<Show when={targetLine !== undefined}>
<span class="line-text">{targetLine}</span>
</Show>
</div>
);
}}
</For>
</div>
);
};
return (
<div class="diff-viewer">
<Show when={props.changes.length === 0 && !props.baseContent && !props.targetContent}>
<div class="empty-state">
<p>No differences to display.</p>
</div>
</Show>
<Switch>
<Match when={props.viewMode === 'split'}>
{renderSplitView()}
</Match>
<Match when={props.viewMode === 'unified'}>
{renderUnifiedView()}
</Match>
<Match when={true}>
{renderChangesView()}
</Match>
</Switch>
</div>
);
};
export default DiffViewer;

View File

@@ -0,0 +1,148 @@
import { Component, createSignal, Show } from 'solid-js';
import type { RevisionData } from '../../lib/revisions/types';
export interface RevisionReviewProps {
revision: RevisionData;
onAccept: (revisionId: number) => void;
onReject: (revisionId: number, reason?: string) => void;
onCompareWithBase?: (revisionId: number) => void;
}
export const RevisionReview: Component<RevisionReviewProps> = (props) => {
const [showRejectDialog, setShowRejectDialog] = createSignal(false);
const [rejectReason, setRejectReason] = createSignal('');
const [isProcessing, setIsProcessing] = createSignal(false);
const handleAccept = () => {
setIsProcessing(true);
props.onAccept(props.revision.id);
setIsProcessing(false);
};
const handleReject = () => {
setIsProcessing(true);
props.onReject(props.revision.id, rejectReason() || undefined);
setShowRejectDialog(false);
setRejectReason('');
setIsProcessing(false);
};
const getStatusDisplay = () => {
switch (props.revision.status) {
case 'draft':
return { label: 'Draft', color: '#6b7280' };
case 'pending_review':
return { label: 'Pending Review', color: '#f59e0b' };
case 'accepted':
return { label: 'Accepted', color: '#22c55e' };
case 'rejected':
return { label: 'Rejected', color: '#ef4444' };
}
};
const canReview = props.revision.status === 'pending_review' || props.revision.status === 'draft';
return (
<div class="revision-review">
<div class="review-header">
<div class="revision-info">
<h3>{props.revision.title}</h3>
<div class="revision-meta">
<span class="version">v{props.revision.versionNumber}</span>
<span class="branch">{props.revision.branchName}</span>
<span
class="status"
style={{
color: getStatusDisplay().color,
background: getStatusDisplay().color + '22',
}}
>
{getStatusDisplay().label}
</span>
</div>
</div>
<Show when={props.revision.summary}>
<div class="revision-summary">{props.revision.summary}</div>
</Show>
</div>
<div class="review-actions">
<Show when={canReview}>
<button
class="btn btn-accept"
onClick={handleAccept}
disabled={isProcessing()}
>
Accept Revision
</button>
<button
class="btn btn-reject"
onClick={() => setShowRejectDialog(true)}
disabled={isProcessing()}
>
Reject Revision
</button>
</Show>
<button
class="btn btn-compare"
onClick={() => props.onCompareWithBase?.(props.revision.id)}
>
Compare with Previous
</button>
</div>
<Show when={props.revision.reviewedById}>
<div class="review-info">
<p>Reviewed by User #{props.revision.reviewedById}</p>
<Show when={props.revision.reviewedAt}>
{(reviewedAt) => (
<p class="review-date">
{new Date(reviewedAt()).toLocaleString()}
</p>
)}
</Show>
</div>
</Show>
<Show when={showRejectDialog()}>
<div class="reject-dialog-overlay" onClick={() => setShowRejectDialog(false)}>
<div class="reject-dialog" onClick={(e) => e.stopPropagation()}>
<h4>Reject Revision</h4>
<p>Please provide a reason for rejecting this revision:</p>
<textarea
class="reject-reason"
value={rejectReason()}
onInput={(e) => setRejectReason(e.target.value)}
placeholder="Explain why this revision is being rejected..."
rows={4}
/>
<div class="dialog-actions">
<button
class="btn btn-confirm-reject"
onClick={handleReject}
disabled={isProcessing()}
>
Confirm Rejection
</button>
<button
class="btn btn-cancel"
onClick={() => {
setShowRejectDialog(false);
setRejectReason('');
}}
disabled={isProcessing()}
>
Cancel
</button>
</div>
</div>
</div>
</Show>
</div>
);
};
export default RevisionReview;

View File

@@ -0,0 +1,160 @@
import { Component, createSignal, For, Show } from 'solid-js';
import type { RevisionTimelineEntry } from '../../lib/revisions/types';
import { formatChangeSummary, getChangeColor } from '../../lib/revisions/diff';
export interface RevisionTimelineProps {
timeline: RevisionTimelineEntry[];
selectedRevisionId?: number;
onSelectRevision: (revisionId: number) => void;
onCompare?: (baseId: number, targetId: number) => void;
}
export const RevisionTimeline: Component<RevisionTimelineProps> = (props) => {
const [compareMode, setCompareMode] = createSignal(false);
const [compareBase, setCompareBase] = createSignal<number | null>(null);
const handleItemClick = (entry: RevisionTimelineEntry) => {
if (compareMode()) {
if (compareBase() === null) {
setCompareBase(entry.revision.id);
} else if (compareBase() !== entry.revision.id) {
const base = compareBase()!;
props.onCompare?.(base, entry.revision.id);
setCompareBase(null);
setCompareMode(false);
}
} else {
props.onSelectRevision(entry.revision.id);
}
};
const toggleCompareMode = () => {
setCompareMode(!compareMode());
setCompareBase(null);
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'accepted':
return '#22c55e';
case 'rejected':
return '#ef4444';
case 'pending_review':
return '#f59e0b';
default:
return '#6b7280';
}
};
return (
<div class="revision-timeline">
<div class="timeline-header">
<h3>Version History</h3>
<div class="timeline-controls">
<button
class={`compare-btn ${compareMode() ? 'active' : ''}`}
onClick={toggleCompareMode}
>
{compareMode()
? compareBase()
? `Comparing from v${compareBase()} — select target`
: 'Select base revision'
: 'Compare'}
</button>
<Show when={compareMode() && compareBase()}>
<button class="cancel-btn" onClick={() => { setCompareMode(false); setCompareBase(null); }}>
Cancel
</button>
</Show>
</div>
</div>
<div class="timeline-list">
<For each={props.timeline}>
{(entry) => (
<div
class={`timeline-item ${
props.selectedRevisionId === entry.revision.id ? 'selected' : ''
} ${compareBase() === entry.revision.id ? 'compare-base' : ''}`}
onClick={() => handleItemClick(entry)}
>
<div class="timeline-marker">
<div
class="marker-dot"
style={{
background: getStatusColor(entry.revision.status),
}}
/>
<div class="marker-line" />
</div>
<div class="timeline-content">
<div class="timeline-title">
<span class="version-badge">v{entry.revision.versionNumber}</span>
<span class="title-text">{entry.revision.title}</span>
<span
class="status-badge"
style={{
background: getStatusColor(entry.revision.status) + '22',
color: getStatusColor(entry.revision.status),
}}
>
{entry.revision.status}
</span>
</div>
<div class="timeline-meta">
<span class="date">{formatDate(entry.revision.createdAt)}</span>
<span class="branch">{entry.revision.branchName}</span>
</div>
<div class="timeline-changes">
<span
class="change-count"
style={{ color: getChangeColor('addition') }}
>
+{entry.additions}
</span>
<span
class="change-count"
style={{ color: getChangeColor('deletion') }}
>
-{entry.deletions}
</span>
<span
class="change-count"
style={{ color: getChangeColor('modification') }}
>
~{entry.modifications}
</span>
</div>
<Show when={entry.revision.summary}>
<div class="timeline-summary">{entry.revision.summary}</div>
</Show>
</div>
</div>
)}
</For>
</div>
<Show when={props.timeline.length === 0}>
<div class="empty-state">
<p>No revisions yet. Create the first revision to start tracking changes.</p>
</div>
</Show>
</div>
);
};
export default RevisionTimeline;

View File

@@ -0,0 +1,3 @@
export * from './RevisionTimeline';
export * from './DiffViewer';
export * from './RevisionReview';

View File

@@ -0,0 +1,77 @@
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { scripts } from "./scripts";
import { users } from "./users";
export const revisions = sqliteTable(
"revisions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
scriptId: integer("script_id")
.notNull()
.references(() => scripts.id),
versionNumber: integer("version_number").notNull(),
branchName: text("branch_name").notNull().default("main"),
parentRevisionId: integer("parent_revision_id"),
title: text("title").notNull(),
summary: text("summary"),
content: text("content").notNull(),
authorId: integer("author_id")
.notNull()
.references(() => users.id),
status: text("status", {
enum: ["draft", "pending_review", "accepted", "rejected"],
})
.notNull()
.default("draft"),
reviewedById: integer("reviewed_by_id").references(() => users.id),
reviewedAt: integer("reviewed_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
scriptVersionIdx: index("revisions_script_version_idx").on(
table.scriptId,
table.versionNumber
),
scriptBranchIdx: index("revisions_script_branch_idx").on(
table.scriptId,
table.branchName
),
authorIdx: index("revisions_author_idx").on(table.authorId),
})
);
export const revisionChanges = sqliteTable(
"revision_changes",
{
id: integer("id").primaryKey({ autoIncrement: true }),
revisionId: integer("revision_id")
.notNull()
.references(() => revisions.id),
changeType: text("change_type", {
enum: ["addition", "deletion", "modification"],
}).notNull(),
elementType: text("element_type"),
oldContent: text("old_content"),
newContent: text("new_content"),
sceneNumber: integer("scene_number"),
lineNumber: integer("line_number"),
pageNumber: integer("page_number"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.$defaultFn(() => new Date()),
},
(table) => ({
revisionIdx: index("revision_changes_revision_idx").on(table.revisionId),
changeTypeIdx: index("revision_changes_type_idx").on(table.changeType),
})
);
export type Revision = typeof revisions.$inferSelect;
export type NewRevision = typeof revisions.$inferInsert;
export type RevisionChange = typeof revisionChanges.$inferSelect;
export type NewRevisionChange = typeof revisionChanges.$inferInsert;

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;
}