Initial release: DictIA v0.8.14-alpha (fork de Speakr, AGPL-3.0)

This commit is contained in:
InnovA AI
2026-03-16 21:47:37 +00:00
commit 42772a31ed
365 changed files with 103572 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
/**
* API utility functions with CSRF token handling
*/
export const createApiClient = (csrfToken) => {
const getHeaders = (contentType = 'application/json') => {
const headers = {
'X-CSRFToken': csrfToken.value
};
if (contentType) {
headers['Content-Type'] = contentType;
}
return headers;
};
return {
get: async (url) => {
const response = await fetch(url, {
headers: getHeaders()
});
return response;
},
post: async (url, data = {}) => {
const response = await fetch(url, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response;
},
postFormData: async (url, formData) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken.value
},
body: formData
});
return response;
},
delete: async (url) => {
const response = await fetch(url, {
method: 'DELETE',
headers: getHeaders()
});
return response;
},
put: async (url, data = {}) => {
const response = await fetch(url, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(data)
});
return response;
}
};
};

View File

@@ -0,0 +1,50 @@
/**
* Color utility functions
*/
/**
* Calculate the relative luminance of a color
* Based on WCAG contrast ratio formula
* @param {string} hexColor - Hex color code (e.g., "#RRGGBB" or "#RGB")
* @returns {number} Luminance value between 0 and 1
*/
function calculateLuminance(hexColor) {
// Remove # if present
const hex = hexColor.replace('#', '');
// Convert 3-digit hex to 6-digit
const fullHex = hex.length === 3
? hex.split('').map(char => char + char).join('')
: hex;
// Parse RGB values
const r = parseInt(fullHex.substr(0, 2), 16) / 255;
const g = parseInt(fullHex.substr(2, 2), 16) / 255;
const b = parseInt(fullHex.substr(4, 2), 16) / 255;
// Calculate relative luminance using simplified formula
// (More accurate would use gamma correction, but this is sufficient)
return 0.299 * r + 0.587 * g + 0.114 * b;
}
/**
* Get the appropriate text color (black or white) for a given background color
* Ensures readable contrast based on background luminance
* @param {string} bgColor - Background color in hex format
* @returns {string} Either 'white' or 'black'
*/
export function getContrastTextColor(bgColor) {
if (!bgColor) {
return 'white'; // Default to white for undefined colors
}
try {
const luminance = calculateLuminance(bgColor);
// Threshold of 0.65: only very light backgrounds get black text
// This ensures medium/dark colors like greens, blues still get white text
return luminance > 0.65 ? 'black' : 'white';
} catch (e) {
console.warn('Failed to calculate contrast color for:', bgColor, e);
return 'white'; // Fallback to white
}
}

View File

@@ -0,0 +1,67 @@
/**
* Date comparison utility functions
*/
export const isSameDay = (date1, date2) => {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
};
export const isToday = (date) => {
const today = new Date();
return isSameDay(date, today);
};
export const isYesterday = (date) => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return isSameDay(date, yesterday);
};
export const isThisWeek = (date) => {
const now = new Date();
const startOfWeek = new Date(now);
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Monday as start of week
startOfWeek.setDate(diff);
startOfWeek.setHours(0, 0, 0, 0);
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(startOfWeek.getDate() + 6);
endOfWeek.setHours(23, 59, 59, 999);
return date >= startOfWeek && date <= endOfWeek;
};
export const isLastWeek = (date) => {
const now = new Date();
const startOfLastWeek = new Date(now);
const day = now.getDay();
const diff = now.getDate() - day + (day === 0 ? -6 : 1) - 7; // Previous Monday
startOfLastWeek.setDate(diff);
startOfLastWeek.setHours(0, 0, 0, 0);
const endOfLastWeek = new Date(startOfLastWeek);
endOfLastWeek.setDate(startOfLastWeek.getDate() + 6);
endOfLastWeek.setHours(23, 59, 59, 999);
return date >= startOfLastWeek && date <= endOfLastWeek;
};
export const isThisMonth = (date) => {
const now = new Date();
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
};
export const isLastMonth = (date) => {
const now = new Date();
const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return date.getFullYear() === lastMonth.getFullYear() && date.getMonth() === lastMonth.getMonth();
};
export const getDateForSorting = (recording, sortBy) => {
const dateStr = sortBy === 'meeting_date' ? recording.meeting_date : recording.created_at;
if (!dateStr) return null;
return new Date(dateStr);
};

