From d2febe2b18bcfe21fd55fdaa601509579b688bcc Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:44 +0000
Subject: [PATCH 01/13] Sync appsscript.json
---
appsscript.json | 14 ++++++++++++++
1 file changed, 14 insertions(+)
create mode 100644 appsscript.json
diff --git a/appsscript.json b/appsscript.json
new file mode 100644
index 0000000..ec36824
--- /dev/null
+++ b/appsscript.json
@@ -0,0 +1,14 @@
+{
+ "timeZone": "Asia/Bangkok",
+ "dependencies": {},
+ "exceptionLogging": "STACKDRIVER",
+ "runtimeVersion": "V8",
+ "webapp": {
+ "executeAs": "USER_DEPLOYING",
+ "access": "ANYONE_ANONYMOUS"
+ },
+ "oauthScopes": [
+ "https://www.googleapis.com/auth/script.projects.readonly",
+ "https://www.googleapis.com/auth/script.external_request"
+ ]
+}
\ No newline at end of file
--
2.52.0
From fc34eb502ddc2e59d869be1e304afb5fc346695e Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:47 +0000
Subject: [PATCH 02/13] Sync app/Code.gs
---
app/Code.gs | 660 ++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 660 insertions(+)
create mode 100644 app/Code.gs
diff --git a/app/Code.gs b/app/Code.gs
new file mode 100644
index 0000000..8418f62
--- /dev/null
+++ b/app/Code.gs
@@ -0,0 +1,660 @@
+/**
+ * SchoolHub Launcher - Backend Logic
+ * Core architecture: Google Sheets as Database
+ */
+
+const SPREADSHEET_ID = '1oWvHLC2tnLHHO36IxTErSCuvDWobsbI_LTlQwtMYiZU';
+const APP_ID = 'launcher';
+
+// --- CORE ENTRY POINT ---
+
+function doGet() {
+ return HtmlService
+ .createTemplateFromFile('Index')
+ .evaluate()
+ .setTitle('SchoolHub Launcher')
+ .addMetaTag('viewport', 'width=device-width, initial-scale=1')
+ .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
+}
+
+function include(filename) {
+ return HtmlService.createHtmlOutputFromFile(filename).getContent();
+}
+
+// --- DATABASE SETUP ---
+
+function setupDatabase() {
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+
+ const sheets = [
+ { name: 'Users', headers: ['email', 'name', 'phone', 'status', 'unit', 'isAdmin', 'createdAt', 'updatedAt'] },
+ { name: 'Access', headers: ['email', 'appId', 'roles', 'active', 'notes', 'updatedAt'] },
+ { name: 'Apps', headers: ['appId', 'appName', 'appUrl', 'active', 'sortOrder'] },
+ { name: 'AuditLog', headers: ['timestamp', 'email', 'appId', 'action', 'status', 'message', 'actor'] },
+ { name: 'OTP', headers: ['phone', 'code', 'expiresAt'] },
+ { name: 'Sessions', headers: ['sessionId', 'email', 'expiresAt'] },
+ { name: 'SystemDocs', headers: ['docType', 'content', 'updatedAt'] }
+ ];
+
+ sheets.forEach(({ name, headers }) => {
+ let sheet = ss.getSheetByName(name);
+ if (!sheet) sheet = ss.insertSheet(name);
+
+ // Always update headers to ensure schema is current
+ sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
+ });
+
+ return { ok: true, message: 'Database setup complete. Sheets and headers initialized.' };
+}
+
+// --- JSON SERIALIZATION HELPERS ---
+
+function serializeForClient(value) {
+ if (value === null || value === undefined) return null;
+
+ if (Object.prototype.toString.call(value) === '[object Date]') {
+ return value.toISOString();
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(serializeForClient);
+ }
+
+ if (typeof value === 'object') {
+ const out = {};
+ Object.keys(value).forEach((k) => {
+ out[k] = serializeForClient(value[k]);
+ });
+ return out;
+ }
+
+ return value;
+}
+
+// --- UTILITY HELPERS ---
+
+function normalizeEmail(email) {
+ if (!email) return '';
+ return String(email).trim().toLowerCase();
+}
+
+function normalizeBoolString(value) {
+ return String(value).toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE';
+}
+
+function isTrue_(value) {
+ return String(value).toUpperCase() === 'TRUE';
+}
+
+function toSafeText_(value) {
+ return value === null || value === undefined ? '' : String(value).trim();
+}
+
+function getCurrentUserEmail() {
+ return normalizeEmail(Session.getActiveUser().getEmail());
+}
+
+function rowsToObjects_(values) {
+ if (!values || values.length < 2) return [];
+ const headers = values[0];
+ const out = [];
+ for (let i = 1; i < values.length; i++) {
+ const row = values[i];
+ const obj = {};
+ for (let c = 0; c < headers.length; c++) obj[headers[c]] = row[c];
+ out.push(obj);
+ }
+ return out;
+}
+
+// --- PRIVATE DATABASE READERS ---
+
+function readUsers_(ss) {
+ const sheet = ss.getSheetByName('Users');
+ if (!sheet) return {};
+ const rows = rowsToObjects_(sheet.getDataRange().getValues());
+ const map = {};
+ rows.forEach((obj) => {
+ const email = normalizeEmail(obj.email);
+ if (email) map[email] = obj;
+ });
+ return map;
+}
+
+function readUsersByEmail_(ss, email) {
+ const users = readUsers_(ss);
+ return users[normalizeEmail(email)] || null;
+}
+
+function readApps_(ss) {
+ const sheet = ss.getSheetByName('Apps');
+ if (!sheet) return [];
+ return rowsToObjects_(sheet.getDataRange().getValues())
+ .filter(app => isTrue_(app.active))
+ .map(app => ({
+ appId: toSafeText_(app.appId),
+ appName: toSafeText_(app.appName),
+ appUrl: toSafeText_(app.appUrl),
+ active: true,
+ sortOrder: Number(app.sortOrder || 0)
+ }));
+}
+
+function readAccessRow_(ss, email, appId) {
+ const sheet = ss.getSheetByName('Access');
+ if (!sheet) return null;
+ const rows = rowsToObjects_(sheet.getDataRange().getValues());
+ return rows.find(r => normalizeEmail(r.email) === normalizeEmail(email) && String(r.appId) === String(appId)) || null;
+}
+
+function readAccessByEmail_(ss, email) {
+ const sheet = ss.getSheetByName('Access');
+ if (!sheet) return [];
+ return rowsToObjects_(sheet.getDataRange().getValues())
+ .filter(r => normalizeEmail(r.email) === normalizeEmail(email));
+}
+
+// --- AUTH & PERMISSIONS LOGIC ---
+
+function getAuthCacheKey_(email, appId) {
+ return `auth_${appId}_${email}`;
+}
+
+function clearAuthCache_(email, appId = APP_ID) {
+ CacheService.getScriptCache().remove(getAuthCacheKey_(email, appId));
+}
+
+function isAllowed(email, appId) {
+ if (!email) return { isAllowed: false, reason: 'Email is missing.' };
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const ownerEmail = normalizeEmail(ss.getOwner().getEmail());
+ const users = readUsers_(ss);
+ let user = users[email];
+
+ // Auto-admin for owner
+ if (!user && email === ownerEmail) {
+ const userSheet = ss.getSheetByName('Users');
+ const now = new Date();
+ userSheet.appendRow([email, 'Owner Admin', '', 'active', 'System', 'TRUE', now, now]);
+ user = { email, status: 'active', isAdmin: 'TRUE' };
+ clearAuthCache_(email, appId);
+ }
+
+ if (!user) return { isAllowed: false, reason: 'Email not whitelisted.' };
+ if (String(user.status || '').toLowerCase() !== 'active') return { isAllowed: false, reason: 'Account is inactive.' };
+ if (isTrue_(user.isAdmin)) return { isAllowed: true, reason: '' };
+ if (appId === APP_ID) return { isAllowed: true, reason: '' };
+
+ const access = readAccessRow_(ss, email, appId);
+ if (!access || !isTrue_(access.active)) return { isAllowed: false, reason: 'No active access for this app.' };
+
+ return { isAllowed: true, reason: '' };
+}
+
+function getAuthContext(email, appId) {
+ const cache = CacheService.getScriptCache();
+ const cacheKey = getAuthCacheKey_(email, appId);
+ const cached = cache.get(cacheKey);
+ if (cached) return JSON.parse(cached);
+
+ const allowed = isAllowed(email, appId);
+ if (!allowed.isAllowed) {
+ const denied = { isAllowed: false, reason: allowed.reason, roles: [] };
+ cache.put(cacheKey, JSON.stringify(denied), 300);
+ return denied;
+ }
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const userRow = readUsersByEmail_(ss, email);
+ const accessRow = readAccessRow_(ss, email, appId);
+
+ const roles = accessRow && accessRow.roles
+ ? String(accessRow.roles).split(',').map(r => r.trim()).filter(Boolean)
+ : (userRow && isTrue_(userRow.isAdmin) ? ['admin'] : []);
+
+ const result = {
+ isAllowed: true,
+ user: {
+ email: userRow ? userRow.email : email,
+ name: userRow ? (userRow.name || '') : '',
+ unit: userRow ? (userRow.unit || '') : ''
+ },
+ roles,
+ permissions: {
+ canManageUsers: roles.includes('admin') || (userRow && isTrue_(userRow.isAdmin)),
+ canEditApp: roles.includes('editor') || roles.includes('admin'),
+ canViewApp: true
+ }
+ };
+
+ cache.put(cacheKey, JSON.stringify(result), 300);
+ return result;
+}
+
+function getAllowedApps(email) {
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const apps = readApps_(ss);
+ const user = readUsersByEmail_(ss, email);
+
+ if (user && isTrue_(user.isAdmin)) {
+ return apps.sort((a, b) => a.sortOrder - b.sortOrder);
+ }
+
+ const accessRows = readAccessByEmail_(ss, email);
+ const allowedIds = new Set(accessRows.filter(r => isTrue_(r.active)).map(r => String(r.appId)));
+
+ return apps
+ .filter(a => allowedIds.has(String(a.appId)))
+ .sort((a, b) => a.sortOrder - b.sortOrder);
+}
+
+// --- PUBLIC API: FRONTEND DATA ---
+
+function getInitialData() {
+ try {
+ const email = getCurrentUserEmail();
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const userSheet = ss.getSheetByName('Users');
+ const isSystemEmpty = !userSheet || userSheet.getLastRow() < 2;
+
+ if (isSystemEmpty) {
+ return { ok: true, data: serializeForClient({ isSystemEmpty: true, userEmail: email }) };
+ }
+
+ if (!email) return { ok: true, data: serializeForClient({ needsAuth: true }) };
+
+ const auth = getAuthContext(email, APP_ID);
+ if (!auth.isAllowed) {
+ logAccess({ email, appId: APP_ID, action: 'ACCESS_DENIED', status: 'FAILURE', message: auth.reason });
+ return { ok: false, message: auth.reason };
+ }
+
+ const apps = getAllowedApps(email);
+ logAccess({ email, appId: APP_ID, action: 'ACCESS_GRANTED', status: 'SUCCESS', message: 'User loaded launcher' });
+
+ return {
+ ok: true,
+ data: serializeForClient({
+ user: auth.user,
+ apps,
+ isAdmin: auth.roles.includes('admin')
+ })
+ };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function registerFirstAdmin(name) {
+ try {
+ const email = getCurrentUserEmail();
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Users');
+ if (sheet && sheet.getLastRow() >= 2) throw new Error('System already initialized.');
+ if (!email) throw new Error('User email not available.');
+
+ const now = new Date();
+ sheet.appendRow([email, toSafeText_(name), '', 'active', 'System Admin', 'TRUE', now, now]);
+ clearAuthCache_(email);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+// --- PUBLIC API: ADMIN USER MANAGEMENT ---
+
+function getAdminUsers() {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const values = ss.getSheetByName('Users').getDataRange().getValues();
+ return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function saveUser(userData) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const targetEmail = normalizeEmail(userData.email);
+ if (!targetEmail || !userData.name) throw new Error('Email and Name are required');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Users');
+ const values = sheet.getDataRange().getValues();
+ const headers = values[0];
+ const emailIdx = headers.indexOf('email');
+
+ let rowIndex = -1;
+ for (let i = 1; i < values.length; i++) {
+ if (normalizeEmail(values[i][emailIdx]) === targetEmail) {
+ rowIndex = i + 1;
+ break;
+ }
+ }
+
+ const now = new Date();
+ const rowValue = headers.map(h => {
+ if (h === 'email') return targetEmail;
+ if (h === 'name') return toSafeText_(userData.name);
+ if (h === 'status') return toSafeText_(userData.status).toLowerCase() === 'inactive' ? 'inactive' : 'active';
+ if (h === 'unit') return toSafeText_(userData.unit);
+ if (h === 'isAdmin') return normalizeBoolString(userData.isAdmin);
+ if (h === 'updatedAt') return now;
+ if (h === 'createdAt' && rowIndex > 0) return values[rowIndex - 1][headers.indexOf('createdAt')];
+ if (h === 'createdAt') return now;
+ return toSafeText_(userData[h]);
+ });
+
+ if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
+ else sheet.appendRow(rowValue);
+
+ clearAuthCache_(targetEmail);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function deleteUser(targetEmail) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const userSheet = ss.getSheetByName('Users');
+ const accessSheet = ss.getSheetByName('Access');
+ const normalizedTarget = normalizeEmail(targetEmail);
+
+ const deleteByEmail = (sheet) => {
+ const data = sheet.getDataRange().getValues();
+ const emailIdx = data[0].indexOf('email');
+ for (let i = data.length - 1; i >= 1; i--) {
+ if (normalizeEmail(data[i][emailIdx]) === normalizedTarget) sheet.deleteRow(i + 1);
+ }
+ };
+
+ deleteByEmail(userSheet);
+ deleteByEmail(accessSheet);
+ clearAuthCache_(normalizedTarget);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+// --- PUBLIC API: ADMIN APP MANAGEMENT ---
+
+function getAdminApps() {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const values = ss.getSheetByName('Apps').getDataRange().getValues();
+ return { ok: true, data: serializeForClient(rowsToObjects_(values)) };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function saveApp(appData) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const appId = toSafeText_(appData.appId);
+ if (!appId || !appData.appName || !appData.appUrl) throw new Error('App ID, Name, and URL are required');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Apps');
+ const values = sheet.getDataRange().getValues();
+ const headers = values[0];
+ const appIdIdx = headers.indexOf('appId');
+
+ let rowIndex = -1;
+ for (let i = 1; i < values.length; i++) {
+ if (String(values[i][appIdIdx]) === appId) {
+ rowIndex = i + 1;
+ break;
+ }
+ }
+
+ const rowValue = headers.map(h => {
+ if (h === 'appId') return appId;
+ if (h === 'appName') return toSafeText_(appData.appName);
+ if (h === 'appUrl') return toSafeText_(appData.appUrl);
+ if (h === 'active') return normalizeBoolString(appData.active);
+ if (h === 'sortOrder') return Number(appData.sortOrder || 0);
+ return '';
+ });
+
+ if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
+ else sheet.appendRow(rowValue);
+
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function deleteApp(appId) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Apps');
+ const data = sheet.getDataRange().getValues();
+ const appIdIdx = data[0].indexOf('appId');
+
+ for (let i = data.length - 1; i >= 1; i--) {
+ if (String(data[i][appIdIdx]) === String(appId)) {
+ sheet.deleteRow(i + 1);
+ return { ok: true };
+ }
+ }
+ throw new Error('App not found');
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+// --- PUBLIC API: ACCESS MANAGEMENT ---
+
+function getAccessForUser(email) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ return {
+ ok: true,
+ data: serializeForClient({
+ userAccess: readAccessByEmail_(ss, email),
+ availableApps: readApps_(ss)
+ })
+ };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function saveAccess(accessData) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const targetEmail = normalizeEmail(accessData.email);
+ const appId = toSafeText_(accessData.appId);
+ if (!targetEmail || !appId) throw new Error('Email and App ID are required');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Access');
+ const values = sheet.getDataRange().getValues();
+ const headers = values[0];
+ const emailIdx = headers.indexOf('email');
+ const appIdIdx = headers.indexOf('appId');
+
+ let rowIndex = -1;
+ for (let i = 1; i < values.length; i++) {
+ if (normalizeEmail(values[i][emailIdx]) === targetEmail && String(values[i][appIdIdx]) === appId) {
+ rowIndex = i + 1;
+ break;
+ }
+ }
+
+ const rowValue = headers.map(h => {
+ if (h === 'email') return targetEmail;
+ if (h === 'appId') return appId;
+ if (h === 'roles') return toSafeText_(accessData.roles);
+ if (h === 'active') return normalizeBoolString(accessData.active);
+ if (h === 'notes') return toSafeText_(accessData.notes);
+ if (h === 'updatedAt') return new Date();
+ return '';
+ });
+
+ if (rowIndex > 0) sheet.getRange(rowIndex, 1, 1, rowValue.length).setValues([rowValue]);
+ else sheet.appendRow(rowValue);
+
+ clearAuthCache_(targetEmail);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function deleteAccess(email, appId) {
+ try {
+ const auth = getAuthContext(getCurrentUserEmail(), APP_ID);
+ if (!auth.roles.includes('admin')) throw new Error('Unauthorized');
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('Access');
+ const data = sheet.getDataRange().getValues();
+ const emailIdx = data[0].indexOf('email');
+ const appIdIdx = data[0].indexOf('appId');
+
+ for (let i = data.length - 1; i >= 1; i--) {
+ if (normalizeEmail(data[i][emailIdx]) === normalizeEmail(email) && String(data[i][appIdIdx]) === String(appId)) {
+ sheet.deleteRow(i + 1);
+ }
+ }
+ clearAuthCache_(email);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+// --- PUBLIC API: AUTHENTICATION & OTP ---
+
+const GO_WHATSAPP_API_KEY = 'YOUR_API_KEY';
+const GO_WHATSAPP_URL = 'https://api.gowhatsapp.com/send';
+
+function sendOTP(phone) {
+ try {
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
+ if (!users.find(u => String(u.phone).trim() === String(phone).trim())) {
+ throw new Error('Nomor WhatsApp Anda belum terdaftar.');
+ }
+
+ const code = Math.floor(100000 + Math.random() * 900000).toString();
+ const expiresAt = new Date(Date.now() + 5 * 60 * 1000);
+
+ const otpSheet = ss.getSheetByName('OTP');
+ const otpData = otpSheet.getDataRange().getValues();
+ for (let i = otpData.length - 1; i >= 1; i--) {
+ if (otpData[i][0] === phone) otpSheet.deleteRow(i + 1);
+ }
+ otpSheet.appendRow([phone, code, expiresAt]);
+
+ UrlFetchApp.fetch(GO_WHATSAPP_URL, {
+ method: 'post',
+ contentType: 'application/json',
+ payload: JSON.stringify({
+ apiKey: GO_WHATSAPP_API_KEY,
+ phone: phone,
+ message: `Kode OTP SchoolHub Anda adalah: ${code}. Berlaku selama 5 menit.`
+ }),
+ muteHttpExceptions: true
+ });
+
+ return { ok: true, message: 'OTP telah dikirim ke WhatsApp Anda.' };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function verifyOTP(phone, code) {
+ try {
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const otpData = ss.getSheetByName('OTP').getDataRange().getValues();
+
+ const validOtp = otpData.find(r => r[0] === phone && String(r[1]) === String(code) && new Date(r[2]) > new Date());
+ if (!validOtp) throw new Error('Kode OTP salah atau kadaluwarsa.');
+
+ const users = rowsToObjects_(ss.getSheetByName('Users').getDataRange().getValues());
+ const user = users.find(u => String(u.phone).trim() === String(phone).trim());
+ if (!user) throw new Error('User tidak ditemukan.');
+
+ const sessionId = Utilities.getUuid();
+ ss.getSheetByName('Sessions').appendRow([sessionId, user.email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
+
+ return { ok: true, data: { sessionId, email: user.email } };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+function loginWithGoogle() {
+ try {
+ const email = getCurrentUserEmail();
+ if (!email) return { ok: false, message: 'Email tidak terdeteksi.' };
+
+ const auth = isAllowed(email, APP_ID);
+ if (!auth.isAllowed) return { ok: false, message: auth.reason };
+
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sessionSheet = ss.getSheetByName('Sessions');
+
+ if (!sessionSheet) {
+ throw new Error('System Error: "Sessions" sheet not found. Please run setupDatabase().');
+ }
+
+ const sessionId = Utilities.getUuid();
+ sessionSheet.appendRow([sessionId, email, new Date(Date.now() + 24 * 60 * 60 * 1000)]);
+
+ return { ok: true, data: { sessionId, email } };
+ } catch (e) {
+ return { ok: false, message: e.message };
+ }
+}
+
+// --- LOGGING ---
+
+function logAccess(event) {
+ try {
+ const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
+ const sheet = ss.getSheetByName('AuditLog');
+ if (!sheet) return;
+ sheet.appendRow([
+ new Date(),
+ event.email || '',
+ event.appId || '',
+ event.action || '',
+ event.status || '',
+ event.message || '',
+ event.actor || 'System'
+ ]);
+ } catch (e) {
+ console.error('Logging failed: ' + e.message);
+ }
+}
--
2.52.0
From 1fc25ff1d87d5a9083b870eeb82735228d9b4433 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:49 +0000
Subject: [PATCH 03/13] Sync app/Index.html
---
app/Index.html | 912 +++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 912 insertions(+)
create mode 100644 app/Index.html
diff --git a/app/Index.html b/app/Index.html
new file mode 100644
index 0000000..461e6af
--- /dev/null
+++ b/app/Index.html
@@ -0,0 +1,912 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ != HtmlService.createHtmlOutputFromFile('styles').getContent(); ?>
+
+
+
+
+
+
+
+
+
+
+
Welcome Back
+
Silakan masuk untuk mengakses dashboard sekolah
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +62
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+=======
+
+
+
+
+
+
+
+ SchoolHub
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Welcome to SchoolHub
+
No users found. Register yourself as the first Administrator.
+
+
+
+
+
+
+
+
+
+
+
Identitas Tidak Terdeteksi
+
Google tidak memberikan informasi akun Anda. Pastikan Anda telah login dan memberikan izin akses aplikasi.
+
+
Tips Pengujian:
+
+ - Gunakan Incognito Window.
+ - Pastikan hanya 1 akun Google yang aktif di browser.
+ - Klik tombol di bawah untuk muat ulang.
+
+
+
+
+
+
+
+
+
+
+
Access Restricted
+
+
+
+
+
+
+
+
+
+
+
Your Applications
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No applications assigned to your account.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading community...
+
+
+
+
+
+
+
+
+
+
+
+ INACTIVE
+
+
+
+
+
+
+
+
+
+
+
Tidak ada pengguna dalam kategori ini
+
Coba pilih kategori lain atau tambahkan pengguna baru
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading system apps...
+
+
+
+
+
+
+
+
+
+
+ Belum ada aplikasi terdaftar
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+!= HtmlService.createHtmlOutputFromFile('scripts').getContent(); ?>
+
+
\ No newline at end of file
--
2.52.0
From dbb2c8891be1e4cdd2953b4a0bc6ff5f4399def8 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:51 +0000
Subject: [PATCH 04/13] Sync app/styles.html
---
app/styles.html | 222 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 222 insertions(+)
create mode 100644 app/styles.html
diff --git a/app/styles.html b/app/styles.html
new file mode 100644
index 0000000..1b776fc
--- /dev/null
+++ b/app/styles.html
@@ -0,0 +1,222 @@
+
--
2.52.0
From 8301255dd6e55bd5c7c620cd62eebd080799f3e3 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:53 +0000
Subject: [PATCH 05/13] Sync app/scripts.html
---
app/scripts.html | 507 +++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 507 insertions(+)
create mode 100644 app/scripts.html
diff --git a/app/scripts.html b/app/scripts.html
new file mode 100644
index 0000000..e60f5e0
--- /dev/null
+++ b/app/scripts.html
@@ -0,0 +1,507 @@
+
+
--
2.52.0
From 80f5e33f36dbcc0976ce6e4bf74eab320d3c9fe9 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:55 +0000
Subject: [PATCH 06/13] Sync docs/Readme.gs
---
docs/Readme.gs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 92 insertions(+)
create mode 100644 docs/Readme.gs
diff --git a/docs/Readme.gs b/docs/Readme.gs
new file mode 100644
index 0000000..432156c
--- /dev/null
+++ b/docs/Readme.gs
@@ -0,0 +1,92 @@
+/**
+ * ๐ SCHOOLHUB LAUNCHER - PROJECT DOCUMENTATION
+ * =============================================================================
+ *
+ * 1. OVERVIEW
+ * -----------
+ * SchoolHub Launcher is a centralized portal designed for school environments.
+ * It allows administrators to manage a list of internal applications and
+ * control which users have access to which apps based on their roles.
+ *
+ * CORE STACK:
+ * - Backend: Google Apps Script (GAS)
+ * - Database: Google Sheets
+ * - Frontend: Alpine.js, Tailwind CSS, Framework7 (Mobile UI)
+ * - Auth: Google Account & WhatsApp OTP (via GoWhatsApp API)
+ *
+ * 2. QUICK START SETUP
+ * --------------------
+ * To get the application running for the first time:
+ *
+ * STEP 1: Spreadsheet Connection
+ * - Ensure the SPREADSHEET_ID in Code.gs matches your target Google Sheet ID.
+ *
+ * STEP 2: Database Initialization
+ * - In the Script Editor, select the function `setupDatabase` from the dropdown.
+ * - Click "Run". This creates all necessary tabs (Users, Access, Apps, etc.)
+ * and sets up the header rows.
+ *
+ * STEP 3: Deployment
+ * - Click "Deploy" -> "New Deployment".
+ * - Select "Web App".
+ * - Execute as: "Me".
+ * - Who has access: "Anyone" (The app handles its own internal authentication).
+ *
+ * STEP 4: First Admin
+ * - Open the Web App URL.
+ * - Since the system is empty, it will prompt you to register as the First Administrator.
+ *
+ * 3. DATABASE SCHEMA (Google Sheets)
+ * ----------------------------------
+ * The system relies on these specific tabs:
+ *
+ * | Tab Name | Purpose | Key Columns |
+ * |-------------|----------------------------------------------|-----------------------------------|
+ * | Users | Registered users and global admin status | email, name, status, isAdmin |
+ * | Access | App-specific permissions (RBAC) | email, appId, roles, active |
+ * | Apps | Registry of available applications | appId, appName, appUrl, sortOrder |
+ * | AuditLog | History of access and admin actions | timestamp, email, action, status |
+ * | OTP | Temporary storage for WhatsApp verification | phone, code, expiresAt |
+ * | Sessions | Active session tokens | sessionId, email, expiresAt |
+ * | SystemDocs | Global system documentation/settings | docType, content, updatedAt |
+ *
+ * 4. CONFIGURATION
+ * -----------------
+ * To modify system behavior, look for these constants in Code.gs:
+ *
+ * - SPREADSHEET_ID: The ID of the Google Sheet used as the DB.
+ * - GO_WHATSAPP_API_KEY: Your API key for the GoWhatsApp service.
+ * - GO_WHATSAPP_URL: The endpoint for sending WhatsApp messages.
+ *
+ * 5. PERMISSION LOGIC (RBAC)
+ * --------------------------
+ * - Global Admin: If a user has `isAdmin = TRUE` in the 'Users' sheet, they
+ * can see ALL apps and access the Admin Dashboard.
+ * - App Access: For non-admins, the system checks the 'Access' sheet.
+ * A user must have an 'active' row matching the `appId` to see that app.
+ * - Roles: Roles (viewer, editor, admin) are stored in the 'Access' sheet
+ * and can be passed to the target app to handle internal permissions.
+ *
+ * 6. MAINTENANCE & TROUBLESHOOTING
+ * --------------------------------
+ * - Cache Issues: The system uses CacheService for performance. If you
+ * manually edit the Sheet and don't see changes, wait 5 minutes or
+ * restart the browser.
+ * - OTP Failures: Ensure the phone number in the 'Users' sheet matches
+ * the number used during login (including country code).
+ * - Deployment: Whenever you change Code.gs, remember to create a
+ * "New Deployment" or update the "Active" deployment to see changes.
+ *
+ * 7. CHANGELOG
+ * -------------
+ * [2023-10-27] - v1.1.0
+ * - Fitur: Tambah sistem OTP WhatsApp.
+ * - Perbaikan: Fix bug pada filter kategori user.
+ * - Perubahan: Update UI Navbar menjadi lebih minimalis.
+ *
+ * [2023-10-20] - v1.0.0
+ * - Initial Release: Dashboard, User Management, App Launcher.
+ *
+ *
+ * =============================================================================
+ */
--
2.52.0
From 5dbf81ca0fa3d3c1e92e45cb2661d43d943fb4f4 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:57 +0000
Subject: [PATCH 07/13] Sync workflow/WorkflowConfig.gs
---
workflow/WorkflowConfig.gs | 46 ++++++++++++++++++++++++++++++++++++++
1 file changed, 46 insertions(+)
create mode 100644 workflow/WorkflowConfig.gs
diff --git a/workflow/WorkflowConfig.gs b/workflow/WorkflowConfig.gs
new file mode 100644
index 0000000..ba2ac51
--- /dev/null
+++ b/workflow/WorkflowConfig.gs
@@ -0,0 +1,46 @@
+/**
+ * WorkflowConfig - Centralized configuration management
+ * Uses PropertiesService to keep secrets out of the codebase.
+ */
+
+function getWorkflowConfig() {
+ const props = PropertiesService.getScriptProperties().getProperties();
+ return props;
+}
+
+function getRequiredProperty_(key) {
+ const value = PropertiesService.getScriptProperties().getProperty(key);
+ if (!value) {
+ throw new Error(`Missing required configuration property: ${key}. Please set it in Project Settings > Script Properties.`);
+ }
+ return value;
+}
+
+function getOptionalProperty_(key, fallback) {
+ const value = PropertiesService.getScriptProperties().getProperty(key);
+ return value !== null ? value : fallback;
+}
+
+function getVikunjaConfig() {
+ return {
+ baseUrl: getRequiredProperty_('VIKUNJA_BASE_URL'),
+ token: getRequiredProperty_('VIKUNJA_TOKEN'),
+ projectId: getOptionalProperty_('VIKUNJA_DEFAULT_PROJECT_ID', null)
+ };
+}
+
+function getTestProjectConfig() {
+ return {
+ testProjectId: getOptionalProperty_('VIKUNJA_TEST_PROJECT_ID', null)
+ };
+}
+
+function getGiteaConfig() {
+ return {
+ baseUrl: getRequiredProperty_('GITEA_BASE_URL'),
+ token: getRequiredProperty_('GITEA_TOKEN'),
+ owner: getRequiredProperty_('GITEA_OWNER'),
+ repo: getRequiredProperty_('GITEA_REPO'),
+ branch: getOptionalProperty_('GITEA_DEFAULT_BRANCH', 'main')
+ };
+}
--
2.52.0
From 72a10dcb4b90a5f73f9b6c110ceddf6a375b65ad Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:44:59 +0000
Subject: [PATCH 08/13] Sync workflow/VikunjaClient.gs
---
workflow/VikunjaClient.gs | 226 ++++++++++++++++++++++++++++++++++++++
1 file changed, 226 insertions(+)
create mode 100644 workflow/VikunjaClient.gs
diff --git a/workflow/VikunjaClient.gs b/workflow/VikunjaClient.gs
new file mode 100644
index 0000000..e5b8e17
--- /dev/null
+++ b/workflow/VikunjaClient.gs
@@ -0,0 +1,226 @@
+/**
+ * VikunjaClient - Low-level API wrapper for Vikunja Task Management
+ */
+
+/**
+ * Helper to ensure the base URL always points to the Vikunja API root (/api/v1)
+ * @return {string} Normalized API base URL
+ */
+function getVikunjaApiBaseUrl_() {
+ const config = getVikunjaConfig();
+ let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
+
+ if (!baseUrl.endsWith('/api/v1')) {
+ baseUrl += '/api/v1';
+ }
+
+ return baseUrl;
+}
+
+function vikunjaRequest_(method, path, payload = null) {
+ const apiBaseUrl = getVikunjaApiBaseUrl_();
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
+ const url = `${apiBaseUrl}${normalizedPath}`;
+
+ console.log(`Vikunja Request: [${method}] ${url}`);
+
+ const config = getVikunjaConfig();
+ if (!config.token) {
+ throw new Error('Vikunja Token is missing from configuration.');
+ }
+
+ const options = {
+ method: method,
+ headers: {
+ 'Authorization': `Bearer ${config.token}`,
+ 'Content-Type': 'application/json'
+ },
+ muteHttpExceptions: true
+ };
+
+ console.log('Vikunja Auth Header Present:', !!options.headers.Authorization);
+
+ if (payload) {
+ options.payload = JSON.stringify(payload);
+ }
+
+ try {
+ const response = UrlFetchApp.fetch(url, options);
+ const code = response.getResponseCode();
+ const rawBody = response.getContentText();
+
+ let json = null;
+ if (rawBody) {
+ try {
+ json = JSON.parse(rawBody);
+ } catch (e) {
+ console.warn(`Non-JSON response received from Vikunja: ${rawBody.substring(0, 200)}...`);
+ }
+ }
+
+ if (code >= 200 && code < 300) {
+ return {
+ ok: true,
+ code: code,
+ data: json,
+ rawBody: rawBody,
+ message: 'Request successful'
+ };
+ }
+
+ return {
+ ok: false,
+ code: code,
+ data: json,
+ rawBody: rawBody,
+ message: json && json.message ? json.message : `Vikunja API Error ${code}`
+ };
+ } catch (e) {
+ return {
+ ok: false,
+ code: 500,
+ data: null,
+ rawBody: null,
+ message: `Vikunja Connection Error: ${e.message}`
+ };
+ }
+}
+
+function getVikunjaTask(taskId) {
+ return vikunjaRequest_('GET', `/tasks/${taskId}`);
+}
+
+function createVikunjaComment(taskId, commentText) {
+ const payload = { comment: commentText };
+ const path = `/tasks/${taskId}/comments`;
+
+ console.log(`Vikunja Comment Create Attempt: [PUT] ${path} | Payload: ${JSON.stringify(payload)}`);
+
+ const res = vikunjaRequest_('PUT', path, payload);
+
+ console.log(`Vikunja Comment Response: Code ${res.code} | Body: ${res.rawBody}`);
+ return res;
+}
+
+function updateVikunjaTask(taskId, data) {
+ console.log(`Vikunja Task Update Attempt: [POST] /tasks/${taskId} | Payload: ${JSON.stringify(data)}`);
+ const res = vikunjaRequest_('POST', `/tasks/${taskId}`, data);
+ console.log(`Vikunja Task Update Response: Code ${res.code} | Body: ${res.rawBody}`);
+ return res;
+}
+
+function setVikunjaTaskDone(taskId, isDone) {
+ return updateVikunjaTask(taskId, { done: isDone });
+}
+
+function createVikunjaTask(taskData) {
+ const projectId = taskData.projectId || taskData.project_id;
+ if (!projectId) {
+ return { ok: false, message: 'projectId is required to create a task in Vikunja.' };
+ }
+
+ // Construct full payload including priority and description
+ const payload = {
+ title: taskData.title,
+ description: taskData.description || '',
+ priority: typeof taskData.priority === 'number' ? taskData.priority : 0
+ };
+
+ const endpoint = `/projects/${projectId}/tasks`;
+ console.log(`Vikunja Create Attempt: [PUT] ${endpoint} | Payload: ${JSON.stringify(payload)}`);
+
+ // Using PUT as per instance-specific evidence (POST returns 405)
+ const res = vikunjaRequest_('PUT', endpoint, payload);
+
+ console.log(`Vikunja Response: Code ${res.code} | Body: ${res.rawBody}`);
+
+ if (res.ok && res.data && res.data.id) {
+ return {
+ ok: true,
+ code: res.code,
+ data: res.data,
+ rawBody: res.rawBody,
+ message: 'Task created successfully'
+ };
+ }
+
+ return {
+ ok: false,
+ code: res.code || 500,
+ data: res.data || null,
+ rawBody: res.rawBody || '',
+ message: `Task creation failed: ${res.message || 'Unknown error'}. Raw body: ${res.rawBody || 'No body'}`
+ };
+}
+
+function searchVikunjaTasks(query) {
+ // API v1 /tasks returns list of tasks.
+ // We fetch and filter client-side to avoid complex filter syntax errors.
+ const res = vikunjaRequest_('GET', '/tasks');
+ if (!res.ok) return res;
+
+ const filtered = (res.data || []).filter(t =>
+ t.title && t.title.toLowerCase().includes(query.toLowerCase())
+ );
+ return { ok: true, data: filtered };
+}
+
+function getLabelIdByName_(labelName) {
+ const res = vikunjaRequest_('GET', '/labels');
+ if (!res.ok) return null;
+
+ // Vikunja labels typically use 'title' for the display name
+ const label = (res.data || []).find(l =>
+ (l.title && l.title.toLowerCase() === labelName.toLowerCase()) ||
+ (l.name && l.name.toLowerCase() === labelName.toLowerCase())
+ );
+ return label ? label.id : null;
+}
+
+function createVikunjaLabel(labelName) {
+ // Using PUT as per instance evidence for label creation
+ const payload = { title: labelName };
+
+ console.log(`Vikunja Label Create Attempt: [PUT] /labels | Payload: ${JSON.stringify(payload)}`);
+ const res = vikunjaRequest_('PUT', '/labels', payload);
+
+ if (res.ok && res.data && res.data.id) {
+ return { ok: true, id: res.data.id, code: res.code };
+ }
+
+ console.error(`Vikunja Label Create Failed: Code ${res.code} | Body: ${res.rawBody}`);
+ return { ok: false, message: res.message || 'Failed to create label', code: res.code };
+}
+
+function addVikunjaLabel(taskId, labelName) {
+ let labelId = getLabelIdByName_(labelName);
+
+ // Auto-create label if it doesn't exist
+ if (!labelId) {
+ const createRes = createVikunjaLabel(labelName);
+ if (!createRes.ok) {
+ return {
+ ok: false,
+ message: `Label resolution failed: ${createRes.message}`,
+ code: createRes.code
+ };
+ }
+ labelId = createRes.id;
+ }
+
+ // Attach label to task using PUT as per instance evidence
+ const payload = { label_id: labelId };
+ console.log(`Vikunja Label Attach Attempt: [PUT] /tasks/${taskId}/labels | Payload: ${JSON.stringify(payload)}`);
+
+ const res = vikunjaRequest_('PUT', `/tasks/${taskId}/labels`, payload);
+
+ if (!res.ok) {
+ console.error(`Vikunja Label Attach Failed: Code ${res.code} | Body: ${res.rawBody}`);
+ }
+
+ return res;
+}
+
+function deleteVikunjaTask(taskId) {
+ return vikunjaRequest_('DELETE', `/tasks/${taskId}`);
+}
--
2.52.0
From 057d7926bea2db68dc8c0be35c016c9686c7721a Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:45:00 +0000
Subject: [PATCH 09/13] Sync workflow/GiteaClient.gs
---
workflow/GiteaClient.gs | 106 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 workflow/GiteaClient.gs
diff --git a/workflow/GiteaClient.gs b/workflow/GiteaClient.gs
new file mode 100644
index 0000000..5734361
--- /dev/null
+++ b/workflow/GiteaClient.gs
@@ -0,0 +1,106 @@
+/**
+ * GiteaClient - Low-level API wrapper for Gitea Git Hosting
+ */
+
+/**
+ * Helper to ensure the base URL always points to the Gitea API root (/api/v1)
+ * @return {string} Normalized API base URL
+ */
+function getGiteaApiBaseUrl_() {
+ const config = getGiteaConfig();
+ let baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
+
+ // Ensure the URL ends with /api/v1
+ if (!baseUrl.endsWith('/api/v1')) {
+ baseUrl += '/api/v1';
+ }
+
+ return baseUrl;
+}
+
+function giteaRequest_(method, path, payload = null) {
+ const apiBaseUrl = getGiteaApiBaseUrl_();
+ const config = getGiteaConfig();
+
+ // Ensure path starts with a single slash
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
+ const url = `${apiBaseUrl}${normalizedPath}`;
+
+ console.log(`Gitea Request: [${method}] ${url}`);
+
+ const options = {
+ method: method,
+ headers: {
+ 'Authorization': `token ${config.token}`,
+ 'Content-Type': 'application/json'
+ },
+ muteHttpExceptions: true
+ };
+
+ if (payload) {
+ options.payload = JSON.stringify(payload);
+ }
+
+ try {
+ const response = UrlFetchApp.fetch(url, options);
+ const code = response.getResponseCode();
+ const rawContent = response.getContentText();
+
+ let json;
+ try {
+ json = JSON.parse(rawContent);
+ } catch (e) {
+ json = null; // Not a JSON response
+ }
+
+ if (code >= 200 && code < 300) {
+ return { ok: true, data: json || rawContent };
+ }
+
+ // Return structured error with raw body if JSON parsing failed
+ return {
+ ok: false,
+ message: json && json.message ? json.message : `Gitea API Error ${code}`,
+ code: code,
+ rawBody: rawContent
+ };
+ } catch (e) {
+ return { ok: false, message: `Gitea Connection Error: ${e.message}` };
+ }
+}
+
+function getGiteaRepo() {
+ const config = getGiteaConfig();
+ // Path is relative to /api/v1
+ return giteaRequest_('GET', `/repos/${config.owner}/${config.repo}`);
+}
+
+function createGiteaIssue(title, body) {
+ const config = getGiteaConfig();
+ return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/issues`, {
+ title: title,
+ body: body
+ });
+}
+
+function createOrUpdateRepoFile(path, content, message, branch) {
+ const config = getGiteaConfig();
+ const targetBranch = branch || config.branch;
+
+ // Gitea requires SHA for updates. This is a simplified boilerplate.
+ return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/contents/${path}`, {
+ content: content,
+ message: message,
+ branch: targetBranch
+ });
+}
+
+function createReleaseTag(tagName, name, body, target) {
+ const config = getGiteaConfig();
+ return giteaRequest_('POST', `/repos/${config.owner}/${config.repo}/releases`, {
+ tag_name: tagName,
+ name: name,
+ body: body,
+ target_commitish: target || config.branch
+ });
+}
--
2.52.0
From 8228d17a305b257bda98633a4943522db40ef692 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:45:02 +0000
Subject: [PATCH 10/13] Sync workflow/WorkflowHooks.gs
---
workflow/WorkflowHooks.gs | 178 ++++++++++++++++++++++++++++++++++++++
1 file changed, 178 insertions(+)
create mode 100644 workflow/WorkflowHooks.gs
diff --git a/workflow/WorkflowHooks.gs b/workflow/WorkflowHooks.gs
new file mode 100644
index 0000000..19e29a1
--- /dev/null
+++ b/workflow/WorkflowHooks.gs
@@ -0,0 +1,178 @@
+/**
+ * WorkflowHooks - High-level orchestrator for business workflows.
+ * Core app files should call these functions instead of Clients directly.
+ */
+
+function workflowStartFeature(featureCode, taskId) {
+ const note = `๐ Feature [${featureCode}] has been started in SchoolHub.`;
+ const res = createVikunjaComment(taskId, note);
+
+ return res.ok ? { ok: true } : { ok: false, message: res.message };
+}
+
+function workflowMarkInternalTest(featureCode, taskId, note) {
+ const fullNote = `๐งช Internal Testing for [${featureCode}]: ${note}`;
+ const res = createVikunjaComment(taskId, fullNote);
+
+ return res.ok ? { ok: true } : { ok: false, message: res.message };
+}
+
+function workflowMarkDeploySuccess(featureCode, taskId, deployInfo) {
+ // 1. Add deployment confirmation comment
+ const comment = `โ
Deployed to Production: ${deployInfo || 'No additional info'}`;
+ createVikunjaComment(taskId, comment);
+
+ // 2. Automatically mark task as done using the workflow helper
+ // We pass a minimal meta object to resolve the task
+ markWorkflowTaskDone_({ featureCode, title: 'Deployment' }, true);
+
+ return { ok: true };
+}
+
+/**
+ * Private helper to mark a workflow task as done or reopened.
+ * Resolves the task ID via ensureWorkflowTask to ensure consistency.
+ */
+function markWorkflowTaskDone_(featureMeta, isDone) {
+ try {
+ const ensureRes = ensureWorkflowTask(featureMeta);
+ if (!ensureRes.ok || !ensureRes.taskId) {
+ console.warn(`Workflow Done Status Warning: Could not resolve task for ${featureMeta.featureCode}`);
+ return;
+ }
+
+ const taskId = ensureRes.taskId;
+ const patch = { done: isDone };
+
+ const res = updateVikunjaTask(taskId, patch);
+ if (res.ok) {
+ console.log(`Workflow Done Status: Task ${taskId} marked as ${isDone ? 'DONE' : 'OPEN'}`);
+ } else {
+ console.warn(`Workflow Done Status Failure: ${res.message}`);
+ }
+ } catch (e) {
+ console.error(`Workflow Done Status Error: ${e.message}`);
+ }
+}
+
+function workflowCreateRelease(featureCode, taskId, releaseInfo) {
+ const { tagName, name, body } = releaseInfo;
+
+ // 1. Create Release in Gitea
+ const gRes = createReleaseTag(tagName, name, body, null);
+
+ if (!gRes.ok) return { ok: false, message: `Gitea Release Failed: ${gRes.message}` };
+
+ // 2. Link Gitea release back to Vikunja task
+ const config = getGiteaConfig();
+ const releaseUrl = `${config.baseUrl}/repos/${config.owner}/${config.repo}/releases/tags/${tagName}`;
+ const vRes = createVikunjaComment(taskId, `๐ฆ Release created: ${releaseUrl}`);
+
+ return vRes.ok ? { ok: true } : { ok: false, message: vRes.message };
+}
+
+/**
+ * Ensures a Vikunja task exists for a specific feature/bug.
+ * If it exists, returns it. If not, creates it with calculated priority and labels.
+ *
+ * @param {Object} featureMeta - { featureCode, title, type, module, impactArea, severity, requestedBy, source }
+ */
+function ensureWorkflowTask(featureMeta) {
+ try {
+ const { featureCode, title } = featureMeta;
+ if (!featureCode || !title) throw new Error('featureCode and title are required for task assurance.');
+
+ // 1. Try to find existing task
+ const existingTask = findExistingWorkflowTask_(featureMeta);
+ if (existingTask) {
+ return {
+ ok: true,
+ taskId: existingTask.id,
+ state: 'EXISTING',
+ priority: existingTask.priority,
+ labels: existingTask.labels || []
+ };
+ }
+
+ // 2. Derive Priority and Labels
+ const priority = deriveWorkflowPriority_(featureMeta);
+ const labels = deriveWorkflowLabels_(featureMeta);
+
+ // 3. Create Task
+ const config = getVikunjaConfig();
+ const taskPayload = {
+ title: `[${featureCode}] ${title}`,
+ description: `Source: ${featureMeta.source || 'System'}\nRequested by: ${featureMeta.requestedBy || 'Unknown'}\nModule: ${featureMeta.module || 'General'}`,
+ projectId: config.projectId,
+ priority: priority
+ };
+
+ const createRes = createVikunjaTask(taskPayload);
+ if (!createRes.ok) throw new Error(`Task creation failed: ${createRes.message}`);
+
+ const taskId = createRes.data.id;
+
+ // 4. Attach Labels
+ attachLabelsToTask_(taskId, labels);
+
+ return {
+ ok: true,
+ taskId: taskId,
+ state: 'CREATED',
+ priority: priority,
+ labels: labels
+ };
+
+ } catch (e) {
+ console.error(`Workflow Task Assurance Error: ${e.message}`);
+ return { ok: false, message: e.message };
+ }
+}
+
+function findExistingWorkflowTask_(featureMeta) {
+ const searchRes = searchVikunjaTasks(featureMeta.featureCode);
+ if (searchRes.ok && Array.isArray(searchRes.data)) {
+ // Find exact match of [CODE] in title
+ return searchRes.data.find(t => t.title.includes(`[${featureMeta.featureCode}]`));
+ }
+ return null;
+}
+
+function deriveWorkflowPriority_(featureMeta) {
+ const { type, module, severity } = featureMeta;
+ const highPrioKeywords = ['auth', 'login', 'otp', 'admin', 'rbac', 'security', 'payment'];
+ const modValue = (module || '').toLowerCase();
+
+ // Rule 1: Critical severity OR critical modules = Priority 5 (Highest)
+ const isCriticalModule = highPrioKeywords.some(kw => modValue.includes(kw));
+ if (severity === 'critical' || isCriticalModule) return 5;
+
+ // Rule 2: Bugfixes OR High severity = Priority 4
+ if (type === 'bugfix' || severity === 'high') return 4;
+
+ // Rule 3: Features OR Improvements = Priority 3
+ if (type === 'feature' || type === 'improvement') return 3;
+
+ // Rule 4: Refactors OR Docs OR Low severity = Priority 2
+ if (type === 'refactor' || type === 'docs' || severity === 'low') return 2;
+
+ // Rule 5: Default = Priority 1 (Lowest)
+ return 1;
+}
+
+function deriveWorkflowLabels_(featureMeta) {
+ const labels = [];
+ if (featureMeta.type) labels.push(`type:${featureMeta.type}`);
+ if (featureMeta.module) labels.push(`mod:${featureMeta.module}`);
+ if (featureMeta.impactArea) labels.push(`impact:${featureMeta.impactArea}`);
+ if (featureMeta.severity) labels.push(`sev:${featureMeta.severity}`);
+
+ return labels;
+}
+
+function attachLabelsToTask_(taskId, labels) {
+ labels.forEach(label => {
+ const res = addVikunjaLabel(taskId, label);
+ if (!res.ok) console.warn(`Workflow Label Warning: ${res.message}`);
+ });
+}
--
2.52.0
From 9ef20a7667328177268e1dd3230745c1eaa2cc58 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:45:04 +0000
Subject: [PATCH 11/13] Sync workflow/WorkflowTest.gs
---
workflow/WorkflowTest.gs | 761 +++++++++++++++++++++++++++++++++++++++
1 file changed, 761 insertions(+)
create mode 100644 workflow/WorkflowTest.gs
diff --git a/workflow/WorkflowTest.gs b/workflow/WorkflowTest.gs
new file mode 100644
index 0000000..b696845
--- /dev/null
+++ b/workflow/WorkflowTest.gs
@@ -0,0 +1,761 @@
+/**
+ * ๐งช WORKFLOW INTEGRATION TESTS
+ * =============================================================================
+ * This file contains manual test helpers to verify the connection between
+ * SchoolHub and external workflow tools (Vikunja & Gitea).
+ *
+ * INSTRUCTIONS:
+ * 1. Select the desired test function from the toolbar dropdown.
+ * 2. Click "Run".
+ * 3. Check the "Execution Log" for the results.
+ * =============================================================================
+ */
+
+/**
+ * Test 1: Verify Script Properties are readable.
+ * Goal: Ensure VIKUNJA_BASE_URL, VIKUNJA_TOKEN, etc., are set.
+ */
+function testWorkflowConfig() {
+ console.log('--- Starting Test: Workflow Config ---');
+ try {
+ const vikunja = getVikunjaConfig();
+ const gitea = getGiteaConfig();
+
+ const results = {
+ vikunjaUrl: !!vikunja.baseUrl,
+ vikunjaToken: !!vikunja.token,
+ giteaUrl: !!gitea.baseUrl,
+ giteaToken: !!gitea.token,
+ giteaOwner: !!gitea.owner,
+ giteaRepo: !!gitea.repo
+ };
+
+ const allPassed = Object.values(results).every(val => val === true);
+
+ if (allPassed) {
+ console.log('โ
PASS: All required Script Properties are present.');
+ } else {
+ console.error('โ FAIL: Some properties are missing:');
+ console.log(JSON.stringify(results, null, 2));
+ }
+ return { ok: allPassed, data: results };
+ } catch (e) {
+ console.error('โ ERROR: ' + e.message);
+ return { ok: false, message: e.message };
+ }
+}
+
+/**
+ * Test 2: Verify Gitea Connectivity.
+ * Goal: Perform a read-only request to the repository root.
+ */
+function testGiteaConnection() {
+ console.log('--- Starting Test: Gitea Connection ---');
+ const res = getGiteaRepo();
+
+ if (res.ok) {
+ console.log('โ
PASS: Gitea authenticated successfully.');
+ console.log('Repo Name: ' + res.data.name);
+ return { ok: true, data: res.data };
+ } else {
+ console.error('โ FAIL: Gitea connection failed: ' + res.message);
+ return { ok: false, message: res.message };
+ }
+}
+
+/**
+ * Test 3: Verify Vikunja Connectivity using a Task ID.
+ * @param {string} taskId - Replace with a valid Task ID from your Vikunja instance.
+ */
+function testVikunjaRead() {
+ const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
+ if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
+ console.warn('โ ๏ธ SKIP: Please provide a valid taskId in the code first.');
+ return { ok: false, message: 'No Task ID provided' };
+ }
+ console.log(`--- Starting Test: Vikunja Read (Task: ${taskId}) ---`);
+ const res = getVikunjaTask(taskId);
+
+ if (res.ok) {
+ console.log('โ
PASS: Vikunja authenticated and task retrieved.');
+ console.log('Task Title: ' + (res.data.title || 'No Title'));
+ return { ok: true, data: res.data };
+ } else {
+ console.error('โ FAIL: Vikunja read failed: ' + res.message);
+ if (res.rawBody) console.log('Raw Response: ' + res.rawBody);
+ return { ok: false, message: res.message };
+ }
+}
+
+/**
+ * Test 4: Verify Vikunja Write Operation.
+ * Goal: Add a clearly marked test comment.
+ * @param {string} taskId - Replace with a valid Task ID.
+ */
+function testVikunjaComment() {
+ const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
+
+ if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
+ console.warn('โ ๏ธ SKIP: Please provide a valid taskId in the code first.');
+ return { ok: false, message: 'No Task ID provided' };
+ }
+ console.log(`--- Starting Test: Vikunja Write (Task: ${taskId}) ---`);
+ const testMessage = `[TEST] Integration check from SchoolHub at ${new Date().toISOString()}`;
+ const res = createVikunjaComment(taskId, testMessage);
+
+ if (res.ok) {
+ console.log('โ
PASS: Test comment successfully created.');
+ return { ok: true };
+ } else {
+ console.error('โ FAIL: Vikunja write failed: ' + res.message);
+ return { ok: false, message: res.message };
+ }
+}
+/*
+ Test 6: Automated Vikunja Lifecycle Test
+ Goal: Create -> Read -> Comment -> Hook -> Delete (in Test Project)
+ */
+function testVikunjaLifecycle() {
+ console.log('--- Starting Test: Vikunja Lifecycle (Auto) ---');
+
+ const config = getTestProjectConfig();
+ const testProjectId = config.testProjectId;
+
+ if (!testProjectId) {
+ console.warn('โ ๏ธ SKIP: VIKUNJA_TEST_PROJECT_ID not found in Script Properties.');
+ return { ok: false, message: 'Missing test project ID' };
+ }
+
+ let createdTaskId = null;
+ const steps = [];
+ let cleanedUp = false;
+
+ try {
+ console.log('Step 1: Creating temporary task...');
+ const createData = {
+ title: `[TEST][AUTO][SchoolHub] Lifecycle Test ${new Date().getTime()}`,
+ projectId: testProjectId
+ };
+
+ const createRes = createVikunjaTask(createData);
+ if (!createRes.ok || !createRes.data || !createRes.data.id) {
+ throw new Error(`Task Creation Failed: ${createRes.message || 'No ID returned'}. Raw: ${createRes.rawBody || 'N/A'}`);
+ }
+
+ createdTaskId = createRes.data.id;
+ steps.push('TASK_CREATED');
+ console.log(`โ
Task created with ID: ${createdTaskId}`);
+
+ console.log('Step 2: Reading task back...');
+ const readRes = getVikunjaTask(createdTaskId);
+ if (!readRes.ok) throw new Error(`Read failed: ${readRes.message}`);
+
+ steps.push('TASK_READ');
+ console.log(`โ
Task verified: ${readRes.data.title}`);
+
+ console.log('Step 3: Adding test comment...');
+ const commentRes = createVikunjaComment(createdTaskId, '[TEST] Automated lifecycle comment check.');
+ if (!commentRes.ok) {
+ throw new Error(`Comment failed (Code: ${commentRes.code}): ${commentRes.message}. Raw: ${commentRes.rawBody}`);
+ }
+
+ steps.push('COMMENT_ADDED');
+ console.log('โ
Comment added successfully');
+
+ console.log('Step 4: Running workflow hook...');
+ const hookRes = workflowStartFeature('TEST-AUTO', createdTaskId);
+ if (!hookRes.ok) throw new Error(`Hook failed: ${hookRes.message}`);
+
+ steps.push('HOOK_EXECUTED');
+ console.log('โ
Workflow hook executed successfully');
+
+ return {
+ ok: true,
+ createdTaskId,
+ cleanedUp: cleanedUp,
+ steps,
+ message: 'Full lifecycle completed successfully'
+ };
+ } catch (e) {
+ console.error(`โ Lifecycle Test Failed at step ${steps.length + 1}: ${e.message}`);
+ return {
+ ok: false,
+ createdTaskId,
+ cleanedUp: cleanedUp,
+ steps,
+ message: e.message
+ };
+ } finally {
+ if (createdTaskId) {
+ console.log('Final Step: Cleaning up temporary task...');
+ const delRes = deleteVikunjaTask(createdTaskId);
+
+ if (delRes.ok) {
+ console.log('โ
Temporary task deleted.');
+ cleanedUp = true;
+ } else {
+ console.error(`โ Cleanup failed for task ${createdTaskId}: ${delRes.message}`);
+ cleanedUp = false;
+ }
+ }
+ }
+}
+
+function testMarkWorkflowTaskDone() {
+ console.log('--- Starting Test: markWorkflowTaskDone ---');
+
+ const featureMeta = {
+ featureCode: `DONE-TEST-${Date.now()}`,
+ title: 'Automated Done Status Test',
+ type: 'feature',
+ module: 'general'
+ };
+
+ try {
+ // 1. Ensure task exists
+ const ensureRes = ensureWorkflowTask(featureMeta);
+ const taskId = ensureRes.taskId;
+ console.log(`Step 1: Task created/resolved: ${taskId}`);
+
+ // 2. Mark as done
+ markWorkflowTaskDone_(featureMeta, true);
+ console.log('Step 2: markWorkflowTaskDone_ executed');
+
+ // 3. Verify status
+ const taskRes = getVikunjaTask(taskId);
+ if (!taskRes.ok) throw new Error(`Read failed: ${taskRes.message}`);
+
+ const task = taskRes.data;
+ assertEqual_('Task is marked done', task.done, true);
+
+ // done_at is optional depending on server-side trigger/version
+ if (task.done_at) {
+ assertTrue_('Task has done_at timestamp', !!task.done_at);
+ } else {
+ console.log('โน๏ธ Note: Server did not return done_at, but task.done is true.');
+ }
+
+ console.log('--- Test Passed: markWorkflowTaskDone ---');
+
+ // Cleanup
+ deleteVikunjaTask(taskId);
+ } catch (e) {
+ console.error(`โ Test Failed: ${e.message}`);
+ throw e;
+ }
+}
+
+function testVikunjaCreateOnly() {
+ const config = getTestProjectConfig();
+ if (!config.testProjectId) return { ok: false, message: 'No test project ID' };
+
+ const res = createVikunjaTask({
+ title: `[DEBUG] Create Test ${new Date().getTime()}`,
+ projectId: config.testProjectId,
+ priority: 4
+ });
+
+ console.log(res.ok ? `โ
Created: ${res.data && res.data.id}` : `โ Failed: ${res.message}`);
+ return res;
+}
+
+/**
+ * ASSERTION HELPERS
+ */
+function assertEqual_(name, actual, expected) {
+ if (actual !== expected) {
+ throw new Error(`[ASSERT FAILED] ${name}: Expected ${expected}, but got ${actual}`);
+ }
+ console.log(`โ
${name}: ${actual} == ${expected}`);
+}
+
+function assertArrayEqual_(name, actual, expected) {
+ const a = JSON.stringify(actual);
+ const e = JSON.stringify(expected);
+ if (a !== e) {
+ throw new Error(`[ASSERT FAILED] ${name}: Expected ${e}, but got ${a}`);
+ }
+ console.log(`โ
${name}: ${a} == ${e}`);
+}
+
+function assertTrue_(name, condition) {
+ if (!condition) {
+ throw new Error(`[ASSERT FAILED] ${name}: Expected true, but got false`);
+ }
+ console.log(`โ
${name}: true`);
+}
+
+/**
+ * Test: Workflow Priority Rules
+ * Goal: Validate that the priority mapping (5..1) follows business rules.
+ */
+function testWorkflowPriorityRules() {
+ console.log('--- Starting Test: Workflow Priority Rules ---');
+
+ const testCases = [
+ {
+ name: "Critical severity overrides all",
+ input: { type: 'feature', module: 'general', severity: 'critical' },
+ expected: 5
+ },
+ {
+ name: "Auth module gets highest priority",
+ input: { type: 'feature', module: 'auth', severity: 'medium' },
+ expected: 5
+ },
+ {
+ name: "Bugfix gets priority 4",
+ input: { type: 'bugfix', module: 'student', severity: 'medium' },
+ expected: 4
+ },
+ {
+ name: "High severity gets priority 4",
+ input: { type: 'feature', module: 'student', severity: 'high' },
+ expected: 4
+ },
+ {
+ name: "Feature gets priority 3",
+ input: { type: 'feature', module: 'student', severity: 'medium' },
+ expected: 3
+ },
+ {
+ name: "Improvement gets priority 3",
+ input: { type: 'improvement', module: 'teacher', severity: 'medium' },
+ expected: 3
+ },
+ {
+ name: "Refactor gets priority 2",
+ input: { type: 'refactor', module: 'general', severity: 'medium' },
+ expected: 2
+ },
+ {
+ name: "Docs gets priority 2",
+ input: { type: 'docs', module: 'general', severity: 'medium' },
+ expected: 2
+ },
+ {
+ name: "Low severity gets priority 2",
+ input: { type: 'chore', module: 'general', severity: 'low' },
+ expected: 2
+ },
+ {
+ name: "Fallback gets priority 1",
+ input: { type: 'chore', module: 'general', severity: 'medium' },
+ expected: 1
+ },
+ ];
+
+ testCases.forEach(tc => {
+ const actual = deriveWorkflowPriority_(tc.input);
+ assertEqual_(tc.name, actual, tc.expected);
+ });
+
+ console.log('--- Test Passed: Workflow Priority Rules ---');
+}
+
+/**
+ * Test: Workflow Label Rules
+ * Goal: Validate that labels are correctly derived from metadata.
+ */
+function testWorkflowLabelRules() {
+ console.log('--- Starting Test: Workflow Label Rules ---');
+
+ const fullInput = {
+ type: 'bugfix',
+ module: 'auth',
+ impactArea: 'student',
+ severity: 'high'
+ };
+ const expectedFull = ['type:bugfix', 'mod:auth', 'impact:student', 'sev:high'];
+ assertArrayEqual_('Full metadata labels', deriveWorkflowLabels_(fullInput), expectedFull);
+
+ const minimalInput = { type: 'feature' };
+ const expectedMinimal = ['type:feature'];
+ assertArrayEqual_('Minimal metadata labels', deriveWorkflowLabels_(minimalInput), expectedMinimal);
+
+ console.log('--- Test Passed: Workflow Label Rules ---');
+}
+
+/**
+ * Test: Ensure Workflow Task Integration
+ * Goal: Verify the full cycle: ensure task -> verify in Vikunja -> delete.
+ */
+function testEnsureWorkflowTask() {
+ console.log('--- Starting Test: Ensure Workflow Task ---');
+
+ const featureMeta = {
+ featureCode: `TEST-AUTO-PRIO-${Date.now()}`,
+ title: 'Automated ensureWorkflowTask priority and label check',
+ type: 'bugfix',
+ module: 'auth',
+ impactArea: 'student',
+ severity: 'critical',
+ requestedBy: 'Automated Test',
+ source: 'WorkflowTest'
+ };
+
+ let createdTaskId = null;
+
+ try {
+ const ensureRes = ensureWorkflowTask(featureMeta);
+ console.log('Ensure Result: ' + JSON.stringify(ensureRes));
+
+ assertTrue_('Ensure task ok', ensureRes.ok);
+ assertTrue_('Task ID exists', !!ensureRes.taskId);
+ assertTrue_('Task state is valid', ['CREATED', 'EXISTING'].includes(ensureRes.state));
+
+ createdTaskId = ensureRes.taskId;
+
+ const taskRes = getVikunjaTask(createdTaskId);
+ assertTrue_('Task fetch ok', taskRes.ok);
+ assertTrue_('Task data exists', !!taskRes.data);
+
+ const task = taskRes.data;
+ assertTrue_('Title contains feature code', task.title.includes(`[${featureMeta.featureCode}]`));
+ assertEqual_('Task priority matches rule', task.priority, deriveWorkflowPriority_(featureMeta));
+
+ const expectedLabels = deriveWorkflowLabels_(featureMeta);
+ if (Array.isArray(task.labels)) {
+ const actualLabels = task.labels.map(l => l.title || l.name);
+ expectedLabels.forEach(el => {
+ assertTrue_(`Label ${el} present`, actualLabels.includes(el));
+ });
+ } else {
+ console.log('โน๏ธ Labels not returned as array; verification skipped for this instance.');
+ }
+
+ console.log('--- Test Passed: Ensure Workflow Task ---');
+ } catch (e) {
+ console.error(`โ Test Failed: ${e.message}`);
+ throw e;
+ } finally {
+ if (createdTaskId) {
+ const delRes = deleteVikunjaTask(createdTaskId);
+ if (delRes.ok) console.log('โ
Cleanup: Temporary task deleted.');
+ else console.warn('โ ๏ธ Cleanup: Failed to delete temporary task.');
+ }
+ }
+}
+
+/**
+ * Test 5: Workflow Hook Dry Run.
+ * Goal: Verify the orchestrator can call the client.
+ * @param {string} taskId - Replace with a valid Task ID.
+ */
+function testWorkflowHooksDryRun() {
+ const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
+
+ if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
+ console.warn('โ ๏ธ SKIP: Please provide a valid taskId in the code first.');
+ return { ok: false, message: 'No Task ID provided' };
+ }
+
+ console.log(`--- Starting Test: Workflow Hook Dry Run (Task: ${taskId}) ---`);
+ const res = workflowStartFeature('TEST-001', taskId);
+
+ if (res.ok) {
+ console.log('โ
PASS: workflowStartFeature executed successfully.');
+ return { ok: true };
+ } else {
+ console.error(`โ FAIL: Workflow hook failed: ${res.message}`);
+ return { ok: false, message: res.message };
+ }
+}
+
+/*
+ * ๐งช GITEA SYNC INTEGRATION TESTS
+ * =============================================================================
+ */
+
+function testGiteaPathMapping() {
+ console.log('--- Starting Test: Gitea Path Mapping ---');
+
+ const testCases = [
+ { input: { name: 'appsscript', type: 'JSON' }, expected: 'appsscript.json' },
+ { input: { name: 'Code', type: 'SERVER_JS' }, expected: 'app/Code.gs' },
+ { input: { name: 'Index', type: 'HTML' }, expected: 'app/Index.html' },
+ { input: { name: 'styles', type: 'HTML' }, expected: 'app/styles.html' },
+ { input: { name: 'Readme', type: 'SERVER_JS' }, expected: 'docs/Readme.gs' },
+ { input: { name: 'WorkflowConfig', type: 'SERVER_JS' }, expected: 'workflow/WorkflowConfig.gs' },
+ { input: { name: 'Unknown', type: 'OTHER' }, expected: null }
+ ];
+
+ testCases.forEach(function(tc) {
+ const actual = mapAppsScriptFileToRepoPath(tc.input);
+ assertEqual_(
+ `Path mapping for ${tc.input.name} (${tc.input.type})`,
+ actual,
+ tc.expected
+ );
+ });
+
+ console.log('--- Test Passed: Gitea Path Mapping ---');
+}
+
+function testGiteaBranchNaming() {
+ console.log('--- Starting Test: Gitea Branch Naming ---');
+
+ const testCases = [
+ { code: 'feat/login', expectedBase: 'feat-login' },
+ { code: 'BUG #123', expectedBase: 'bug-123' },
+ { code: '---cool---feature---', expectedBase: 'cool-feature' },
+ { code: null, expectedBase: 'manual' },
+ { code: '', expectedBase: 'manual' }
+ ];
+
+ testCases.forEach(function(tc) {
+ const branch = buildSyncBranchName(tc.code);
+
+ assertTrue_(
+ `Branch starts with sync/ for input: ${tc.code}`,
+ branch.indexOf('sync/') === 0
+ );
+
+ assertTrue_(
+ `Branch contains sanitized base "${tc.expectedBase}"`,
+ branch.indexOf(tc.expectedBase) !== -1
+ );
+
+ assertTrue_(
+ `Branch ends with timestamp pattern for input: ${tc.code}`,
+ /\d{8}-\d{4}$/.test(branch)
+ );
+
+ assertTrue_(
+ `Branch has no duplicate hyphens for input: ${tc.code}`,
+ !/--/.test(branch)
+ );
+ });
+
+ console.log('--- Test Passed: Gitea Branch Naming ---');
+}
+/**
+ * Convenience runner for the Workflow Rules Suite.
+ * Runs priority, label, and task assurance tests.
+ */
+function testWorkflowRulesSuite() {
+ console.log('=== Starting Workflow Rules Suite ===');
+
+ try {
+ testWorkflowPriorityRules();
+ testWorkflowLabelRules();
+ testEnsureWorkflowTask();
+ console.log('=== Workflow Rules Suite Passed ===');
+ } catch (e) {
+ console.error('=== Workflow Rules Suite FAILED ===');
+ console.error(e.message);
+ throw e;
+ }
+}
+
+function testPrepareGitSyncPayload() {
+ console.log('--- Starting Test: Prepare Git Sync Payload ---');
+
+ const mockFiles = [
+ { name: 'appsscript', type: 'JSON', source: '{ "timeZone": "UTC" }' },
+ { name: 'Code', type: 'SERVER_JS', source: 'function test() {}' },
+ { name: 'Index', type: 'HTML', source: '' },
+ { name: 'InvalidFile', type: 'UNKNOWN', source: 'junk' },
+ { name: 'MissingSource', type: 'SERVER_JS' },
+ null
+ ];
+
+ const payload = prepareGitSyncPayload('test-feature', mockFiles);
+
+ assertTrue_('Base branch is set', !!payload.baseBranch);
+ assertTrue_('Sync branch starts with sync/', payload.syncBranch.indexOf('sync/') === 0);
+ assertTrue_('Sync branch contains feature code', payload.syncBranch.indexOf('test-feature') !== -1);
+
+ assertEqual_('Valid files mapped correctly', payload.files.length, 4);
+ assertEqual_('JSON mapped to appsscript.json', payload.files[0].repoPath, 'appsscript.json');
+ assertEqual_('SERVER_JS mapped to app/Code.gs', payload.files[1].repoPath, 'app/Code.gs');
+ assertEqual_('HTML mapped to app/Index.html', payload.files[2].repoPath, 'app/Index.html');
+ assertEqual_('SERVER_JS missing source mapped to workflow/MissingSource.gs', payload.files[3].repoPath, 'workflow/MissingSource.gs');
+
+ console.log('--- Test Passed: Prepare Git Sync Payload ---');
+}
+
+
+function testGiteaSyncHelpers() {
+ console.log('--- Starting Test: Gitea Sync Helpers ---');
+
+ const url = getGiteaSyncApiUrl('/test/path');
+ assertTrue_('URL ends with /api/v1/test/path', url.indexOf('/api/v1/test/path') !== -1);
+
+ const opts = getGiteaSyncRequestOptions('POST', { key: 'value' });
+ assertEqual_('Method is POST', opts.method, 'POST');
+ assertTrue_('Has Authorization header', !!opts.headers['Authorization']);
+ assertEqual_('contentType is application/json', opts.contentType, 'application/json');
+ assertEqual_('Payload is stringified', opts.payload, '{"key":"value"}');
+
+ console.log('--- Test Passed: Gitea Sync Helpers ---');
+}
+
+function testGiteaBranchCreationPayload() {
+ console.log('--- Starting Test: Gitea Branch Creation Payload ---');
+
+ const baseBranch = 'main';
+ const newBranch = 'sync/feat-test';
+ const payload = {
+ new_branch_name: newBranch,
+ old_ref_name: baseBranch
+ };
+
+ assertEqual_('Payload uses old_ref_name instead of old_branch_name', payload.old_ref_name, 'main');
+ assertTrue_('old_branch_name is undefined', payload.old_branch_name === undefined);
+
+ console.log('--- Test Passed: Gitea Branch Creation Payload ---');
+}
+
+function testSyncSummaryFormat() {
+ console.log('--- Starting Test: Sync Summary Format ---');
+
+ const mockResults = {
+ created: 2,
+ updated: 1,
+ errors: 0,
+ details: [
+ { file: "app/Code.gs", ok: true, message: "" },
+ { file: "appsscript.json", ok: true, message: "" },
+ { file: "sync-manifest.json", ok: true, message: "" }
+ ]
+ };
+
+ assertTrue_('Summary has created count', mockResults.created === 2);
+ assertTrue_('Summary has updated count', mockResults.updated === 1);
+ assertTrue_('Summary has errors count', mockResults.errors === 0);
+ assertEqual_('Summary details length is correct', mockResults.details.length, 3);
+
+ const mockFinalResult = {
+ ok: true,
+ branch: 'sync/manual',
+ summary: mockResults,
+ pullRequest: { url: 'https://gitea.example.com/pr/1', existed: true }
+ };
+
+ assertTrue_('Final result has branch info', !!mockFinalResult.branch);
+ assertTrue_('Final result has pullRequest info', !!mockFinalResult.pullRequest);
+ assertTrue_('Final result pullRequest has url', !!mockFinalResult.pullRequest.url);
+ assertEqual_('Final result pullRequest existed flag is true', mockFinalResult.pullRequest.existed, true);
+
+ console.log('--- Test Passed: Sync Summary Format ---');
+}
+
+
+function testGetAppsScriptProjectContentErrorFormat() {
+ console.log('--- Starting Test: Get Apps Script Project Content Error Format ---');
+
+ const mockErrorCode = 500;
+ const result = { ok: false, message: "Failed to fetch project content. Code: " + mockErrorCode };
+
+ assertEqual_('Result ok is false', result.ok, false);
+ assertEqual_('Message formatting correct', result.message, "Failed to fetch project content. Code: 500");
+
+ const result403 = {
+ ok: false,
+ message: "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.",
+ code: 403,
+ rawBody: "Mock raw body content"
+ };
+
+ assertEqual_('403 Result ok is false', result403.ok, false);
+ assertEqual_('403 Result code is 403', result403.code, 403);
+ assertTrue_('Message contains HTTP 403 Forbidden', result403.message.indexOf("HTTP 403 Forbidden") !== -1);
+ assertEqual_('rawBody is preserved', result403.rawBody, "Mock raw body content");
+
+ console.log('--- Test Passed: Get Apps Script Project Content Error Format ---');
+}
+
+function testScriptIdFallback() {
+ console.log('--- Starting Test: Script ID Fallback ---');
+
+ const currentScriptId = ScriptApp.getScriptId();
+ const config = getSyncConfig();
+
+ assertTrue_('getSyncConfig provides a scriptId', !!config.scriptId);
+ console.log('Current ScriptApp ID: ' + currentScriptId);
+ console.log('Config Script ID: ' + config.scriptId);
+
+ if (config.scriptId === currentScriptId) {
+ console.log('โ
Fallback working or explicitly matched.');
+ } else {
+ console.log('โน๏ธ Configured scriptId is different from current ScriptApp ID.');
+ }
+
+ console.log('--- Test Passed: Script ID Fallback ---');
+}
+
+function testGiteaPullRequestPayload() {
+ console.log('--- Starting Test: Gitea Pull Request Payload ---');
+
+ const payload = {
+ title: 'Sync Feature: test-feat',
+ body: 'Auto sync',
+ base: 'main',
+ head: 'sync/test-feat'
+ };
+
+ assertEqual_('PR title is set', payload.title, 'Sync Feature: test-feat');
+ assertEqual_('PR base is main', payload.base, 'main');
+ assertEqual_('PR head is correct', payload.head, 'sync/test-feat');
+
+ console.log('--- Test Passed: Gitea Pull Request Payload ---');
+}
+
+function testCheckGiteaPullRequestExistsLogic() {
+ console.log('--- Starting Test: Check Gitea PR Exists Logic ---');
+
+ const mockPrs = [
+ { base: { ref: 'main' }, head: { ref: 'sync/feat-1' } },
+ { base: { ref: 'main' }, head: { ref: 'sync/feat-2' } }
+ ];
+
+ const baseBranch = 'main';
+ const headBranch = 'sync/feat-2';
+
+ const existing = mockPrs.find(function(pr) {
+ return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch;
+ });
+
+ assertTrue_('Finds existing PR', !!existing);
+ assertEqual_('Matches correct head', existing.head.ref, 'sync/feat-2');
+
+ const notExisting = mockPrs.find(function(pr) {
+ return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === 'sync/feat-3';
+ });
+
+ assertTrue_('Does not find missing PR', !notExisting);
+
+ console.log('--- Test Passed: Check Gitea PR Exists Logic ---');
+}
+
+function testEmptyRepoErrorDetection() {
+ console.log('--- Starting Test: Empty Repo Error Detection ---');
+
+ const mockCreateRes = {
+ ok: false,
+ message: "Branch creation failed: Git Repository is empty.",
+ code: 409
+ };
+
+ const isRepoEmptyError = !mockCreateRes.ok && mockCreateRes.message && mockCreateRes.message.toLowerCase().indexOf('empty') !== -1;
+ assertTrue_('Detects empty repository error message', isRepoEmptyError);
+
+ const mockSuccessRes = {
+ ok: true,
+ data: { name: "sync/feat-test" }
+ };
+
+ const isSuccessEmptyError = !mockSuccessRes.ok && mockSuccessRes.message && mockSuccessRes.message.toLowerCase().indexOf('empty') !== -1;
+ assertEqual_('Does not falsely detect empty repo on success', isSuccessEmptyError, false);
+
+ const mockBootstrapFail = {
+ ok: false,
+ message: 'Git Repository is empty and bootstrap failed: 404 Not Found. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).'
+ };
+ const detectsManualInit = !mockBootstrapFail.ok && mockBootstrapFail.message.toLowerCase().indexOf('manual') !== -1;
+ assertTrue_('Detects manual initialization instruction on bootstrap failure', detectsManualInit);
+
+ console.log('--- Test Passed: Empty Repo Error Detection ---');
+}
+
--
2.52.0
From 7d8606daefceafd127eb8733b36875faddbba5ad Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:45:06 +0000
Subject: [PATCH 12/13] Sync workflow/GiteaSync.gs
---
workflow/GiteaSync.gs | 402 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 402 insertions(+)
create mode 100644 workflow/GiteaSync.gs
diff --git a/workflow/GiteaSync.gs b/workflow/GiteaSync.gs
new file mode 100644
index 0000000..528dfc9
--- /dev/null
+++ b/workflow/GiteaSync.gs
@@ -0,0 +1,402 @@
+function getSyncConfig() {
+ const p = PropertiesService.getScriptProperties().getProperties();
+ return {
+ baseUrl: p.GITEA_BASE_URL || '',
+ owner: p.GITEA_OWNER || '',
+ repo: p.GITEA_REPO || '',
+ token: p.GITEA_TOKEN || '',
+ defaultBranch: p.GITEA_DEFAULT_BRANCH || 'main',
+ scriptId: p.SCRIPT_ID || ScriptApp.getScriptId()
+ };
+}
+
+function formatSyncTimestamp() {
+ return Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMdd-HHmm");
+}
+
+function encodeBase64Utf8(text) {
+ return Utilities.base64Encode(text || '', Utilities.Charset.UTF_8);
+}
+
+function buildSyncBranchName(featureCode) {
+ var safeFeature = String(featureCode || "manual")
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+
+ return "sync/" + (safeFeature || "manual") + "-" + formatSyncTimestamp();
+}
+
+function mapAppsScriptFileToRepoPath(file) {
+ if (!file || !file.name || !file.type) return null;
+
+ if (file.name === 'appsscript' && file.type === 'JSON') {
+ return 'appsscript.json';
+ }
+
+ const appFiles = ['Code', 'Index', 'styles', 'scripts'];
+ const docsFiles = ['Readme'];
+
+ let prefix = 'workflow/';
+ if (appFiles.includes(file.name)) {
+ prefix = 'app/';
+ } else if (docsFiles.includes(file.name)) {
+ prefix = 'docs/';
+ }
+
+ if (file.type === 'SERVER_JS') {
+ return prefix + file.name + '.gs';
+ }
+ if (file.type === 'HTML') {
+ return prefix + file.name + '.html';
+ }
+
+ Logger.log('Unknown file type for mapping: ' + file.name + ' (' + file.type + ')');
+ return null;
+}
+
+function prepareGitSyncPayload(featureCode, appScriptFiles) {
+ const config = getSyncConfig();
+ const syncBranch = buildSyncBranchName(featureCode);
+ const mappedFiles = [];
+
+ if (appScriptFiles && Array.isArray(appScriptFiles)) {
+ appScriptFiles.forEach(function(file) {
+ const repoPath = mapAppsScriptFileToRepoPath(file);
+ if (repoPath) {
+ mappedFiles.push({
+ repoPath: repoPath,
+ content: file.source || '',
+ originalName: file.name,
+ type: file.type
+ });
+ }
+ });
+ }
+
+ return {
+ baseBranch: config.defaultBranch,
+ syncBranch: syncBranch,
+ files: mappedFiles
+ };
+}
+
+function getGiteaSyncApiUrl(path) {
+ const config = getSyncConfig();
+ let baseUrl = config.baseUrl.replace(/\/$/, '');
+ if (!baseUrl.endsWith('/api/v1')) {
+ baseUrl += '/api/v1';
+ }
+ const normalizedPath = path.startsWith('/') ? path : '/' + path;
+ return baseUrl + normalizedPath;
+}
+
+function getGiteaSyncRequestOptions(method, payload) {
+ const config = getSyncConfig();
+ const options = {
+ method: method,
+ headers: {
+ 'Authorization': 'token ' + config.token
+ },
+ contentType: 'application/json',
+ muteHttpExceptions: true
+ };
+ if (payload) {
+ options.payload = JSON.stringify(payload);
+ }
+ return options;
+}
+
+function giteaSyncRequest(method, path, payload) {
+ const url = getGiteaSyncApiUrl(path);
+ const options = getGiteaSyncRequestOptions(method, payload);
+ try {
+ const response = UrlFetchApp.fetch(url, options);
+ const code = response.getResponseCode();
+ const rawContent = response.getContentText();
+ let json = null;
+ try {
+ json = JSON.parse(rawContent);
+ } catch (e) {}
+
+ if (code >= 200 && code < 300) {
+ return { ok: true, data: json || rawContent, code: code };
+ }
+ return {
+ ok: false,
+ message: json && json.message ? json.message : 'API Error ' + code,
+ code: code,
+ rawBody: rawContent
+ };
+ } catch (e) {
+ return { ok: false, message: 'Connection Error: ' + e.message, code: 500 };
+ }
+}
+
+function createGiteaSyncBranch(baseBranch, newBranch) {
+ const config = getSyncConfig();
+ const path = '/repos/' + config.owner + '/' + config.repo + '/branches';
+ const payload = {
+ new_branch_name: newBranch,
+ old_ref_name: baseBranch || config.defaultBranch
+ };
+ return giteaSyncRequest('POST', path, payload);
+}
+
+function getGiteaFileMetadata(repoPath, branch) {
+ const config = getSyncConfig();
+ const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath + '?ref=' + encodeURIComponent(branch);
+ return giteaSyncRequest('GET', path, null);
+}
+
+function upsertGiteaSyncFile(repoPath, content, branch, message) {
+ const config = getSyncConfig();
+ const path = '/repos/' + config.owner + '/' + config.repo + '/contents/' + repoPath;
+
+ const metaRes = getGiteaFileMetadata(repoPath, branch);
+ let sha = null;
+
+ if (metaRes.ok && metaRes.data && metaRes.data.sha) {
+ sha = metaRes.data.sha;
+ } else if (!metaRes.ok && metaRes.code !== 404) {
+ return metaRes;
+ }
+
+ const payload = {
+ branch: branch,
+ content: encodeBase64Utf8(content),
+ message: message || 'Update ' + repoPath
+ };
+
+ if (sha) {
+ payload.sha = sha;
+ }
+
+ return giteaSyncRequest(sha ? 'PUT' : 'POST', path, payload);
+}
+
+function getAppsScriptProjectContent() {
+ const config = getSyncConfig();
+ const currentScriptId = ScriptApp.getScriptId();
+ const targetScriptId = config.scriptId || currentScriptId;
+
+ console.log("--- Diagnostic: getAppsScriptProjectContent ---");
+ console.log("Configured Script ID : " + (config.scriptId || "(empty)"));
+ console.log("Current Script ID : " + currentScriptId);
+
+ if (config.scriptId && config.scriptId !== currentScriptId) {
+ console.warn("MISMATCH: Configured SCRIPT_ID is different from current ScriptApp.getScriptId(). This may cause a 403 error.");
+ } else {
+ console.log("MATCH: Script IDs match (or using default fallback).");
+ }
+
+ const url = "https://script.googleapis.com/v1/projects/" + targetScriptId + "/content";
+ const token = ScriptApp.getOAuthToken();
+ const options = {
+ method: "get",
+ headers: {
+ Authorization: "Bearer " + token
+ },
+ muteHttpExceptions: true
+ };
+
+ const response = UrlFetchApp.fetch(url, options);
+ const code = response.getResponseCode();
+ const rawContent = response.getContentText();
+
+ if (code === 200) {
+ const data = JSON.parse(rawContent);
+ const files = data.files || [];
+ console.log("Response Code : " + code);
+ console.log("Files Retrieved : " + files.length);
+ console.log("File Names : " + files.map(function(f){ return f.name; }).join(", "));
+ console.log("-----------------------------------------------");
+ return { ok: true, data: data };
+ }
+
+ console.log("Response Code : " + code);
+ console.log("Raw Response Body : " + rawContent);
+ console.log("-----------------------------------------------");
+
+ let errorMsg = "Failed to fetch project content. Code: " + code;
+ if (code === 403) {
+ errorMsg = "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.";
+ }
+
+ return { ok: false, message: errorMsg, code: code, rawBody: rawContent };
+}
+
+function normalizeAppsScriptFiles(files) {
+ return files.map(function(file) {
+ return {
+ name: file.name,
+ type: file.type,
+ source: file.source || ''
+ };
+ });
+}
+
+function exportAppsScriptProject() {
+ const res = getAppsScriptProjectContent();
+ if (!res.ok) return res;
+ return { ok: true, files: normalizeAppsScriptFiles(res.data.files || []) };
+}
+
+function buildSyncManifest(featureCode, mappedFiles) {
+ const manifest = {
+ featureCode: featureCode,
+ syncDate: new Date().toISOString(),
+ filesSynced: mappedFiles.length
+ };
+ return JSON.stringify(manifest, null, 2);
+}
+
+function checkGiteaPullRequestExists(baseBranch, headBranch) {
+ const config = getSyncConfig();
+ const path = '/repos/' + config.owner + '/' + config.repo + '/pulls?state=open';
+ const res = giteaSyncRequest('GET', path, null);
+ if (res.ok && res.data && Array.isArray(res.data)) {
+ const existing = res.data.find(function(pr) {
+ return pr.base && pr.head && pr.base.ref === baseBranch && pr.head.ref === headBranch;
+ });
+ if (existing) {
+ return { ok: true, existed: true, data: existing };
+ }
+ return { ok: true, existed: false };
+ }
+ return { ok: false, existed: false, message: res.message || 'Failed to check existing PRs' };
+}
+
+function createGiteaPullRequest(title, body, baseBranch, headBranch) {
+ const config = getSyncConfig();
+
+ const checkRes = checkGiteaPullRequestExists(baseBranch, headBranch);
+ if (checkRes.existed) {
+ return { ok: true, existed: true, data: checkRes.data, message: 'Pull request already exists' };
+ }
+ if (!checkRes.ok) {
+ return checkRes;
+ }
+
+ const path = '/repos/' + config.owner + '/' + config.repo + '/pulls';
+ const payload = {
+ title: title,
+ body: body,
+ base: baseBranch,
+ head: headBranch
+ };
+ const res = giteaSyncRequest('POST', path, payload);
+ if (res.ok) {
+ res.existed = false;
+ }
+ return res;
+}
+
+function ensureGiteaBranch(baseBranch, newBranch) {
+ const config = getSyncConfig();
+ const checkRes = giteaSyncRequest('GET', '/repos/' + config.owner + '/' + config.repo + '/branches/' + newBranch, null);
+ if (checkRes.ok) {
+ return { ok: true, existed: true };
+ }
+
+ const createRes = createGiteaSyncBranch(baseBranch, newBranch);
+
+ if (!createRes.ok && createRes.message && createRes.message.toLowerCase().indexOf('empty') !== -1) {
+ console.log('Git Repository is empty. Attempting to bootstrap with initial commit...');
+ const bootstrapRes = giteaSyncRequest('POST', '/repos/' + config.owner + '/' + config.repo + '/contents/README.md', {
+ branch: baseBranch || config.defaultBranch,
+ content: encodeBase64Utf8('# SchoolHub Sync\nRepository initialized automatically.'),
+ message: 'Initial commit'
+ });
+
+ if (bootstrapRes.ok) {
+ console.log('Bootstrap successful. Retrying branch creation...');
+ return createGiteaSyncBranch(baseBranch, newBranch);
+ }
+ return { ok: false, message: 'Git Repository is empty and bootstrap failed: ' + bootstrapRes.message + '. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).' };
+ }
+
+ return createRes;
+}
+
+
+function syncAppsScriptToGiteaBranch(featureCode) {
+ console.log("Starting Gitea sync for feature: " + featureCode);
+ const projectRes = exportAppsScriptProject();
+ if (!projectRes.ok) return projectRes;
+
+ const payload = prepareGitSyncPayload(featureCode, projectRes.files);
+ if (!payload.files || payload.files.length === 0) {
+ return { ok: false, message: 'No valid files to sync.' };
+ }
+
+ const branchRes = ensureGiteaBranch(payload.baseBranch, payload.syncBranch);
+ if (!branchRes.ok) return { ok: false, message: 'Branch creation failed: ' + branchRes.message };
+
+ console.log("Branch ready: " + payload.syncBranch + " (Existed: " + !!branchRes.existed + ")");
+
+ const results = { created: 0, updated: 0, errors: 0, details: [] };
+
+ payload.files.forEach(function(file) {
+ const res = upsertGiteaSyncFile(file.repoPath, file.content, payload.syncBranch, 'Sync ' + file.repoPath);
+ results.details.push({ file: file.repoPath, ok: res.ok, message: res.message });
+ if (res.ok) {
+ if (res.code === 201) results.created++;
+ else results.updated++;
+ } else {
+ results.errors++;
+ }
+ });
+
+ const manifestContent = buildSyncManifest(featureCode, payload.files);
+ const manifestRes = upsertGiteaSyncFile('sync-manifest.json', manifestContent, payload.syncBranch, 'Update sync manifest');
+ if (manifestRes.ok) {
+ if (manifestRes.code === 201) results.created++;
+ else results.updated++;
+ } else {
+ results.errors++;
+ }
+
+ const prTitle = 'Sync Feature: ' + featureCode;
+ const prBody = 'Automated sync from Google Apps Script for feature: ' + featureCode + '.\n\nFiles synced:\n' + payload.files.map(function(f) { return '- ' + f.repoPath; }).join('\n');
+ const prRes = createGiteaPullRequest(prTitle, prBody, payload.baseBranch, payload.syncBranch);
+
+ let prInfo = null;
+ if (prRes.ok) {
+ prInfo = {
+ url: prRes.data.html_url || '',
+ existed: prRes.existed,
+ number: prRes.data.number
+ };
+ }
+
+ console.log("Sync Summary: " + results.created + " created, " + results.updated + " updated, " + results.errors + " errors.");
+ if (prInfo) {
+ console.log("Pull Request: " + prInfo.url + (prInfo.existed ? " (Existed)" : " (Created)"));
+ } else {
+ console.log("Pull Request Failed: " + prRes.message);
+ }
+
+ return { ok: true, branch: payload.syncBranch, summary: results, pullRequest: prInfo };
+}
+
+function runManualSyncToGitea() {
+ const res = syncAppsScriptToGiteaBranch('manual');
+ if (res.ok) {
+ console.log('=== Sync Summary ===');
+ console.log('Branch Sync: ' + res.branch);
+ console.log('Files: ' + res.summary.created + ' created, ' + res.summary.updated + ' updated, ' + res.summary.errors + ' errors.');
+ console.log('Catatan: File baru akan menggunakan path app/, docs/, dan workflow/. File lama dengan folder src/ mungkin masih tertinggal di repositori (Gitea) dan harus dihapus secara manual.');
+ if (res.pullRequest) {
+ console.log('Pull Request Status: ' + (res.pullRequest.existed ? 'Existing' : 'Created'));
+ console.log('Pull Request URL: ' + res.pullRequest.url);
+ } else {
+ console.log('Pull Request Status: Not available');
+ }
+ } else {
+ console.error('Sync failed: ' + res.message);
+ }
+ return res;
+}
+
--
2.52.0
From 9841953e8f5fb5f5606dd51080276a981b117b28 Mon Sep 17 00:00:00 2001
From: someone <2+someone@noreply.localhost>
Date: Fri, 24 Apr 2026 23:45:08 +0000
Subject: [PATCH 13/13] Update sync manifest
---
sync-manifest.json | 5 +++++
1 file changed, 5 insertions(+)
create mode 100644 sync-manifest.json
diff --git a/sync-manifest.json b/sync-manifest.json
new file mode 100644
index 0000000..830a1f6
--- /dev/null
+++ b/sync-manifest.json
@@ -0,0 +1,5 @@
+{
+ "featureCode": "manual",
+ "syncDate": "2026-04-24T23:45:06.978Z",
+ "filesSynced": 12
+}
\ No newline at end of file
--
2.52.0