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