1 // Download utilities for guides and videos
2 import type { RepairGuide, TroubleshootingFlow } from "@shared/schema";
3 import { saveDownloadedFile, type DownloadedFile } from "./offline-storage";
4
5 export interface DownloadOptions {
6 includeImages?: boolean;
7 includeVideos?: boolean;
8 format?: 'pdf' | 'html' | 'json';
9 }
10
11 // Download a repair guide as a file
12 export const downloadGuide = async (guide: RepairGuide, options: DownloadOptions = {}) => {
13 const { format = 'html', includeImages = true } = options;
14
15 try {
16 let content: string;
17 let mimeType: string;
18 let fileName: string;
19
20 switch (format) {
21 case 'html':
22 content = generateHTMLGuide(guide, includeImages);
23 mimeType = 'text/html';
24 fileName = `${sanitizeFileName(guide.title)}.html`;
25 break;
26 case 'json':
27 content = JSON.stringify(guide, null, 2);
28 mimeType = 'application/json';
29 fileName = `${sanitizeFileName(guide.title)}.json`;
30 break;
31 case 'pdf':
32 // For PDF, we'll generate HTML and let the browser handle PDF conversion
33 content = generatePrintableHTML(guide, includeImages);
34 mimeType = 'text/html';
35 fileName = `${sanitizeFileName(guide.title)}_printable.html`;
36 break;
37 default:
38 throw new Error('Unsupported format');
39 }
40
41 const blob = new Blob([content], { type: mimeType });
42 const url = URL.createObjectURL(blob);
43
44 const link = document.createElement('a');
45 link.href = url;
46 link.download = fileName;
47 link.style.display = 'none';
48
49 document.body.appendChild(link);
50 link.click();
51 document.body.removeChild(link);
52
53 URL.revokeObjectURL(url);
54
55 // Track the download
56 await trackGuideDownload(guide.id);
57 const downloadRecord: DownloadedFile = {
58 id: `guide_${guide.id}_${Date.now()}`,
59 name: guide.title,
60 type: 'guide',
61 size: blob.size,
62 downloadDate: new Date().toISOString(),
63 lastAccessed: new Date().toISOString(),
64 format: format,
65 relatedGuideId: guide.id,
66 };
67 saveDownloadedFile(downloadRecord);
68
69 return true;
70 } catch (error) {
71 console.error('Failed to download guide:', error);
72 return false;
73 }
74 };
75
76 // Download video from URL
77 export const downloadVideo = async (videoUrl: string, title: string): Promise<boolean> => {
78 try {
79 const response = await fetch(videoUrl, {
80 mode: 'cors',
81 });
82
83 if (!response.ok) {
84 throw new Error(`HTTP error! status: ${response.status}`);
85 }
86
87 const blob = await response.blob();
88 const url = URL.createObjectURL(blob);
89
90 const link = document.createElement('a');
91 link.href = url;
92 link.download = `${sanitizeFileName(title)}.mp4`;
93 link.style.display = 'none';
94
95 document.body.appendChild(link);
96 link.click();
97 document.body.removeChild(link);
98
99 URL.revokeObjectURL(url);
100
101 // Track the video download
102 const downloadRecord: DownloadedFile = {
103 id: `video_${Date.now()}`,
104 name: title,
105 type: 'video',
106 size: blob.size,
107 downloadDate: new Date().toISOString(),
108 lastAccessed: new Date().toISOString(),
109 format: 'mp4',
110 };
111 saveDownloadedFile(downloadRecord);
112
113 return true;
114 } catch (error) {
115 console.error('Failed to download video:', error);
116 return false;
117 }
118 };
119
120 // Download multiple guides as a ZIP file
121 export const downloadGuidePackage = async (guides: RepairGuide[], packageName: string = 'repair_guides'): Promise<boolean> => {
122 try {
123 // For now, we'll create individual files. In production, you'd use a ZIP library
124 const promises = guides.map(guide =>
125 downloadGuide(guide, { format: 'html', includeImages: true })
126 );
127
128 await Promise.all(promises);
129 return true;
130 } catch (error) {
131 console.error('Failed to download guide package:', error);
132 return false;
133 }
134 };
135
136 // Generate HTML content for a guide
137 const generateHTMLGuide = (guide: RepairGuide, includeImages: boolean): string => {
138 const steps = guide.steps as Array<{
139 stepNumber: number;
140 title: string;
141 description: string;
142 notes?: string[];
143 warnings?: string[];
144 }> || [];
145
146 return `
147 <!DOCTYPE html>
148 <html lang="en">
149 <head>
150 <meta charset="UTF-8">
151 <meta name="viewport" content="width=device-width, initial-scale=1.0">
152 <title>${guide.title}</title>
153 <style>
154 body {
155 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
156 line-height: 1.6;
157 max-width: 800px;
158 margin: 0 auto;
159 padding: 20px;
160 color: #333;
161 }
162 .header {
163 border-bottom: 2px solid #e2e8f0;
164 padding-bottom: 20px;
165 margin-bottom: 30px;
166 }
167 .title {
168 font-size: 2.5rem;
169 font-weight: bold;
170 margin-bottom: 10px;
171 color: #1a202c;
172 }
173 .description {
174 font-size: 1.1rem;
175 color: #4a5568;
176 margin-bottom: 15px;
177 }
178 .meta {
179 display: flex;
180 gap: 15px;
181 font-size: 0.9rem;
182 color: #718096;
183 }
184 .difficulty {
185 padding: 4px 12px;
186 border-radius: 6px;
187 font-weight: 500;
188 }
189 .difficulty.easy { background: #c6f6d5; color: #22543d; }
190 .difficulty.medium { background: #fef2c0; color: #744210; }
191 .difficulty.hard { background: #fed7d7; color: #742a2a; }
192 .section {
193 margin: 30px 0;
194 }
195 .section-title {
196 font-size: 1.5rem;
197 font-weight: 600;
198 margin-bottom: 15px;
199 color: #2d3748;
200 }
201 .tools-list, .safety-list {
202 list-style: none;
203 padding: 0;
204 }
205 .tools-list li, .safety-list li {
206 padding: 8px 12px;
207 margin: 5px 0;
208 border-left: 4px solid #4299e1;
209 background: #ebf8ff;
210 }
211 .safety-list li {
212 border-left-color: #f56565;
213 background: #fed7d7;
214 }
215 .step {
216 margin: 25px 0;
217 padding: 20px;
218 border: 1px solid #e2e8f0;
219 border-radius: 8px;
220 background: #f7fafc;
221 }
222 .step-number {
223 font-size: 1.3rem;
224 font-weight: bold;
225 color: #3182ce;
226 margin-bottom: 10px;
227 }
228 .step-title {
229 font-size: 1.2rem;
230 font-weight: 600;
231 margin-bottom: 10px;
232 color: #2d3748;
233 }
234 .step-description {
235 margin-bottom: 15px;
236 line-height: 1.8;
237 }
238 .notes, .warnings {
239 margin-top: 10px;
240 padding: 10px;
241 border-radius: 6px;
242 font-size: 0.9rem;
243 }
244 .notes {
245 background: #ebf8ff;
246 border-left: 4px solid #4299e1;
247 }
248 .warnings {
249 background: #fed7d7;
250 border-left: 4px solid #f56565;
251 }
252 .guide-image {
253 max-width: 100%;
254 height: auto;
255 border-radius: 8px;
256 margin: 20px 0;
257 }
258 @media print {
259 body { font-size: 12pt; }
260 .step { break-inside: avoid; }
261 }
262 </style>
263 </head>
264 <body>
265 <div class="header">
266 <h1 class="title">${guide.title}</h1>
267 <p class="description">${guide.description}</p>
268 <div class="meta">
269 <span class="difficulty ${guide.difficulty}">${guide.difficulty.toUpperCase()}</span>
270 <span>📱 ${guide.deviceType}</span>
271 <span>🔧 ${guide.category}</span>
272 <span>⏱️ ${guide.estimatedTime}</span>
273 </div>
274 </div>
275
276 ${includeImages && guide.imageUrl ? `
277 <div class="section">
278 <img src="${guide.imageUrl}" alt="${guide.title}" class="guide-image" />
279 </div>
280 ` : ''}
281
282 <div class="section">
283 <h2 class="section-title">🛠️ Tools Required</h2>
284 <ul class="tools-list">
285 ${guide.toolsRequired.map(tool => `<li>${tool}</li>`).join('')}
286 </ul>
287 </div>
288
289 ${guide.safetyWarnings && guide.safetyWarnings.length > 0 ? `
290 <div class="section">
291 <h2 class="section-title">⚠️ Safety Warnings</h2>
292 <ul class="safety-list">
293 ${guide.safetyWarnings.map(warning => `<li>${warning}</li>`).join('')}
294 </ul>
295 </div>
296 ` : ''}
297
298 <div class="section">
299 <h2 class="section-title">📋 Repair Steps</h2>
300 ${steps.map(step => `
301 <div class="step">
302 <div class="step-number">Step ${step.stepNumber}</div>
303 <div class="step-title">${step.title}</div>
304 <div class="step-description">${step.description}</div>
305 ${step.notes && step.notes.length > 0 ? `
306 <div class="notes">
307 <strong>Notes:</strong> ${step.notes.join(', ')}
308 </div>
309 ` : ''}
310 ${step.warnings && step.warnings.length > 0 ? `
311 <div class="warnings">
312 <strong>⚠️ Warnings:</strong> ${step.warnings.join(', ')}
313 </div>
314 ` : ''}
315 </div>
316 `).join('')}
317 </div>
318
319 ${guide.alternativeSolutions ? `
320 <div class="section">
321 <h2 class="section-title">🔄 Alternative Solutions</h2>
322 <p>${guide.alternativeSolutions}</p>
323 </div>
324 ` : ''}
325
326 <div class="section" style="margin-top: 50px; padding-top: 20px; border-top: 1px solid #e2e8f0; text-align: center; color: #718096; font-size: 0.9rem;">
327 <p>Downloaded from JCR Guide Pro • ${new Date().toLocaleDateString()}</p>
328 </div>
329 </body>
330 </html>
331 `.trim();
332 };
333
334 // Generate printable HTML (optimized for PDF)
335 const generatePrintableHTML = (guide: RepairGuide, includeImages: boolean): string => {
336 const html = generateHTMLGuide(guide, includeImages);
337 return html.replace(
338 '<style>',
339 '<style>@page { margin: 1in; } body { font-size: 11pt; } '
340 );
341 };
342
343 // Sanitize filename for downloads
344 const sanitizeFileName = (fileName: string): string => {
345 return fileName
346 .replace(/[^a-z0-9]/gi, '_')
347 .replace(/_+/g, '_')
348 .replace(/^_|_$/g, '')
349 .toLowerCase();
350 };
351
352 // Check if download is supported
353 export const isDownloadSupported = (): boolean => {
354 return typeof document !== 'undefined' && 'createElement' in document;
355 };
356
357 // Get estimated download size
358 export const getEstimatedSize = (guide: RepairGuide, options: DownloadOptions = {}): string => {
359 const { format = 'html', includeImages = true } = options;
360
361 let estimatedBytes = 0;
362
363 // Base content size
364 estimatedBytes += JSON.stringify(guide).length * 2; // HTML markup overhead
365
366 // Add estimated image sizes if included
367 if (includeImages && guide.imageUrl) {
368 estimatedBytes += 100000; // Estimate 100KB per image
369 }
370
371 // Format-specific overhead
372 switch (format) {
373 case 'html':
374 estimatedBytes += 5000; // CSS and HTML structure
375 break;
376 case 'pdf':
377 estimatedBytes *= 1.5; // PDF conversion overhead
378 break;
379 }
380
381 return formatFileSize(estimatedBytes);
382 };
383
384 // Format file size for display
385 const formatFileSize = (bytes: number): string => {
386 if (bytes === 0) return '0 B';
387 const k = 1024;
388 const sizes = ['B', 'KB', 'MB', 'GB'];
389 const i = Math.floor(Math.log(bytes) / Math.log(k));
390 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
391 };
392
393 // Track guide download
394 export const trackGuideDownload = async (guideId: string): Promise<void> => {
395 try {
396 await fetch(`/api/repair-guides/${guideId}/download`, {
397 method: 'POST',
398 headers: {
399 'Content-Type': 'application/json',
400 },
401 });
402 } catch (error) {
403 console.error('Failed to track download:', error);
404 }
405 };