View File

@@ -0,0 +1,271 @@
/**
* Error Display Utility
*
* Parses and displays user-friendly error messages from the backend.
* Handles both JSON-formatted errors (ERROR_JSON:...) and plain text errors.
*/
/**
* Parse a stored error message from the backend.
* @param {string} text - The stored transcription/error text
* @returns {Object|null} - Parsed error object or null if not an error
*/
export function parseStoredError(text) {
if (!text) return null;
// Check for JSON-formatted error
if (text.startsWith('ERROR_JSON:')) {
try {
const jsonStr = text.substring(11); // Remove 'ERROR_JSON:' prefix
const data = JSON.parse(jsonStr);
return {
title: data.t || 'Error',
message: data.m || 'An error occurred',
guidance: data.g || '',
icon: data.i || 'fa-exclamation-circle',
type: data.y || 'unknown',
isKnown: data.k || false,
technical: data.d || '',
isFormattedError: true
};
} catch (e) {
console.error('Failed to parse error JSON:', e);
}
}
// Check for legacy error format (starts with common error prefixes)
const errorPrefixes = [
'Transcription failed:',
'Processing failed:',
'ASR processing failed:',
'Audio extraction failed:',
'Error:'
];
for (const prefix of errorPrefixes) {
if (text.startsWith(prefix)) {
// Parse the error using pattern matching
return parseUnformattedError(text);
}
}
return null;
}
/**
* Parse an unformatted error message and try to make it user-friendly.
* @param {string} text - The raw error text
* @returns {Object} - Parsed error object
*/
function parseUnformattedError(text) {
const lowerText = text.toLowerCase();
// Known error patterns
const patterns = [
{
patterns: ['maximum content size limit', 'file too large', '413', 'payload too large', 'exceeded'],
title: 'File Too Large',
message: 'The audio file exceeds the maximum size allowed by the transcription service.',
guidance: 'Try enabling audio chunking in your settings, or compress the audio file before uploading.',
icon: 'fa-file-audio',
type: 'size_limit'
},
{
patterns: ['timed out', 'timeout', 'deadline exceeded'],
title: 'Processing Timeout',
message: 'The transcription took too long to complete.',
guidance: 'This can happen with very long recordings. Try splitting the audio into smaller parts.',
icon: 'fa-clock',
type: 'timeout'
},
{
patterns: ['401', 'unauthorized', 'invalid api key', 'authentication failed', 'incorrect api key'],
title: 'Authentication Error',
message: 'The transcription service rejected the API credentials.',
guidance: 'Please check that the API key is correct and has not expired.',
icon: 'fa-key',
type: 'auth'
},
{
patterns: ['rate limit', 'too many requests', '429', 'quota exceeded'],
title: 'Rate Limit Exceeded',
message: 'Too many requests were sent to the transcription service.',
guidance: 'Please wait a few minutes and try reprocessing.',
icon: 'fa-hourglass-half',
type: 'rate_limit'
},
{
patterns: ['connection refused', 'connection reset', 'could not connect', 'network unreachable'],
title: 'Connection Error',
message: 'Could not connect to the transcription service.',
guidance: 'Check your internet connection and ensure the service is available.',
icon: 'fa-wifi',
type: 'connection'
},
{
patterns: ['503', '502', '500', 'service unavailable', 'server error', 'internal server error'],
title: 'Service Unavailable',
message: 'The transcription service is temporarily unavailable.',
guidance: 'This is usually temporary. Please try again in a few minutes.',
icon: 'fa-server',
type: 'service_error'
},
{
patterns: ['invalid file format', 'unsupported format', 'could not decode', 'corrupt', 'not valid audio'],
title: 'Invalid Audio Format',
message: 'The audio file format is not supported or the file may be corrupted.',
guidance: 'Try converting the audio to MP3 or WAV format before uploading.',
icon: 'fa-file-audio',
type: 'format'
},
{
patterns: ['audio extraction failed', 'ffmpeg failed', 'no audio stream'],
title: 'Audio Extraction Failed',
message: 'Could not extract audio from the uploaded file.',
guidance: 'Try converting the file to a standard audio format (MP3, WAV) before uploading.',
icon: 'fa-file-video',
type: 'extraction'
}
];
// Check patterns
for (const pattern of patterns) {
for (const p of pattern.patterns) {
if (lowerText.includes(p)) {
return {
title: pattern.title,
message: pattern.message,
guidance: pattern.guidance,
icon: pattern.icon,
type: pattern.type,
isKnown: true,
technical: text,
isFormattedError: true
};
}
}
}
// Unknown error - clean it up as best we can
let cleanMessage = text;
for (const prefix of ['Transcription failed:', 'Processing failed:', 'Error:', 'ASR processing failed:']) {
if (cleanMessage.startsWith(prefix)) {
cleanMessage = cleanMessage.substring(prefix.length).trim();
}
}
// Truncate if too long
if (cleanMessage.length > 200) {
cleanMessage = cleanMessage.substring(0, 200) + '...';
}
return {
title: 'Processing Error',
message: cleanMessage,
guidance: 'If this error persists, try reprocessing the recording.',
icon: 'fa-exclamation-circle',
type: 'unknown',
isKnown: false,
technical: text,
isFormattedError: true
};
}
/**
* Check if a transcription text is actually an error message.
* @param {string} text - The transcription text
* @returns {boolean}
*/
export function isErrorMessage(text) {
if (!text) return false;
if (text.startsWith('ERROR_JSON:')) return true;
const errorPrefixes = [
'Transcription failed:',
'Processing failed:',
'ASR processing failed:',
'Audio extraction failed:'
];
return errorPrefixes.some(prefix => text.startsWith(prefix));
}
/**
* Generate HTML for displaying an error nicely.
* @param {Object} error - Parsed error object from parseStoredError
* @param {boolean} showTechnical - Whether to show technical details
* @returns {string} - HTML string
*/
export function generateErrorHTML(error, showTechnical = false) {
if (!error) return '';
const typeColors = {
size_limit: 'amber',
timeout: 'orange',
auth: 'red',
rate_limit: 'yellow',
connection: 'blue',
service_error: 'purple',
format: 'pink',
extraction: 'indigo',
billing: 'red',
model: 'gray',
unknown: 'gray'
};
const color = typeColors[error.type] || 'gray';
let html = `
<div class="error-display bg-${color}-500/10 border border-${color}-500/30 rounded-lg p-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-${color}-500/20 flex items-center justify-center">
<i class="fas ${error.icon} text-${color}-500"></i>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-${color}-600 dark:text-${color}-400 mb-1">
${escapeHtml(error.title)}
</h3>
<p class="text-[var(--text-primary)] mb-2">
${escapeHtml(error.message)}
</p>
${error.guidance ? `
<div class="flex items-start gap-2 text-sm text-[var(--text-secondary)] bg-[var(--bg-tertiary)]/50 rounded p-2">
<i class="fas fa-lightbulb text-yellow-500 mt-0.5"></i>
<span>${escapeHtml(error.guidance)}</span>
</div>
` : ''}
</div>
</div>
${showTechnical && error.technical ? `
<details class="mt-3 text-xs">
<summary class="cursor-pointer text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
Technical details
</summary>
<pre class="mt-2 p-2 bg-[var(--bg-tertiary)] rounded overflow-x-auto text-[var(--text-muted)]">${escapeHtml(error.technical)}</pre>
</details>
` : ''}
</div>
`;
return html;
}
/**
* Escape HTML special characters.
* @param {string} text
* @returns {string}
*/
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Export for use in Vue components
export default {
parseStoredError,
isErrorMessage,
generateErrorHTML
};

