// =========================================================
// מנוע סקרים אוניברסלי מבוסס גיליון - גרסה משופרת, נקייה וידידותית למשתמש
// כולל שיפורי ביצועים (Batch), טיפול בסקייל (TextFinder) ומניעת קריסות
// =========================================================
/**
* יצירת תפריט מותאם אישית ברגע שפותחים את הגיליון
*/
function onOpen() {
SpreadsheetApp.getUi().createMenu('מערכת סקרים 📊')
.addItem('1. התקנה ועיצוב אוטומטי 🏗️', 'setupSystem')
.addSeparator()
.addItem('2. הוסף מילון לסקר חדש ➕', 'addNewPollDictionary')
.addSeparator()
.addItem('3. שלח קמפיין לרשימת התפוצה 🚀', 'triggerSendEmails')
.addSeparator()
.addItem('4. הפק דשבורד גרפים 📈', 'generateGraphs')
.addToUi();
}
// ---------------------------------------------------------
// פונקציית התקנה אוטומטית - בונה, מעצבת ומנגישה את המערכת
// ---------------------------------------------------------
function setupSystem() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
// --- 1. גיליון הצבעות ---
let sheetVotes = ss.getSheetByName("הצבעות");
if (!sheetVotes) sheetVotes = ss.insertSheet("הצבעות");
sheetVotes.setTabColor("#34a853"); // צבע ירוק
if (sheetVotes.getLastRow() === 0) {
sheetVotes.getRange("A1:E1").setValues([["מייל הבוחר", "מזהה סקר", "הצבעה", "תאריך ושעה", "דפדפן/מכשיר"]])
.setFontWeight("bold").setBackground("#d9ead3").setBorder(true, true, true, true, true, true);
sheetVotes.setFrozenRows(1);
sheetVotes.setColumnWidths(1, 5, 150);
}
// --- 2. רשימת תפוצה ---
let sheetList = ss.getSheetByName("רשימת_תפוצה");
if (!sheetList) sheetList = ss.insertSheet("רשימת_תפוצה");
sheetList.setTabColor("#4285f4"); // צבע כחול
if (sheetList.getLastRow() === 0) {
sheetList.getRange("A1:C1").setValues([["כתובת מייל (חובה)", "שם הנמען (אופציונלי)", "סטטוס שליחה"]])
.setFontWeight("bold").setBackground("#c9daf8").setBorder(true, true, true, true, true, true);
sheetList.setFrozenRows(1);
sheetList.setColumnWidths(1, 3, 200);
// דוגמה למילוי
sheetList.getRange("A2:B2").setValues([["example@gmail.com", "ישראל ישראלי"]]);
}
// --- 3. גיליון הגדרות ---
let sheetSettings = ss.getSheetByName("הגדרות");
if (!sheetSettings) sheetSettings = ss.insertSheet("הגדרות");
sheetSettings.setTabColor("#fbbc04"); // צבע כתום
if (sheetSettings.getLastRow() === 0) {
// תבנית HTML ברירת מחדל מעוצבת ומוכנה מראש
const defaultHtml = `
שלום {{NAME}}, נשמח לשמוע את דעתך!
אנא לחץ על התשובה המתאימה לכל שאלה. שימו לב: לאחר כל לחיצה, אנא חזרו למייל כדי לענות על השאלה הבאה.
1. איך היית מדרג את השירות הכללי שקיבלת?
`;
// הנתונים להזנה
const settingsData = [
["🛠️ הגדרות כלליות", "הזן את הערכים בעמודה זו 👇", ""],
["כתובת השרת (Web App URL)\n*חובה לייצר פריסה ולהדביק כאן", "", ""],
["נושא המייל", "סקר שביעות רצון - נשמח לדעתך", ""],
["הודעת הצלחה (בסיום ההצבעה)", "תודה רבה! ההצבעה נקלטה בהצלחה. תוכלו כעת לסגור חלון זה ולחזור למייל כדי לענות על שאר השאלות.", ""],
["הודעת שגיאה (לחיצה כפולה)", "שמנו לב שכבר הצבעת בשאלה זו. אנא חזרו למייל להמשך המענה.", ""],
["כתובת הפניה (URL) - אופציונלי\n*אם מלא, יעביר את הלקוח לאתר שלך במקום הודעה", "", ""],
["", "", ""],
["✉️ תבנית מייל (קוד HTML)", defaultHtml, ""],
["", "", ""],
["🔠 מזהה סקר (poll_id)", "🔢 קוד (vote)", "📝 טקסט מתורגם"],
["כללי", "1", "מצוין"],
["כללי", "2", "בינוני"],
["כללי", "3", "טעון שיפור"]
];
// הכנסת הנתונים לתאים ב-3 עמודות
sheetSettings.getRange(1, 1, settingsData.length, 3).setValues(settingsData);
// מיזוג תאים כדי שההגדרות העליונות ייראו טוב
sheetSettings.getRange("B1:C1").merge();
sheetSettings.getRange("B2:C2").merge();
sheetSettings.getRange("B3:C3").merge();
sheetSettings.getRange("B4:C4").merge();
sheetSettings.getRange("B5:C5").merge();
sheetSettings.getRange("B6:C6").merge();
sheetSettings.getRange("B8:C8").merge();
// עיצוב הגיליון החדש
sheetSettings.setColumnWidth(1, 200);
sheetSettings.setColumnWidth(2, 200);
sheetSettings.setColumnWidth(3, 300);
// עיצוב כותרות
sheetSettings.getRange("A1:C1").setFontWeight("bold").setBackground("#ffd966").setFontSize(14).setBorder(true, true, true, true, null, null);
sheetSettings.getRange("A10:C10").setFontWeight("bold").setBackground("#ffd966").setFontSize(14).setBorder(true, true, true, true, null, null);
// עיצוב שורת ה-HTML
sheetSettings.getRange("A8").setFontWeight("bold").setBackground("#e0e0e0");
const htmlCell = sheetSettings.getRange("B8");
htmlCell.setWrap(true).setBackground("#f8f9fa").setFontFamily("Courier New");
sheetSettings.setRowHeight(8, 200);
// עידון הטקסט והגבולות
sheetSettings.getRange("A2:A6").setFontWeight("bold");
sheetSettings.getRange("A2:C6").setBorder(true, true, true, true, true, true);
sheetSettings.getRange("A11:C13").setBorder(true, true, true, true, true, true);
}
// --- 4. ניקוי גיליונות ריקים ---
const defaultSheet = ss.getSheetByName("גיליון1") || ss.getSheetByName("Sheet1");
if (defaultSheet && ss.getSheets().length > 1) {
ss.deleteSheet(defaultSheet);
}
SpreadsheetApp.getUi().alert(
"ההתקנה הושלמה בהצלחה! 🎉",
"1. הגיליונות סודרו ונצבעו לנוחותך.\n2. נוצרה תבנית מייל אוטומטית בגיליון 'הגדרות'.\n3. כדי להתחיל, עליך להפיק קישור לפריסה (Deploy) ולהדביק אותו בתא B2 בגיליון ההגדרות.",
SpreadsheetApp.getUi().ButtonSet.OK
);
}
// ---------------------------------------------------------
// פונקציית מעטפת לשליחת המיילים (כולל חלונית אישור ובדיקות)
// ---------------------------------------------------------
function triggerSendEmails() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName("הגדרות");
const serverUrl = settingsSheet.getRange("B2").getValue();
if (!serverUrl || serverUrl.trim() === "") {
ui.alert('⚠️ שגיאה לפני שליחה', 'לא הזנת את "כתובת השרת" בגיליון ההגדרות (תא B2).\nיש לבצע פריסה (Deploy -> Web App) ולהדביק את הקישור שם.', ui.ButtonSet.OK);
return;
}
const response = ui.alert('האם להתחיל בשליחה?', 'פעולה זו תשלח כעת מייל לכל מי שמופיע ב"רשימת_תפוצה".\nהאם אתה בטוח שברצונך להמשיך?', ui.ButtonSet.YES_NO);
if (response == ui.Button.YES) {
ss.toast("מתחיל בשליחת המיילים (זה עשוי לקחת כמה רגעים), אנא המתן...", "מערכת סקרים", 8);
const result = sendVotingEmails();
ui.alert('סטטוס שליחה 📬', result, ui.ButtonSet.OK);
}
}
// ---------------------------------------------------------
// שרת קליטה (doGet) - משופר עם LockService ו-TextFinder לעומסים גדולים
// ---------------------------------------------------------
function doGet(e) {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName("הצבעות");
const settingsSheet = ss.getSheetByName("הגדרות");
if (!sheet || !settingsSheet) return createResponseHtml('שגיאה: חסרות לשוניות. אנא הרץ התקנה אוטומטית.');
const successMsg = settingsSheet.getRange("B4").getValue() || "תודה! ההצבעה נקלטה.";
const errorMsg = settingsSheet.getRange("B5").getValue() || "כבר הצבעת בסקר זה.";
const redirectUrl = settingsSheet.getRange("B6").getValue();
const uid = e.parameter.uid || "unknown";
const pollId = e.parameter.poll_id || "general";
const vote = e.parameter.vote || "none";
const cacheKey = uid + "_" + pollId;
const cache = CacheService.getScriptCache();
// [שיפור ביצועים] מנגנון נעילה - מונע דריסת נתונים אם המון משתמשים מצביעים באותה שנייה
const lock = LockService.getScriptLock();
try {
// ממתין עד 10 שניות לשחרור הגיליון (אם תפוס על ידי לחיצה אחרת)
lock.waitLock(10000);
// 1. חסימת לחיצה כפולה ממטמון (Cache) - מהיר מאוד
if (cache.get(cacheKey)) return handleFinish(errorMsg, redirectUrl);
// 2. [שיפור לבעיית ה-Scale] - במקום לטעון את כל הגיליון (שעלול להיות עצום), מחפשים נקודתית
let alreadyVoted = false;
// חיפוש ה-UID בעמודה A בלבד באמצעות מנוע חיפוש פנימי (מהיר פי כמה)
const uidMatches = sheet.getRange("A:A").createTextFinder(uid).matchEntireCell(true).findAll();
for (let i = 0; i < uidMatches.length; i++) {
const row = uidMatches[i].getRow();
// בודקים אם באותה השורה שמצאנו, הסקר הספציפי כבר קיים
const existingPollId = sheet.getRange(row, 2).getValue();
if (String(existingPollId).trim() === String(pollId).trim()) {
alreadyVoted = true;
break;
}
}
if (alreadyVoted) {
cache.put(cacheKey, "1", 21600);
return handleFinish(errorMsg, redirectUrl);
}
// 3. תרגום הערך למילה קריאה
let voteText = decodeURIComponent(vote);
const dictionary = settingsSheet.getRange("A11:C50").getValues();
let globalTranslation = null;
for (let i = 0; i < dictionary.length; i++) {
const dictPollId = String(dictionary[i][0]).trim();
const dictVote = String(dictionary[i][1]).trim();
const dictTranslation = dictionary[i][2];
if (dictVote === String(vote).trim() && dictTranslation) {
if (dictPollId === String(pollId).trim()) {
voteText = dictTranslation;
break;
} else if (dictPollId === "כללי" || dictPollId === "") {
globalTranslation = dictTranslation;
}
}
}
if (voteText === decodeURIComponent(vote) && globalTranslation) {
voteText = globalTranslation;
}
// 4. שמירת ההצבעה החדשה
sheet.appendRow([uid, pollId, voteText, new Date(), e.parameter.userAgent || ""]);
cache.put(cacheKey, "1", 21600);
return handleFinish(successMsg, redirectUrl, voteText);
} catch (error) {
// במקרה של שגיאה בלתי צפויה או חריגת זמן נעילה
return createResponseHtml('אירעה שגיאה בעת עיבוד ההצבעה. נסה שנית בעוד מספר רגעים.');
} finally {
// משחרר את הנעילה כדי לאפשר למצביע הבא להיכנס, גם אם הייתה שגיאה
lock.releaseLock();
}
}
// ---------------------------------------------------------
// עיצוב מסך הסיום (הפניה או הודעה מעוצבת)
// ---------------------------------------------------------
function handleFinish(message, redirectUrl, voteText = "") {
if (redirectUrl && redirectUrl.indexOf("http") === 0) {
return HtmlService.createHtmlOutput(`
מעביר אותך ליעד...
`);
} else {
let voteDisplay = voteText ? `הבחירה שנקלטה: ${voteText}
` : '';
return HtmlService.createHtmlOutput(`
✅
${message}
${voteDisplay}
תוכל כעת לסגור חלון זה ולחזור למייל.
`);
}
}
function createResponseHtml(message) {
return HtmlService.createHtmlOutput(`
${message}
`);
}
// ---------------------------------------------------------
// מנוע שליחת מיילים אוטומטית - משופר עם Batch Operations ומניעת שגיאות אנוש
// ---------------------------------------------------------
function sendVotingEmails() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName("הגדרות");
const listSheet = ss.getSheetByName("רשימת_תפוצה");
if (!settingsSheet || !listSheet) return "שגיאה: הלשוניות חסרות. לחץ על 'התקנה ועיצוב אוטומטי' בתפריט.";
const serverUrl = settingsSheet.getRange("B2").getValue();
const emailSubject = settingsSheet.getRange("B3").getValue() || "סקר חדש";
const rawHtmlTemplate = String(settingsSheet.getRange("B8").getValue() || "");
if (!serverUrl || !serverUrl.includes("exec")) return "הפעולה בוטלה: חסרה כתובת שרת תקינה בתא B2 בגיליון ההגדרות.";
if (!rawHtmlTemplate.trim()) return "הפעולה בוטלה: חסר קוד תבנית המייל בתא B8 בגיליון ההגדרות.";
// [מניעת שגיאות אנוש] - ולידציה שקיימות התגיות ההכרחיות בקוד
if (rawHtmlTemplate.indexOf("{{SERVER_URL}}") === -1) {
return "שגיאה קריטית: חסרה התגית {{SERVER_URL}} בתוך קוד ה-HTML בתא B8. התגית קריטית כדי שהכפתורים יעבדו.";
}
if (rawHtmlTemplate.indexOf("{{EMAIL}}") === -1) {
return "שגיאה קריטית: חסרה התגית {{EMAIL}} בתוך קוד ה-HTML בתא B8. בלעדיה לא נוכל לדעת מי הצביע.";
}
const data = listSheet.getDataRange().getValues();
if (data.length <= 1) return "רשימת התפוצה ריקה. נא להזין כתובות מייל בגיליון 'רשימת_תפוצה'.";
let sentCount = 0;
let errorCount = 0;
// [שיפור ביצועים - Batch Operations] במקום לעדכן שורה-שורה, נאגור הכל למערכים
const statusUpdates = [];
const colorUpdates = [];
for (let i = 1; i < data.length; i++) {
const email = data[i][0];
const name = data[i][1];
// וידוא שהמייל לא ריק ושנראה כמו כתובת תקינה בסיסית
if (!email || String(email).indexOf("@") === -1) {
statusUpdates.push(["כתובת לא תקינה"]);
colorUpdates.push(["#ff0000"]); // צבע אדום
continue;
}
let personalizedHtml = rawHtmlTemplate
.replace(/{{SERVER_URL}}/g, serverUrl.trim())
.replace(/{{EMAIL}}/g, encodeURIComponent(String(email).trim()))
.replace(/{{NAME}}/g, name || 'לקוח יקר');
try {
MailApp.sendEmail({
to: String(email).trim(),
subject: emailSubject,
htmlBody: personalizedHtml
});
statusUpdates.push(["נשלח ✔"]);
colorUpdates.push(["#008000"]); // צבע ירוק
sentCount++;
} catch(err) {
statusUpdates.push(["שגיאה: " + err.message]);
colorUpdates.push(["#ff0000"]); // צבע אדום
errorCount++;
}
}
// עדכון גורף במכה אחת של כל הסטטוסים בגיליון (חוסך המון זמן ריצה)
if (statusUpdates.length > 0) {
const targetRange = listSheet.getRange(2, 3, statusUpdates.length, 1);
targetRange.setValues(statusUpdates);
targetRange.setFontColors(colorUpdates);
}
if (errorCount > 0) {
return `פעולת השליחה הסתיימה.\nנשלחו בהצלחה: ${sentCount} מיילים.\nנכשלו או לא תקינים: ${errorCount} (ראה עמודת סטטוס).`;
}
return `מצוין! הקמפיין הסתיים בהצלחה.\n${sentCount} מיילים נשלחו אל הנמענים.`;
}
// ---------------------------------------------------------
// פונקציה להוספת מילון כפתורים לסקר חדש - אוטומטית מתוך קוד ה-HTML
// ---------------------------------------------------------
function addNewPollDictionary() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const settingsSheet = ss.getSheetByName("הגדרות");
if (!settingsSheet) {
ui.alert('⚠️ שגיאה', 'גיליון "הגדרות" לא קיים. אנא הרץ קודם התקנה ועיצוב אוטומטי.', ui.ButtonSet.OK);
return;
}
const htmlTemplate = settingsSheet.getRange("B8").getValue();
if (!htmlTemplate) {
ui.alert('⚠️ שגיאה', 'לא נמצא קוד HTML בתא B8 בגיליון ההגדרות.', ui.ButtonSet.OK);
return;
}
const regex = /poll_id=([^&"']+)/g;
let match;
const foundPollIds = new Set();
while ((match = regex.exec(htmlTemplate)) !== null) {
foundPollIds.add(match[1].trim());
}
if (foundPollIds.size === 0) {
ui.alert('ℹ️ מידע', 'לא זוהו מזהי סקרים (poll_id) בתוך קוד ה-HTML.', ui.ButtonSet.OK);
return;
}
const lastRow = settingsSheet.getLastRow();
const existingPollIds = new Set();
if (lastRow >= 11) {
const dictionaryData = settingsSheet.getRange(11, 1, lastRow - 10, 1).getValues();
dictionaryData.forEach(row => {
if (row[0]) existingPollIds.add(String(row[0]).trim());
});
}
const newPollIds = Array.from(foundPollIds).filter(id => !existingPollIds.has(id));
if (newPollIds.length === 0) {
ui.alert('✅ הכל מעודכן', 'המערכת סרקה את קוד ה-HTML. כל מזהי הסקרים המופיעים בו כבר קיימים במילון למטה.', ui.ButtonSet.OK);
return;
}
const newData = [];
newPollIds.forEach(pollId => {
newData.push([pollId, "1", "מצוין"]);
newData.push([pollId, "2", "בינוני"]);
newData.push([pollId, "3", "טעון שיפור"]);
});
const range = settingsSheet.getRange(lastRow + 1, 1, newData.length, 3);
range.setValues(newData);
range.setBorder(true, true, true, true, true, true);
range.setBackground("#ffffff");
ui.alert('✅ זיהוי והוספה הצליחו', 'זוהו ונוספו למילון ' + newPollIds.length + ' סקרים חדשים מתוך קוד ה-HTML:\n\n' + newPollIds.join('\n') + '\n\nכעת ניתן לערוך את הטקסטים בעמודה C במידת הצורך.', ui.ButtonSet.OK);
}
// ---------------------------------------------------------
// מנוע לייצור דשבורד וגרפים אוטומטיים
// ---------------------------------------------------------
function generateGraphs() {
const ui = SpreadsheetApp.getUi();
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheetVotes = ss.getSheetByName("הצבעות");
if (!sheetVotes || sheetVotes.getLastRow() <= 1) {
ui.alert("⚠️ שגיאה", "אין עדיין מספיק נתונים בגיליון 'הצבעות' כדי ליצור גרפים.", ui.ButtonSet.OK);
return;
}
let sheetGraphs = ss.getSheetByName("דשבורד_גרפים");
if (sheetGraphs) {
ss.deleteSheet(sheetGraphs);
}
sheetGraphs = ss.insertSheet("דשבורד_גרפים");
sheetGraphs.setTabColor("#8e44ad");
sheetGraphs.getRange("A1").setValue("📊 דשבורד תוצאות סקרים").setFontSize(20).setFontWeight("bold");
const data = sheetVotes.getDataRange().getValues();
const pollStats = {};
for (let i = 1; i < data.length; i++) {
const pollId = String(data[i][1]).trim();
const vote = String(data[i][2]).trim();
if (!pollId || !vote) continue;
if (!pollStats[pollId]) {
pollStats[pollId] = {};
}
if (!pollStats[pollId][vote]) {
pollStats[pollId][vote] = 0;
}
pollStats[pollId][vote]++;
}
let rowPos = 3;
let colPos = 1;
for (const pollId in pollStats) {
const votes = pollStats[pollId];
const tableData = [["תשובה", "כמות הצבעות"]];
for (const vote in votes) {
tableData.push([vote, votes[vote]]);
}
const range = sheetGraphs.getRange(rowPos, colPos, tableData.length, 2);
range.setValues(tableData);
range.setBorder(true, true, true, true, true, true);
sheetGraphs.getRange(rowPos, colPos, 1, 2).setBackground("#d9ead3").setFontWeight("bold");
sheetGraphs.getRange(rowPos - 1, colPos).setValue("סקר: " + pollId).setFontWeight("bold").setFontSize(14).setFontColor("#2c3e50");
const chartBuilder = sheetGraphs.newChart()
.setChartType(Charts.ChartType.PIE)
.addRange(range)
.setPosition(rowPos, colPos + 3, 0, 0)
.setOption('title', 'התפלגות תגובות: ' + pollId)
.setOption('is3D', true)
.setOption('width', 450)
.setOption('height', 280);
sheetGraphs.insertChart(chartBuilder.build());
rowPos += Math.max(tableData.length + 2, 16);
}
sheetGraphs.setColumnWidth(1, 150);
sheetGraphs.setColumnWidth(2, 100);
ui.alert("✅ הדשבורד מוכן!", "הגרפים נוצרו בהצלחה. עבור ללשונית החדשה 'דשבורד_גרפים' כדי לצפות בהם.", ui.ButtonSet.OK);
}