feat: enhance action differentiators, deep video extraction, and full action type parsing
This commit is contained in:
+252
-33
@@ -516,50 +516,257 @@ async function fetchAllActivities(studentId) {
|
||||
function matchesSelectedCategories(act) {
|
||||
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
|
||||
|
||||
if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) {
|
||||
if (type.includes('photo') || type.includes('video') || act.media || act.image_url || act.video_url || act.video_stream_url) {
|
||||
if (el.catPhotos.checked) return true;
|
||||
}
|
||||
if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) {
|
||||
if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med') || type.includes('bottle')) {
|
||||
if (el.catCare.checked) return true;
|
||||
}
|
||||
if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) {
|
||||
if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos') || type.includes('skill')) {
|
||||
if (el.catMilestones.checked) return true;
|
||||
}
|
||||
if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) {
|
||||
if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident') || type.includes('screening') || type.includes('name_to_face')) {
|
||||
if (el.catAttendance.checked) return true;
|
||||
}
|
||||
if (type.includes('note') || type.includes('message') || type.includes('announcement')) {
|
||||
if (type.includes('note') || type.includes('message') || type.includes('announcement') || type.includes('reminder') || type.includes('custom')) {
|
||||
if (el.catMessages.checked) return true;
|
||||
}
|
||||
// Default: include all other action types
|
||||
return true;
|
||||
}
|
||||
|
||||
// Deep recursive media extractor to find high-res photos and videos anywhere in object/array
|
||||
function extractMediaUrls(act) {
|
||||
let imageUrl = null;
|
||||
let videoUrl = null;
|
||||
let thumbUrl = null;
|
||||
|
||||
const isVideoStr = (url) => {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('.mp4') || lower.includes('.mov') || lower.includes('.webm') || lower.includes('/videos/') || lower.includes('video_stream') || lower.includes('transcoded_video') || lower.includes('media_videos');
|
||||
};
|
||||
|
||||
const isImageStr = (url) => {
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('.jpg') || lower.includes('.jpeg') || lower.includes('.png') || lower.includes('.webp') || lower.includes('.gif') || lower.includes('/images/') || lower.includes('/photos/') || lower.includes('media_images');
|
||||
};
|
||||
|
||||
function search(obj, depth = 0) {
|
||||
if (!obj || depth > 5) return;
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
if (obj.startsWith('http://') || obj.startsWith('https://')) {
|
||||
if (isVideoStr(obj) && !videoUrl) videoUrl = obj;
|
||||
else if (isImageStr(obj) && !imageUrl) imageUrl = obj;
|
||||
else if (!imageUrl && !videoUrl) {
|
||||
if (act.activity_type === 'ac_video' || act.type === 'video') videoUrl = obj;
|
||||
else imageUrl = obj;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) search(item, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
// Check video keys
|
||||
const possibleVideos = [
|
||||
obj.video_url, obj.video_stream_url, obj.video_hd_url, obj.video_transcode_url,
|
||||
obj.transcoded_video_url, obj.original_video_url, obj.stream_url, obj.mp4_url,
|
||||
obj.video, obj.source_url, obj.download_url
|
||||
];
|
||||
for (const v of possibleVideos) {
|
||||
if (typeof v === 'string' && v.startsWith('http')) {
|
||||
if (!videoUrl) videoUrl = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check image keys
|
||||
const possibleImages = [
|
||||
obj.image_url, obj.photo_url, obj.full_url, obj.picture_url,
|
||||
obj.original_url, obj.cover_url, obj.large_url
|
||||
];
|
||||
for (const img of possibleImages) {
|
||||
if (typeof img === 'string' && img.startsWith('http')) {
|
||||
if (!imageUrl && img !== videoUrl) imageUrl = img;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check generic url
|
||||
if (typeof obj.url === 'string' && obj.url.startsWith('http')) {
|
||||
if (isVideoStr(obj.url) || act.activity_type === 'ac_video' || act.type === 'video') {
|
||||
if (!videoUrl) videoUrl = obj.url;
|
||||
} else if (!imageUrl) {
|
||||
imageUrl = obj.url;
|
||||
}
|
||||
}
|
||||
|
||||
// Check thumbnail
|
||||
const possibleThumbs = [obj.thumb_url, obj.thumbnail_url, obj.thumbnail, obj.preview_url, obj.preview_image_url];
|
||||
for (const th of possibleThumbs) {
|
||||
if (typeof th === 'string' && th.startsWith('http') && !thumbUrl) {
|
||||
thumbUrl = th;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into nested common containers
|
||||
if (obj.media) search(obj.media, depth + 1);
|
||||
if (obj.video_data) search(obj.video_data, depth + 1);
|
||||
if (obj.attachments) search(obj.attachments, depth + 1);
|
||||
if (obj.items) search(obj.items, depth + 1);
|
||||
if (obj.photos) search(obj.photos, depth + 1);
|
||||
if (obj.videos) search(obj.videos, depth + 1);
|
||||
if (obj.assets) search(obj.assets, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan root act and common child objects
|
||||
search(act);
|
||||
|
||||
// If it's an ac_video activity and imageUrl was found but points to video, fix assignment
|
||||
if ((act.activity_type === 'ac_video' || act.type === 'video') && !videoUrl && imageUrl) {
|
||||
videoUrl = imageUrl;
|
||||
imageUrl = null;
|
||||
}
|
||||
|
||||
return { imageUrl, videoUrl, thumbUrl };
|
||||
}
|
||||
|
||||
function normalizeActivity(act) {
|
||||
const mediaObj = act.media || {};
|
||||
const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url;
|
||||
const highResVideo = mediaObj.video_url || act.video_url;
|
||||
const { imageUrl, videoUrl, thumbUrl } = extractMediaUrls(act);
|
||||
const actType = (act.activity_type || act.action_type || act.type || 'activity').toLowerCase();
|
||||
const rawSubtype = (act.action_type || act.event_type || act.subtype || act.sub_type || act.action || act.state || act.status || '').toLowerCase();
|
||||
|
||||
// Extract staff / actor name
|
||||
const teacherName = act.actor_name || act.created_by?.name || act.teacher_name || act.staff_name || (act.actor ? `${act.actor.first_name || ''} ${act.actor.last_name || ''}`.trim() : '') || '';
|
||||
|
||||
// Parse Subtypes and Human-readable Summaries for each category
|
||||
let actionSummary = '';
|
||||
let actionSubtype = rawSubtype;
|
||||
let foodType = act.food_type || act.meal_type || act.menu_type || null;
|
||||
let amountEaten = act.amount_eaten || act.quantity || act.amount || null;
|
||||
let napStart = act.start_time || act.nap_start || act.started_at || null;
|
||||
let napEnd = act.end_time || act.nap_end || act.ended_at || null;
|
||||
let durationMinutes = act.duration_minutes || act.duration_in_minutes || act.duration || null;
|
||||
let pottyType = act.potty_type || act.diaper_type || act.potty || act.diaper || null;
|
||||
let milestoneName = act.milestone_name || act.tag_name || act.skill_name || act.milestone || null;
|
||||
let attendanceInfo = '';
|
||||
|
||||
// 1. NAP
|
||||
if (actType.includes('nap')) {
|
||||
if (rawSubtype.includes('start') || (napStart && !napEnd && !durationMinutes)) {
|
||||
actionSubtype = 'start_nap';
|
||||
actionSummary = 'Nap Started';
|
||||
} else if (rawSubtype.includes('end') || rawSubtype.includes('wake') || napEnd || durationMinutes) {
|
||||
actionSubtype = 'end_nap';
|
||||
const durText = durationMinutes ? `${durationMinutes} mins` : '';
|
||||
actionSummary = durText ? `Nap Ended (${durText})` : 'Nap Ended';
|
||||
} else {
|
||||
actionSubtype = 'nap';
|
||||
actionSummary = durationMinutes ? `Nap (${durationMinutes} mins)` : 'Nap Log';
|
||||
}
|
||||
}
|
||||
// 2. CHECK-IN / ATTENDANCE
|
||||
else if (actType.includes('checkin') || actType.includes('checkout') || actType.includes('attendance')) {
|
||||
const isOut = rawSubtype.includes('out') || rawSubtype.includes('away') || rawSubtype.includes('pickup') || actType.includes('checkout');
|
||||
const signedBy = act.signed_in_by || act.signed_out_by || act.guardian_name || act.actor_name || teacherName || '';
|
||||
if (isOut) {
|
||||
actionSubtype = 'signed_out';
|
||||
actionSummary = signedBy ? `Checked Out / Signed Out (by ${signedBy})` : 'Checked Out / Signed Out';
|
||||
} else {
|
||||
actionSubtype = 'signed_in';
|
||||
actionSummary = signedBy ? `Checked In / Signed In (by ${signedBy})` : 'Checked In / Signed In';
|
||||
}
|
||||
attendanceInfo = signedBy ? `Signed by: ${signedBy}` : '';
|
||||
}
|
||||
// 3. FOOD / MEALS
|
||||
else if (actType.includes('food') || actType.includes('meal')) {
|
||||
if (!foodType && rawSubtype) foodType = rawSubtype;
|
||||
const mealLabel = foodType ? capitalize(foodType) : 'Meal';
|
||||
const eatLabel = amountEaten ? `Ate ${amountEaten}` : '';
|
||||
actionSubtype = foodType || 'meal';
|
||||
actionSummary = eatLabel ? `${mealLabel} (${eatLabel})` : mealLabel;
|
||||
}
|
||||
// 4. POTTY / DIAPER
|
||||
else if (actType.includes('potty') || actType.includes('diaper')) {
|
||||
if (!pottyType && rawSubtype) pottyType = rawSubtype;
|
||||
const pLabel = pottyType ? capitalize(pottyType) : 'Check';
|
||||
actionSubtype = pottyType || 'potty';
|
||||
actionSummary = `Potty / Diaper (${pLabel})`;
|
||||
}
|
||||
// 5. PHOTOS & VIDEOS
|
||||
else if (actType.includes('video') || videoUrl) {
|
||||
actionSubtype = 'video';
|
||||
actionSummary = act.note ? `Video: "${act.note}"` : 'Video Clip';
|
||||
} else if (actType.includes('photo') || imageUrl) {
|
||||
actionSubtype = 'photo';
|
||||
actionSummary = act.note ? `Photo: "${act.note}"` : 'Photo';
|
||||
}
|
||||
// 6. MILESTONES & OBSERVATIONS
|
||||
else if (actType.includes('milestone') || actType.includes('observation') || actType.includes('learn') || actType.includes('kudos')) {
|
||||
actionSubtype = 'milestone';
|
||||
actionSummary = milestoneName ? `Milestone: ${milestoneName}` : (act.title || 'Learning Milestone');
|
||||
}
|
||||
// 7. MEDICATIONS
|
||||
else if (actType.includes('med')) {
|
||||
actionSubtype = 'medication';
|
||||
actionSummary = `Medication Administered: ${act.medication_name || act.title || ''}`;
|
||||
}
|
||||
// 8. INCIDENTS
|
||||
else if (actType.includes('incident')) {
|
||||
actionSubtype = 'incident';
|
||||
actionSummary = `Incident Report: ${act.title || act.incident_type || ''}`;
|
||||
}
|
||||
// 9. HEALTH / SCREENING
|
||||
else if (actType.includes('health') || actType.includes('screening')) {
|
||||
actionSubtype = 'health_screening';
|
||||
actionSummary = act.temperature ? `Health Screening (Temp: ${act.temperature}°)` : 'Health Screening';
|
||||
attendanceInfo = act.temperature ? `Temp: ${act.temperature}°` : '';
|
||||
}
|
||||
// 10. DEFAULT / NOTES
|
||||
else {
|
||||
actionSubtype = rawSubtype || actType;
|
||||
actionSummary = act.title || act.note || capitalize(actType.replace('ac_', ''));
|
||||
}
|
||||
|
||||
return {
|
||||
id: act.id || act.object_id || Math.random().toString(36).substring(2),
|
||||
created_at: act.created_at || act.event_date || new Date().toISOString(),
|
||||
activity_type: act.activity_type || act.action_type || act.type || 'activity',
|
||||
activity_type: act.activity_type || actType,
|
||||
action_subtype: actionSubtype,
|
||||
action_summary: actionSummary,
|
||||
title: act.title || '',
|
||||
note: act.note || act.description || act.body || '',
|
||||
teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '',
|
||||
image_url: highResImage || null,
|
||||
video_url: highResVideo || null,
|
||||
thumb_url: mediaObj.thumb_url || act.thumb_url || null,
|
||||
food_type: act.food_type || act.meal_type || null,
|
||||
amount_eaten: act.amount_eaten || act.quantity || null,
|
||||
nap_start: act.start_time || act.nap_start || null,
|
||||
nap_end: act.end_time || act.nap_end || null,
|
||||
duration_minutes: act.duration_minutes || null,
|
||||
potty_type: act.potty_type || act.diaper_type || null,
|
||||
milestone_name: act.milestone_name || act.tag_name || null,
|
||||
media_local_path: null // Populated during ZIP bundling
|
||||
note: act.note || act.description || act.body || act.comment || '',
|
||||
teacher_name: teacherName,
|
||||
image_url: imageUrl || null,
|
||||
video_url: videoUrl || null,
|
||||
thumb_url: thumbUrl || null,
|
||||
food_type: foodType,
|
||||
amount_eaten: amountEaten,
|
||||
nap_start: napStart,
|
||||
nap_end: napEnd,
|
||||
duration_minutes: durationMinutes,
|
||||
potty_type: pottyType,
|
||||
milestone_name: milestoneName,
|
||||
attendance_info: attendanceInfo,
|
||||
media_local_path: null, // Populated during ZIP bundling
|
||||
raw: act // Preserve 100% full raw backend payload
|
||||
};
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
if (!s || typeof s !== 'string') return '';
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// Media Downloader with Concurrency & Progress
|
||||
// -------------------------------------------------------------
|
||||
@@ -570,7 +777,7 @@ function extractMediaQueue() {
|
||||
state.activities.forEach((act) => {
|
||||
const url = act.video_url || act.image_url;
|
||||
if (url) {
|
||||
const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video';
|
||||
const isVideo = !!act.video_url || act.activity_type === 'ac_video' || act.activity_type === 'video' || url.includes('.mp4');
|
||||
const ext = isVideo ? 'mp4' : 'jpg';
|
||||
const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date';
|
||||
const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`;
|
||||
@@ -583,6 +790,7 @@ function extractMediaQueue() {
|
||||
state.mediaQueue.push({
|
||||
activityId: act.id,
|
||||
url: url,
|
||||
isVideo: isVideo,
|
||||
filename: filename,
|
||||
localZipPath: localZipPath,
|
||||
blob: null,
|
||||
@@ -600,6 +808,10 @@ async function downloadAllMediaFiles() {
|
||||
const concurrency = 3;
|
||||
let activeIndex = 0;
|
||||
|
||||
const videoCount = queue.filter(q => q.isVideo).length;
|
||||
const photoCount = queue.length - videoCount;
|
||||
log(`Media to download: ${photoCount} photos, ${videoCount} videos.`, 'info');
|
||||
|
||||
updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
|
||||
|
||||
async function worker() {
|
||||
@@ -622,7 +834,7 @@ async function downloadAllMediaFiles() {
|
||||
} catch (err) {
|
||||
item.status = 'failed';
|
||||
state.stats.mediaFailed++;
|
||||
log(`Failed to download media ${item.filename}: ${err.message}`, 'warn');
|
||||
log(`Failed to download ${item.isVideo ? 'video' : 'photo'} ${item.filename}: ${err.message}`, 'warn');
|
||||
}
|
||||
|
||||
updateMetrics();
|
||||
@@ -643,7 +855,7 @@ async function fetchMediaBlob(url) {
|
||||
let retries = 0;
|
||||
while (retries < 3) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { credentials: 'omit' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.blob();
|
||||
} catch (err) {
|
||||
@@ -764,26 +976,31 @@ function generateCSV(activities) {
|
||||
'Activity ID',
|
||||
'Date & Time',
|
||||
'Type',
|
||||
'Event Action / Summary',
|
||||
'Subtype',
|
||||
'Title',
|
||||
'Note / Caption',
|
||||
'Logged By',
|
||||
'Staff / Logged By',
|
||||
'Media File Path',
|
||||
'Food / Meal',
|
||||
'Amount Eaten',
|
||||
'Nap Start',
|
||||
'Nap End',
|
||||
'Nap Mins',
|
||||
'Potty Type',
|
||||
'Milestone'
|
||||
'Nap Duration (Mins)',
|
||||
'Potty / Diaper Type',
|
||||
'Milestone',
|
||||
'Attendance / Health Info'
|
||||
];
|
||||
|
||||
const rows = activities.map(a => [
|
||||
escapeCSV(a.id),
|
||||
escapeCSV(a.created_at),
|
||||
escapeCSV(a.activity_type),
|
||||
escapeCSV(a.title),
|
||||
escapeCSV(a.note),
|
||||
escapeCSV(a.teacher_name),
|
||||
escapeCSV(a.action_summary || ''),
|
||||
escapeCSV(a.action_subtype || ''),
|
||||
escapeCSV(a.title || ''),
|
||||
escapeCSV(a.note || ''),
|
||||
escapeCSV(a.teacher_name || ''),
|
||||
escapeCSV(a.media_local_path || a.video_url || a.image_url || ''),
|
||||
escapeCSV(a.food_type || ''),
|
||||
escapeCSV(a.amount_eaten || ''),
|
||||
@@ -791,7 +1008,8 @@ function generateCSV(activities) {
|
||||
escapeCSV(a.nap_end || ''),
|
||||
escapeCSV(a.duration_minutes || ''),
|
||||
escapeCSV(a.potty_type || ''),
|
||||
escapeCSV(a.milestone_name || '')
|
||||
escapeCSV(a.milestone_name || ''),
|
||||
escapeCSV(a.attendance_info || '')
|
||||
]);
|
||||
|
||||
return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n');
|
||||
@@ -810,3 +1028,4 @@ function sanitizeFilename(str) {
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user