View File

@@ -0,0 +1,139 @@
/**
* Formatting utility functions
*/
export const formatFileSize = (bytes) => {
if (bytes == null || bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
if (bytes < 0) bytes = 0;
const i = bytes === 0 ? 0 : Math.max(0, Math.floor(Math.log(bytes) / Math.log(k)));
const size = i === 0 ? bytes : parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return size + ' ' + sizes[i];
};
export const formatDisplayDate = (dateString) => {
if (!dateString) return '';
try {
let date = new Date(dateString);
if (isNaN(date.getTime())) {
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
date = new Date(dateString + 'T00:00:00');
} else {
return dateString;
}
}
if (isNaN(date.getTime())) {
return dateString;
}
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
} catch (e) {
console.error("Error formatting date:", e);
return dateString;
}
};
export const formatShortDate = (dateString) => {
if (!dateString) return '';
try {
let date = new Date(dateString);
if (isNaN(date.getTime())) {
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
date = new Date(dateString + 'T00:00:00');
} else {
return dateString;
}
}
if (isNaN(date.getTime())) {
return dateString;
}
const now = new Date();
const isCurrentYear = date.getFullYear() === now.getFullYear();
if (isCurrentYear) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
} else {
return date.toLocaleDateString(undefined, { year: '2-digit', month: 'short', day: 'numeric' });
}
} catch (e) {
console.error("Error formatting short date:", e);
return dateString;
}
};
export const formatStatus = (status, t) => {
if (!status || status === 'COMPLETED') return '';
const statusMap = {
'PENDING': t('status.queued'),
'QUEUED': t('status.queued'),
'PROCESSING': t('status.processing'),
'TRANSCRIBING': t('status.transcribing'),
'SUMMARIZING': t('status.summarizing'),
'FAILED': t('status.failed'),
'UPLOADING': t('status.uploading')
};
return statusMap[status] || status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
};
export const getStatusClass = (status) => {
switch(status) {
case 'PENDING': return 'status-pending';
case 'QUEUED': return 'status-pending';
case 'PROCESSING': return 'status-processing';
case 'SUMMARIZING': return 'status-summarizing';
case 'COMPLETED': return '';
case 'FAILED': return 'status-failed';
default: return 'status-pending';
}
};
export const formatTime = (seconds) => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
export const formatDuration = (totalSeconds) => {
if (totalSeconds == null || totalSeconds < 0) return 'N/A';
if (totalSeconds < 1) {
return `${totalSeconds.toFixed(2)} seconds`;
}
totalSeconds = Math.round(totalSeconds);
if (totalSeconds < 60) {
return `${totalSeconds} sec`;
}
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
let parts = [];
if (hours > 0) {
parts.push(`${hours} hr`);
}
if (minutes > 0) {
parts.push(`${minutes} min`);
}
if (hours === 0 && seconds > 0) {
parts.push(`${seconds} sec`);
}
return parts.join(' ');
};
export const formatProcessingDuration = (seconds) => {
if (!seconds && seconds !== 0) return null;
if (seconds < 60) return `${seconds}s`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
};

