خطوات الربط لـ Google Apps Script & Sheets
أنشئ ملف Google Sheet جديد.
من القائمة اختر Extensions > Apps Script واحذف أي كود افتراضي.
ألصق كود الـ Backend المرفق أدناه في محرر Apps Script.
أضف في Project Settings > Script Properties خاصية باسم TEACHER_EMAIL وضع بريدك الإلكتروني.
انقر فوق Deploy > New Deployment واختر نوع Web App (Execute as: Me, Who has access: Anyone).
انسخ الرابط الناتج (Web App URL). للأجهزة المتعددة الأفضل وضعه مرة واحدة داخل المتغير DEFAULT_BACKEND_URL قبل رفع الملف إلى GitHub؛ وبذلك تستخدمه جميع أجهزة الطالبات تلقائيًا.
يحفظ التطبيق تقدم كل مجموعة محليًا لمدة 3 ساعات من آخر تحديث ، ويعرض في لوحة الحصة السجلات الحديثة فقط.
✅ رابط الخادم مثبت داخل هذه النسخة
https://script.google.com/macros/s/AKfycbybzbqc-WbrjiZTJLuvtd1vjcmPpjGw2vz1McCWGpHdQ4KQyiHebFV5L0y3BLD9vFF1/exec
كود Google Apps Script المقترح (Backend):
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName('Leaderboard') || ss.insertSheet('Leaderboard');
const SESSION_TTL_MS = 3 * 60 * 60 * 1000; // 3 ساعات
const HEADERS = ['sessionId','leaderName','totalScore','status','currentActivity','startedAt','lastUpdatedAt','finishedAt'];
const ACTIVE_SESSION_KEY = 'CC146_ACTIVE_SESSION_ID';
function ensureSheetSetup() {
if (sheet.getLastRow() === 0) {
sheet.appendRow(HEADERS);
sheet.setFrozenRows(1);
return;
}
const first = String(sheet.getRange(1, 1).getValue() || '');
if (first !== 'sessionId') {
sheet.insertRowBefore(1);
sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
sheet.setFrozenRows(1);
}
}
function jsonOutput(obj) {
return ContentService.createTextOutput(JSON.stringify(obj))
.setMimeType(ContentService.MimeType.JSON);
}
// JSONP يتجاوز مشكلة CORS بين GitHub Pages وGoogle Apps Script.
function jsonpOutput(obj, callback) {
const cb = String(callback || '');
if (cb && /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(cb)) {
return ContentService.createTextOutput(cb + '(' + JSON.stringify(obj) + ');')
.setMimeType(ContentService.MimeType.JAVASCRIPT);
}
return jsonOutput(obj);
}
function makeSessionId() {
const tz = Session.getScriptTimeZone() || 'Asia/Riyadh';
return 'SES_' + Utilities.formatDate(new Date(), tz, 'yyyyMMdd_HHmmss') + '_' + Utilities.getUuid().slice(0, 6);
}
function getActiveSessionId() {
const props = PropertiesService.getScriptProperties();
let id = props.getProperty(ACTIVE_SESSION_KEY);
if (!id) {
id = makeSessionId();
props.setProperty(ACTIVE_SESSION_KEY, id);
}
return id;
}
function normalizeRequestData(p) {
p = p || {};
return {
action: String(p.action || ''),
sessionId: String(p.sessionId || ''),
leaderName: String(p.leaderName || ''),
totalScore: Number(p.totalScore || 0),
status: String(p.status || 'قيد الحل'),
currentActivity: Number(p.currentActivity || 1),
startedAt: String(p.startedAt || ''),
lastUpdatedAt: String(p.lastUpdatedAt || ''),
finishedAt: String(p.finishedAt || '')
};
}
function handleAction(data) {
const action = data.action || 'activeSession';
if (action === 'activeSession') return { success: true, sessionId: getActiveSessionId(), ttlHours: 3 };
if (action === 'newSession') return startNewSession();
if (action === 'sync') return syncRecord(data);
if (action === 'leaderboard') return getLeaderboardData();
if (action === 'email') return sendEmailReport();
return { success: false, error: 'Invalid action' };
}
function doGet(e) {
try {
ensureSheetSetup();
const p = (e && e.parameter) || {};
const data = normalizeRequestData(p);
const result = handleAction(data);
return jsonpOutput(result, p.callback);
} catch (err) {
const p = (e && e.parameter) || {};
return jsonpOutput({ success: false, error: String(err) }, p.callback);
}
}
// نبقي POST للتوافق والاختبار اليدوي، لكن واجهة GitHub تستخدم JSONP عبر GET.
function doPost(e) {
try {
ensureSheetSetup();
const raw = JSON.parse((e && e.postData && e.postData.contents) || '{}');
return jsonOutput(handleAction(normalizeRequestData(raw)));
} catch (err) {
return jsonOutput({ success: false, error: String(err) });
}
}
function startNewSession() {
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
ensureSheetSetup();
const lastRow = sheet.getLastRow();
if (lastRow > 1) sheet.deleteRows(2, lastRow - 1);
const newId = makeSessionId();
PropertiesService.getScriptProperties().setProperty(ACTIVE_SESSION_KEY, newId);
return { success: true, sessionId: newId, cleared: true };
} finally {
lock.releaseLock();
}
}
function syncRecord(data) {
const leaderName = String(data.leaderName || '').trim();
if (!leaderName) return { success: false, error: 'leaderName is required' };
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
ensureSheetSetup();
const sessionId = getActiveSessionId();
if (data.sessionId && String(data.sessionId) !== sessionId) {
return { success: true, sessionId, resetRequired: true, totalScore: 0, currentActivity: 1, status: 'قيد الحل' };
}
const incomingScore = Math.max(0, Number(data.totalScore) || 0);
const incomingActivity = Math.max(1, Number(data.currentActivity) || 1);
const incomingStatus = data.status === 'مكتمل' ? 'مكتمل' : 'قيد الحل';
const rows = sheet.getDataRange().getValues();
let foundRow = -1;
for (let i = 1; i < rows.length; i++) {
if (String(rows[i][0]) === sessionId && String(rows[i][1]).trim() === leaderName) {
foundRow = i + 1;
break;
}
}
const now = new Date().toISOString();
if (foundRow > 0) {
const old = rows[foundRow - 1];
const finalScore = Math.max(Number(old[2]) || 0, incomingScore);
const finalActivity = Math.max(Number(old[4]) || 1, incomingActivity);
const finalStatus = (String(old[3]) === 'مكتمل' || incomingStatus === 'مكتمل') ? 'مكتمل' : 'قيد الحل';
sheet.getRange(foundRow, 3).setValue(finalScore);
sheet.getRange(foundRow, 4).setValue(finalStatus);
sheet.getRange(foundRow, 5).setValue(finalActivity);
sheet.getRange(foundRow, 7).setValue(now);
if (finalStatus === 'مكتمل' && !old[7]) sheet.getRange(foundRow, 8).setValue(now);
return { success: true, sessionId, totalScore: finalScore, currentActivity: finalActivity, status: finalStatus };
}
sheet.appendRow([sessionId, leaderName, incomingScore, incomingStatus, incomingActivity, data.startedAt || now, now, incomingStatus === 'مكتمل' ? now : '']);
return { success: true, sessionId, totalScore: incomingScore, currentActivity: incomingActivity, status: incomingStatus };
} finally {
lock.releaseLock();
}
}
function getLeaderboardData() {
ensureSheetSetup();
const sid = getActiveSessionId();
const rows = sheet.getDataRange().getValues();
const list = [];
for (let i = 1; i < rows.length; i++) {
if (String(rows[i][0]) !== sid) continue;
const lastUpdated = new Date(rows[i][6]).getTime();
const isFresh = Number.isFinite(lastUpdated) && (Date.now() - lastUpdated <= SESSION_TTL_MS);
if (!isFresh) continue;
list.push({
sessionId: String(rows[i][0]),
leaderName: String(rows[i][1]),
totalScore: Number(rows[i][2]) || 0,
status: String(rows[i][3] || 'قيد الحل'),
currentActivity: Number(rows[i][4]) || 1,
startedAt: rows[i][5],
lastUpdatedAt: rows[i][6],
finishedAt: rows[i][7]
});
}
list.sort((a, b) => b.totalScore - a.totalScore || String(a.finishedAt || '9999').localeCompare(String(b.finishedAt || '9999')));
return { success: true, sessionId: sid, leaderboard: list, ttlHours: 3 };
}
function sendEmailReport() {
const email = PropertiesService.getScriptProperties().getProperty('TEACHER_EMAIL');
if (!email) return { success: false, error: 'TEACHER_EMAIL is not set' };
const data = getLeaderboardData();
let html = "<div dir='rtl' style='font-family:Arial,sans-serif'><h2>تقرير مركز التحكم الذكي - الثانوية 146</h2>";
html += "<p>نتائج الجلسة الحالية فقط.</p><table border='1' cellpadding='8' style='border-collapse:collapse;width:100%'><tr><th>الترتيب</th><th>القائدة</th><th>النقاط</th><th>الحالة</th></tr>";
data.leaderboard.forEach((item, idx) => {
html += `<tr><td>${idx + 1}</td><td>${escapeHtml(item.leaderName)}</td><td>${item.totalScore}</td><td>${escapeHtml(item.status)}</td></tr>`;
});
html += '</table></div>';
GmailApp.sendEmail(email, 'تقرير نتائج حصة التقنية الرقمية - مركز التحكم 146', 'يرجى عرض الرسالة بصيغة HTML.', { htmlBody: html });
return { success: true, count: data.leaderboard.length, sessionId: data.sessionId };
}
function escapeHtml(value) {
return String(value || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}