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) { function matchesSelectedCategories(act) {
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase(); 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 (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 (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 (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 (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; if (el.catMessages.checked) return true;
} }
// Default: include all other action types
return true; 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) { function normalizeActivity(act) {
const mediaObj = act.media || {}; const { imageUrl, videoUrl, thumbUrl } = extractMediaUrls(act);
const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url; const actType = (act.activity_type || act.action_type || act.type || 'activity').toLowerCase();
const highResVideo = mediaObj.video_url || act.video_url; 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 { return {
id: act.id || act.object_id || Math.random().toString(36).substring(2), id: act.id || act.object_id || Math.random().toString(36).substring(2),
created_at: act.created_at || act.event_date || new Date().toISOString(), 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 || '', title: act.title || '',
note: act.note || act.description || act.body || '', note: act.note || act.description || act.body || act.comment || '',
teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '', teacher_name: teacherName,
image_url: highResImage || null, image_url: imageUrl || null,
video_url: highResVideo || null, video_url: videoUrl || null,
thumb_url: mediaObj.thumb_url || act.thumb_url || null, thumb_url: thumbUrl || null,
food_type: act.food_type || act.meal_type || null, food_type: foodType,
amount_eaten: act.amount_eaten || act.quantity || null, amount_eaten: amountEaten,
nap_start: act.start_time || act.nap_start || null, nap_start: napStart,
nap_end: act.end_time || act.nap_end || null, nap_end: napEnd,
duration_minutes: act.duration_minutes || null, duration_minutes: durationMinutes,
potty_type: act.potty_type || act.diaper_type || null, potty_type: pottyType,
milestone_name: act.milestone_name || act.tag_name || null, milestone_name: milestoneName,
media_local_path: null // Populated during ZIP bundling 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 // Media Downloader with Concurrency & Progress
// ------------------------------------------------------------- // -------------------------------------------------------------
@@ -570,7 +777,7 @@ function extractMediaQueue() {
state.activities.forEach((act) => { state.activities.forEach((act) => {
const url = act.video_url || act.image_url; const url = act.video_url || act.image_url;
if (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 ext = isVideo ? 'mp4' : 'jpg';
const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date'; const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date';
const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`; const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`;
@@ -583,6 +790,7 @@ function extractMediaQueue() {
state.mediaQueue.push({ state.mediaQueue.push({
activityId: act.id, activityId: act.id,
url: url, url: url,
isVideo: isVideo,
filename: filename, filename: filename,
localZipPath: localZipPath, localZipPath: localZipPath,
blob: null, blob: null,
@@ -600,6 +808,10 @@ async function downloadAllMediaFiles() {
const concurrency = 3; const concurrency = 3;
let activeIndex = 0; 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...`); updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
async function worker() { async function worker() {
@@ -622,7 +834,7 @@ async function downloadAllMediaFiles() {
} catch (err) { } catch (err) {
item.status = 'failed'; item.status = 'failed';
state.stats.mediaFailed++; 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(); updateMetrics();
@@ -643,7 +855,7 @@ async function fetchMediaBlob(url) {
let retries = 0; let retries = 0;
while (retries < 3) { while (retries < 3) {
try { try {
const res = await fetch(url); const res = await fetch(url, { credentials: 'omit' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.blob(); return await res.blob();
} catch (err) { } catch (err) {
@@ -764,26 +976,31 @@ function generateCSV(activities) {
'Activity ID', 'Activity ID',
'Date & Time', 'Date & Time',
'Type', 'Type',
'Event Action / Summary',
'Subtype',
'Title', 'Title',
'Note / Caption', 'Note / Caption',
'Logged By', 'Staff / Logged By',
'Media File Path', 'Media File Path',
'Food / Meal', 'Food / Meal',
'Amount Eaten', 'Amount Eaten',
'Nap Start', 'Nap Start',
'Nap End', 'Nap End',
'Nap Mins', 'Nap Duration (Mins)',
'Potty Type', 'Potty / Diaper Type',
'Milestone' 'Milestone',
'Attendance / Health Info'
]; ];
const rows = activities.map(a => [ const rows = activities.map(a => [
escapeCSV(a.id), escapeCSV(a.id),
escapeCSV(a.created_at), escapeCSV(a.created_at),
escapeCSV(a.activity_type), escapeCSV(a.activity_type),
escapeCSV(a.title), escapeCSV(a.action_summary || ''),
escapeCSV(a.note), escapeCSV(a.action_subtype || ''),
escapeCSV(a.teacher_name), escapeCSV(a.title || ''),
escapeCSV(a.note || ''),
escapeCSV(a.teacher_name || ''),
escapeCSV(a.media_local_path || a.video_url || a.image_url || ''), escapeCSV(a.media_local_path || a.video_url || a.image_url || ''),
escapeCSV(a.food_type || ''), escapeCSV(a.food_type || ''),
escapeCSV(a.amount_eaten || ''), escapeCSV(a.amount_eaten || ''),
@@ -791,7 +1008,8 @@ function generateCSV(activities) {
escapeCSV(a.nap_end || ''), escapeCSV(a.nap_end || ''),
escapeCSV(a.duration_minutes || ''), escapeCSV(a.duration_minutes || ''),
escapeCSV(a.potty_type || ''), 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'); return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n');
@@ -810,3 +1028,4 @@ function sanitizeFilename(str) {
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
+9 -4
View File
@@ -358,7 +358,7 @@
}) : ''; }) : '';
const mediaPath = act.media_local_path || act.video_url || act.image_url; const mediaPath = act.media_local_path || act.video_url || act.image_url;
const isVideo = (act.video_url || mediaPath?.endsWith('.mp4') || act.activity_type === 'video'); const isVideo = (act.video_url || mediaPath?.endsWith('.mp4') || act.activity_type === 'ac_video' || act.activity_type === 'video');
let mediaHtml = ''; let mediaHtml = '';
if (mediaPath) { if (mediaPath) {
@@ -374,7 +374,7 @@
detailLines.push(`🍽️ <strong>${act.food_type || 'Meal'}</strong>: ${act.amount_eaten || 'Eaten'}`); detailLines.push(`🍽️ <strong>${act.food_type || 'Meal'}</strong>: ${act.amount_eaten || 'Eaten'}`);
} }
if (act.nap_start || act.nap_end || act.duration_minutes) { if (act.nap_start || act.nap_end || act.duration_minutes) {
detailLines.push(`😴 <strong>Nap Duration</strong>: ${act.duration_minutes ? act.duration_minutes + ' mins' : (act.nap_start || '') + ' - ' + (act.nap_end || '')}`); detailLines.push(`😴 <strong>Nap</strong>: ${act.duration_minutes ? act.duration_minutes + ' mins' : (act.nap_start || '') + ' - ' + (act.nap_end || '')}`);
} }
if (act.potty_type) { if (act.potty_type) {
detailLines.push(`🚽 <strong>Potty</strong>: ${act.potty_type}`); detailLines.push(`🚽 <strong>Potty</strong>: ${act.potty_type}`);
@@ -382,16 +382,21 @@
if (act.milestone_name) { if (act.milestone_name) {
detailLines.push(`🌟 <strong>Milestone</strong>: ${act.milestone_name}`); detailLines.push(`🌟 <strong>Milestone</strong>: ${act.milestone_name}`);
} }
if (act.attendance_info) {
detailLines.push(`📋 <strong>Attendance</strong>: ${act.attendance_info}`);
}
const displayTitle = act.action_summary || act.title || act.activity_type;
return ` return `
<div class="feed-card"> <div class="feed-card">
${mediaHtml} ${mediaHtml}
<div class="card-body"> <div class="card-body">
<div class="card-header"> <div class="card-header">
<span class="type-badge type-${type}">${act.activity_type || type}</span> <span class="type-badge type-${type}">${act.action_subtype || act.activity_type || type}</span>
<span class="card-time">${formattedDate}</span> <span class="card-time">${formattedDate}</span>
</div> </div>
${act.title ? `<div class="card-title">${act.title}</div>` : ''} <div class="card-title">${displayTitle}</div>
${act.note ? `<div class="card-note">${act.note}</div>` : ''} ${act.note ? `<div class="card-note">${act.note}</div>` : ''}
${detailLines.length > 0 ? `<div class="card-details">${detailLines.join('<br>')}</div>` : ''} ${detailLines.length > 0 ? `<div class="card-details">${detailLines.join('<br>')}</div>` : ''}
<div class="card-footer"> <div class="card-footer">