View File

@@ -0,0 +1,9 @@
/**
* Utils module exports
*/
export * from './formatters.js';
export * from './dates.js';
export { createApiClient } from './api.js';
export { showToast } from './toast.js';
export { getContrastTextColor } from './colors.js';

View File

@@ -0,0 +1,91 @@
/**
* Toast notification utility
*/
export const showToast = (message, iconClass = 'fa-info-circle', duration = 3000, type = 'info') => {
const container = document.getElementById('toastContainer');
if (!container) {
console.warn('Toast container not found');
return;
}
// Determine colors and styles based on type
let bgColor, textColor, iconColor, borderColor;
switch (type) {
case 'success':
bgColor = '#10b981'; // green-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#059669'; // green-600
break;
case 'error':
bgColor = '#ef4444'; // red-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#dc2626'; // red-600
break;
case 'warning':
bgColor = '#f59e0b'; // amber-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#d97706'; // amber-600
break;
case 'info':
default:
bgColor = '#3b82f6'; // blue-500
textColor = '#ffffff';
iconColor = '#ffffff';
borderColor = '#2563eb'; // blue-600
break;
}
const toast = document.createElement('div');
toast.className = 'toast-message px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 opacity-0 min-w-[300px]';
toast.style.backgroundColor = bgColor;
toast.style.color = textColor;
toast.style.border = `1px solid ${borderColor}`;
// Handle icon class - support both old format (just icon name) and new format (full class)
let fullIconClass = iconClass;
if (!iconClass.includes(' ')) {
// Old format: just the icon name like 'fa-check-circle'
fullIconClass = `fas ${iconClass}`;
}
toast.innerHTML = `
<i class="${fullIconClass}" style="color: ${iconColor}"></i>
<span class="flex-1">${message}</span>
`;
// Make toast clickable to dismiss
toast.style.cursor = 'pointer';
container.appendChild(toast);
// Trigger fly-in animation
requestAnimationFrame(() => {
toast.classList.remove('opacity-0');
toast.classList.add('opacity-100', 'toast-show');
});
// Function to dismiss the toast
const dismissToast = () => {
toast.classList.remove('opacity-100', 'toast-show');
toast.classList.add('opacity-0');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
};
// Add click handler to dismiss toast
toast.addEventListener('click', () => {
clearTimeout(timeoutId);
dismissToast();
});
// Auto-dismiss after duration
const timeoutId = setTimeout(dismissToast, duration);
};