mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(comments): add secure CSV/PDF export for version comments with auth, rate limits, and CSV injection hardening
This commit is contained in:
+1
-1
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
|
|||||||
function formatBytes(bytes: number, decimals = 2) {
|
function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes < 0) return 'Error Fetching';
|
if (bytes < 0) return 'Error Fetching';
|
||||||
if (!+bytes) return '0 Bytes';
|
if (!+bytes) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1000;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const metadata: Metadata = {
|
|||||||
function formatBytes(bytes: number, decimals = 2) {
|
function formatBytes(bytes: number, decimals = 2) {
|
||||||
if (bytes < 0) return 'Error Fetching';
|
if (bytes < 0) return 'Error Fetching';
|
||||||
if (!+bytes) return '0 Bytes';
|
if (!+bytes) return '0 Bytes';
|
||||||
const k = 1024;
|
const k = 1000;
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import {
|
||||||
|
buildCommentsCsv,
|
||||||
|
buildCommentsPdf,
|
||||||
|
buildExportFileBaseName,
|
||||||
|
flattenCommentsForExport,
|
||||||
|
} from '@/lib/comment-export';
|
||||||
|
import { apiErrors, withCacheControl } from '@/lib/api-response';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ versionId: string }> };
|
||||||
|
const MAX_EXPORT_COMMENTS = 5000;
|
||||||
|
const EXPORT_RATE_LIMIT = { windowMs: 60 * 1000, maxRequests: 10 };
|
||||||
|
|
||||||
|
// GET /api/versions/[versionId]/comments/export?format=csv|pdf&includeResolved=true|false
|
||||||
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const limited = await rateLimit(request, 'comment-export', EXPORT_RATE_LIMIT);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized('Authentication required for exports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { versionId } = await params;
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const format = (searchParams.get('format') || 'csv').toLowerCase();
|
||||||
|
if (format !== 'csv' && format !== 'pdf') {
|
||||||
|
return apiErrors.badRequest('Invalid format. Use "csv" or "pdf"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeResolved = searchParams.get('includeResolved') !== 'false';
|
||||||
|
|
||||||
|
const version = await db.videoVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
versionNumber: true,
|
||||||
|
versionLabel: true,
|
||||||
|
video: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
ownerId: true,
|
||||||
|
workspaceId: true,
|
||||||
|
visibility: true,
|
||||||
|
members: {
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!version) {
|
||||||
|
return apiErrors.notFound('Version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = version.video.project;
|
||||||
|
const isOwner = session.user.id === project.ownerId;
|
||||||
|
const isMember = project.members.length > 0;
|
||||||
|
|
||||||
|
let isWorkspaceMember = false;
|
||||||
|
if (!isOwner && !isMember) {
|
||||||
|
const [workspaceMember, workspace] = await Promise.all([
|
||||||
|
db.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId: project.workspaceId,
|
||||||
|
userId: session.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
db.workspace.findUnique({
|
||||||
|
where: { id: project.workspaceId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
isWorkspaceMember = !!workspaceMember || workspace?.ownerId === session.user.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOwner && !isMember && !isWorkspaceMember) {
|
||||||
|
return apiErrors.notFound('Version');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalComments = await db.comment.count({
|
||||||
|
where: {
|
||||||
|
versionId,
|
||||||
|
...(includeResolved ? {} : { isResolved: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (totalComments > MAX_EXPORT_COMMENTS) {
|
||||||
|
return apiErrors.badRequest(
|
||||||
|
`Too many comments to export (${totalComments}). Maximum allowed is ${MAX_EXPORT_COMMENTS}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await db.comment.findMany({
|
||||||
|
where: {
|
||||||
|
versionId,
|
||||||
|
parentId: null,
|
||||||
|
...(includeResolved ? {} : { isResolved: false }),
|
||||||
|
},
|
||||||
|
orderBy: { timestamp: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
content: true,
|
||||||
|
timestamp: true,
|
||||||
|
timestampEnd: true,
|
||||||
|
isResolved: true,
|
||||||
|
voiceUrl: true,
|
||||||
|
voiceDuration: true,
|
||||||
|
imageUrl: true,
|
||||||
|
annotationData: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: { select: { name: true } },
|
||||||
|
guestName: true,
|
||||||
|
tag: { select: { name: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
content: true,
|
||||||
|
timestamp: true,
|
||||||
|
timestampEnd: true,
|
||||||
|
isResolved: true,
|
||||||
|
voiceUrl: true,
|
||||||
|
voiceDuration: true,
|
||||||
|
imageUrl: true,
|
||||||
|
annotationData: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: { select: { name: true } },
|
||||||
|
guestName: true,
|
||||||
|
tag: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = flattenCommentsForExport(comments);
|
||||||
|
const fileBaseName = buildExportFileBaseName(version.video.title, version.versionNumber);
|
||||||
|
const versionMeta = {
|
||||||
|
videoTitle: version.video.title,
|
||||||
|
versionNumber: version.versionNumber,
|
||||||
|
versionLabel: version.versionLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csv = buildCommentsCsv(rows, versionMeta);
|
||||||
|
const response = new Response(csv, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/csv; charset=utf-8',
|
||||||
|
'Content-Disposition': `attachment; filename="${fileBaseName}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdf = buildCommentsPdf(rows, versionMeta);
|
||||||
|
const pdfBytes = Uint8Array.from(pdf);
|
||||||
|
const response = new Response(pdfBytes, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${fileBaseName}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error exporting comments:', error);
|
||||||
|
return apiErrors.internalError('Failed to export comments');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
Minimize,
|
Minimize,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Download,
|
Download,
|
||||||
|
FileText,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -253,6 +254,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const voiceKnownDurationRef = useRef<number>(0);
|
const voiceKnownDurationRef = useRef<number>(0);
|
||||||
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
const [isExportingCsv, setIsExportingCsv] = useState(false);
|
||||||
|
const [isExportingPdf, setIsExportingPdf] = useState(false);
|
||||||
|
|
||||||
// Watch progress state
|
// Watch progress state
|
||||||
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
||||||
@@ -367,6 +370,64 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
setShowResolved(prev => !prev);
|
setShowResolved(prev => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleExportComments = useCallback(
|
||||||
|
async (format: 'csv' | 'pdf') => {
|
||||||
|
if (!activeVersionId) return;
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
setIsExportingCsv(true);
|
||||||
|
} else {
|
||||||
|
setIsExportingPdf(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/versions/${activeVersionId}/comments/export?format=${format}&includeResolved=${showResolved}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = 'Failed to export comments';
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
if (typeof data?.error === 'string') {
|
||||||
|
message = data.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep fallback message when response is not JSON.
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const disposition = response.headers.get('content-disposition');
|
||||||
|
const fallbackName = `comments.${format}`;
|
||||||
|
const matched = disposition?.match(/filename="?([^"]+)"?/i);
|
||||||
|
const filename = matched?.[1] || fallbackName;
|
||||||
|
|
||||||
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = downloadUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
|
|
||||||
|
toast.success(`Comments exported as ${format.toUpperCase()}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to export comments:', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to export comments');
|
||||||
|
} finally {
|
||||||
|
if (format === 'csv') {
|
||||||
|
setIsExportingCsv(false);
|
||||||
|
} else {
|
||||||
|
setIsExportingPdf(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeVersionId, showResolved]
|
||||||
|
);
|
||||||
|
|
||||||
const handleVideoMouseMove = useCallback(() => {
|
const handleVideoMouseMove = useCallback(() => {
|
||||||
setCursorIdle(false);
|
setCursorIdle(false);
|
||||||
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current);
|
||||||
@@ -3164,6 +3225,32 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
|
||||||
{showResolved ? 'Hide' : 'Show'} Resolved
|
{showResolved ? 'Hide' : 'Show'} Resolved
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExportComments('csv');
|
||||||
|
}}
|
||||||
|
title="Download comments as CSV"
|
||||||
|
>
|
||||||
|
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleExportComments('pdf');
|
||||||
|
}}
|
||||||
|
title="Download comments as PDF"
|
||||||
|
>
|
||||||
|
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
interface ExportAuthor {
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportTag {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportComment {
|
||||||
|
id: string;
|
||||||
|
parentId: string | null;
|
||||||
|
content: string | null;
|
||||||
|
timestamp: number;
|
||||||
|
timestampEnd: number | null;
|
||||||
|
isResolved: boolean;
|
||||||
|
voiceUrl: string | null;
|
||||||
|
voiceDuration: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
annotationData: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
author: ExportAuthor | null;
|
||||||
|
guestName: string | null;
|
||||||
|
tag: ExportTag | null;
|
||||||
|
replies: Omit<ExportComment, 'replies'>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportCommentRow {
|
||||||
|
commentId: string;
|
||||||
|
parentCommentId: string | null;
|
||||||
|
level: 0 | 1;
|
||||||
|
authorName: string;
|
||||||
|
authorType: 'user' | 'guest';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
timestampEnd: number | null;
|
||||||
|
tag: string;
|
||||||
|
isResolved: boolean;
|
||||||
|
hasVoiceNote: boolean;
|
||||||
|
voiceDuration: number | null;
|
||||||
|
hasImageAttachment: boolean;
|
||||||
|
hasAnnotation: boolean;
|
||||||
|
createdAtIso: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string | number | boolean | null): string {
|
||||||
|
const raw = value === null ? '' : String(value);
|
||||||
|
const neutralized = /^[\s]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
|
||||||
|
return `"${neutralized.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(seconds: number): string {
|
||||||
|
const totalSeconds = Math.floor(seconds);
|
||||||
|
const hrs = Math.floor(totalSeconds / 3600);
|
||||||
|
const mins = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const secs = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hrs > 0) {
|
||||||
|
return `${hrs}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPdfSafeAscii(value: string): string {
|
||||||
|
return value.replace(/[^\x20-\x7E]/g, '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePdfText(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLine(value: string, maxChars: number): string[] {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return [''];
|
||||||
|
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
const lines: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const next = current ? `${current} ${word}` : word;
|
||||||
|
if (next.length <= maxChars) {
|
||||||
|
current = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) lines.push(current);
|
||||||
|
|
||||||
|
if (word.length <= maxChars) {
|
||||||
|
current = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chunk = word;
|
||||||
|
while (chunk.length > maxChars) {
|
||||||
|
lines.push(chunk.slice(0, maxChars));
|
||||||
|
chunk = chunk.slice(maxChars);
|
||||||
|
}
|
||||||
|
current = chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current) lines.push(current);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeFileSegment(input: string): string {
|
||||||
|
const cleaned = input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
return cleaned || 'comments';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExportFileBaseName(videoTitle: string, versionNumber: number): string {
|
||||||
|
return `${sanitizeFileSegment(videoTitle)}-v${versionNumber}-comments`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenCommentsForExport(comments: ExportComment[]): ExportCommentRow[] {
|
||||||
|
const rows: ExportCommentRow[] = [];
|
||||||
|
|
||||||
|
for (const comment of comments) {
|
||||||
|
rows.push({
|
||||||
|
commentId: comment.id,
|
||||||
|
parentCommentId: null,
|
||||||
|
level: 0,
|
||||||
|
authorName: comment.author?.name || comment.guestName || 'Anonymous',
|
||||||
|
authorType: comment.author ? 'user' : 'guest',
|
||||||
|
content: comment.content || '',
|
||||||
|
timestamp: comment.timestamp,
|
||||||
|
timestampEnd: comment.timestampEnd,
|
||||||
|
tag: comment.tag?.name || '',
|
||||||
|
isResolved: comment.isResolved,
|
||||||
|
hasVoiceNote: !!comment.voiceUrl,
|
||||||
|
voiceDuration: comment.voiceDuration,
|
||||||
|
hasImageAttachment: !!comment.imageUrl,
|
||||||
|
hasAnnotation: !!comment.annotationData,
|
||||||
|
createdAtIso: comment.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const reply of comment.replies) {
|
||||||
|
rows.push({
|
||||||
|
commentId: reply.id,
|
||||||
|
parentCommentId: comment.id,
|
||||||
|
level: 1,
|
||||||
|
authorName: reply.author?.name || reply.guestName || 'Anonymous',
|
||||||
|
authorType: reply.author ? 'user' : 'guest',
|
||||||
|
content: reply.content || '',
|
||||||
|
timestamp: reply.timestamp,
|
||||||
|
timestampEnd: reply.timestampEnd,
|
||||||
|
tag: reply.tag?.name || '',
|
||||||
|
isResolved: reply.isResolved,
|
||||||
|
hasVoiceNote: !!reply.voiceUrl,
|
||||||
|
voiceDuration: reply.voiceDuration,
|
||||||
|
hasImageAttachment: !!reply.imageUrl,
|
||||||
|
hasAnnotation: !!reply.annotationData,
|
||||||
|
createdAtIso: reply.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommentsCsv(
|
||||||
|
rows: ExportCommentRow[],
|
||||||
|
meta: { videoTitle: string; versionNumber: number; versionLabel: string | null }
|
||||||
|
): string {
|
||||||
|
const header = [
|
||||||
|
'video_title',
|
||||||
|
'version_number',
|
||||||
|
'version_label',
|
||||||
|
'comment_id',
|
||||||
|
'parent_comment_id',
|
||||||
|
'thread_level',
|
||||||
|
'author_name',
|
||||||
|
'author_type',
|
||||||
|
'timestamp_seconds',
|
||||||
|
'timestamp_hhmmss',
|
||||||
|
'timestamp_end_seconds',
|
||||||
|
'is_resolved',
|
||||||
|
'tag',
|
||||||
|
'content',
|
||||||
|
'has_voice_note',
|
||||||
|
'voice_duration_seconds',
|
||||||
|
'has_image_attachment',
|
||||||
|
'has_annotation',
|
||||||
|
'created_at_iso',
|
||||||
|
];
|
||||||
|
|
||||||
|
const lines = [header.map(csvCell).join(',')];
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
meta.videoTitle,
|
||||||
|
meta.versionNumber,
|
||||||
|
meta.versionLabel || '',
|
||||||
|
row.commentId,
|
||||||
|
row.parentCommentId,
|
||||||
|
row.level,
|
||||||
|
row.authorName,
|
||||||
|
row.authorType,
|
||||||
|
row.timestamp.toFixed(3),
|
||||||
|
formatTimestamp(row.timestamp),
|
||||||
|
row.timestampEnd === null ? '' : row.timestampEnd.toFixed(3),
|
||||||
|
row.isResolved,
|
||||||
|
row.tag,
|
||||||
|
row.content,
|
||||||
|
row.hasVoiceNote,
|
||||||
|
row.voiceDuration === null ? '' : row.voiceDuration.toFixed(3),
|
||||||
|
row.hasImageAttachment,
|
||||||
|
row.hasAnnotation,
|
||||||
|
row.createdAtIso,
|
||||||
|
].map(csvCell).join(',')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCommentsPdf(
|
||||||
|
rows: ExportCommentRow[],
|
||||||
|
meta: { videoTitle: string; versionNumber: number; versionLabel: string | null }
|
||||||
|
): Buffer {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const versionTitle = meta.versionLabel
|
||||||
|
? `v${meta.versionNumber} (${meta.versionLabel})`
|
||||||
|
: `v${meta.versionNumber}`;
|
||||||
|
|
||||||
|
lines.push(`OpenFrame Comments Export`);
|
||||||
|
lines.push(`Video: ${meta.videoTitle}`);
|
||||||
|
lines.push(`Version: ${versionTitle}`);
|
||||||
|
lines.push(`Generated At: ${new Date().toISOString()}`);
|
||||||
|
lines.push(`Total Entries: ${rows.length}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const prefix = row.level === 1 ? ' Reply' : 'Comment';
|
||||||
|
const base = `${index + 1}. ${prefix} ${formatTimestamp(row.timestamp)} by ${row.authorName}`;
|
||||||
|
const details = [
|
||||||
|
`resolved=${row.isResolved ? 'yes' : 'no'}`,
|
||||||
|
`voice=${row.hasVoiceNote ? 'yes' : 'no'}`,
|
||||||
|
`image=${row.hasImageAttachment ? 'yes' : 'no'}`,
|
||||||
|
`annotation=${row.hasAnnotation ? 'yes' : 'no'}`,
|
||||||
|
row.tag ? `tag=${row.tag}` : null,
|
||||||
|
].filter((item): item is string => item !== null).join(', ');
|
||||||
|
|
||||||
|
lines.push(base);
|
||||||
|
lines.push(` ${details}`);
|
||||||
|
if (row.content) {
|
||||||
|
lines.push(...wrapLine(` ${row.content}`, 96));
|
||||||
|
}
|
||||||
|
lines.push(` created_at=${row.createdAtIso}`);
|
||||||
|
lines.push('');
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildSimplePdf(lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimplePdf(lines: string[]): Buffer {
|
||||||
|
const pageWidth = 612;
|
||||||
|
const pageHeight = 792;
|
||||||
|
const margin = 40;
|
||||||
|
const lineHeight = 14;
|
||||||
|
const maxLinesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight);
|
||||||
|
|
||||||
|
const pages: string[][] = [];
|
||||||
|
let currentPage: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (currentPage.length >= maxLinesPerPage) {
|
||||||
|
pages.push(currentPage);
|
||||||
|
currentPage = [];
|
||||||
|
}
|
||||||
|
currentPage.push(line);
|
||||||
|
}
|
||||||
|
if (currentPage.length > 0) pages.push(currentPage);
|
||||||
|
if (pages.length === 0) pages.push(['No comments']);
|
||||||
|
|
||||||
|
const objects: string[] = [];
|
||||||
|
const pageRefs: string[] = [];
|
||||||
|
|
||||||
|
objects[0] = '<< /Type /Catalog /Pages 2 0 R >>';
|
||||||
|
objects[1] = '';
|
||||||
|
objects[2] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>';
|
||||||
|
|
||||||
|
for (const pageLines of pages) {
|
||||||
|
const contentLines = ['BT', '/F1 11 Tf'];
|
||||||
|
pageLines.forEach((line, lineIndex) => {
|
||||||
|
const y = pageHeight - margin - lineIndex * lineHeight;
|
||||||
|
const safeLine = escapePdfText(toPdfSafeAscii(line));
|
||||||
|
contentLines.push(`1 0 0 1 ${margin} ${y} Tm (${safeLine}) Tj`);
|
||||||
|
});
|
||||||
|
contentLines.push('ET');
|
||||||
|
|
||||||
|
const stream = contentLines.join('\n');
|
||||||
|
const contentObject = `<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`;
|
||||||
|
const contentObjNumber = objects.length + 1;
|
||||||
|
objects.push(contentObject);
|
||||||
|
|
||||||
|
const pageObjNumber = objects.length + 1;
|
||||||
|
objects.push(
|
||||||
|
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjNumber} 0 R >>`
|
||||||
|
);
|
||||||
|
pageRefs.push(`${pageObjNumber} 0 R`);
|
||||||
|
}
|
||||||
|
|
||||||
|
objects[1] = `<< /Type /Pages /Kids [${pageRefs.join(' ')}] /Count ${pageRefs.length} >>`;
|
||||||
|
|
||||||
|
let pdf = '%PDF-1.4\n';
|
||||||
|
const offsets: number[] = [0];
|
||||||
|
|
||||||
|
objects.forEach((obj, index) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, 'utf8'));
|
||||||
|
const objNum = index + 1;
|
||||||
|
pdf += `${objNum} 0 obj\n${obj}\nendobj\n`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, 'utf8');
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||||
|
pdf += '0000000000 65535 f \n';
|
||||||
|
for (let i = 1; i < offsets.length; i += 1) {
|
||||||
|
pdf += `${offsets[i].toString().padStart(10, '0')} 00000 n \n`;
|
||||||
|
}
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||||
|
|
||||||
|
return Buffer.from(pdf, 'utf8');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user