feat: enhance action differentiators, deep video extraction, and full action type parsing

This commit is contained in:
2026-08-23 11:40:26 -07:00
parent fe670fdbec
commit 630936fac3
2 changed files with 261 additions and 37 deletions
+252 -33
View File
@@ -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));
}