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>
562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
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;
|
|
}),
|
|
};
|