/** * @OnlyCurrentDoc */ /** * FormulaLock for Google Sheets · v1.0.0 · https://formulalock.com * * Locks the cells you choose, marks them with a locked colour, adds Google's own * "warning" protection, and puts the original formulas/values back when a * collaborator overwrites them. * * Honest limits: this is not encryption and not a permissions system. Anyone with * edit access can still open Extensions > Apps Script and delete this code, and * edits made by other scripts or the API are not caught. Full list: * https://formulalock.com/limits.html * * Free: locks on one tab. Pro ($19 once): every tab, every spreadsheet you own. */ var FL_VERSION = '1.0.0'; var FL_API = 'https://formulalock.com/api/verify'; var FL_BUY = 'https://formulalock.com/#pricing'; var FL_BG = '#fff2cc'; var FL_TAG = 'FormulaLock:'; var FL_CHUNK = 2800; // chars per property value (9 KB limit, UTF-8 safe) var FL_MAX_CELLS = 5000; // per lock // ---------------------------------------------------------------- menu function onOpen() { SpreadsheetApp.getUi().createMenu('FormulaLock') .addItem('Lock selected cells', 'flLockSelection') .addItem('Lock every formula on this tab', 'flLockTabFormulas') .addItem('Unlock selected cells', 'flUnlockSelection') .addItem('Make selected lock strict (only me can edit)', 'flStrictSelection') .addSeparator() .addItem('Show locks', 'flShowLocks') .addItem('Show restore log', 'flShowLog') .addSeparator() .addItem('Enter Pro licence key', 'flEnterKey') .addItem('About and limits', 'flAbout') .addToUi(); } function onInstall(e) { onOpen(e); } // ---------------------------------------------------------------- storage function flProps_() { return PropertiesService.getDocumentProperties(); } function flIndex_() { var s = flProps_().getProperty('fl_index'); return s ? JSON.parse(s) : []; } function flSetIndex_(idx) { flProps_().setProperty('fl_index', JSON.stringify(idx)); } function flSave_(rec) { var p = flProps_(), s = JSON.stringify(rec); var n = Math.max(1, Math.ceil(s.length / FL_CHUNK)), out = {}; for (var i = 0; i < n; i++) out['fl_rec_' + rec.id + '_' + i] = s.substr(i * FL_CHUNK, FL_CHUNK); var old = Number(p.getProperty('fl_rec_' + rec.id + '_n') || 0); out['fl_rec_' + rec.id + '_n'] = String(n); p.setProperties(out); for (var j = n; j < old; j++) p.deleteProperty('fl_rec_' + rec.id + '_' + j); } function flLoad_(id) { var p = flProps_(), n = Number(p.getProperty('fl_rec_' + id + '_n') || 0), s = ''; if (!n) return null; for (var i = 0; i < n; i++) s += p.getProperty('fl_rec_' + id + '_' + i) || ''; try { return JSON.parse(s); } catch (x) { return null; } } function flDeleteRec_(id) { var p = flProps_(), n = Number(p.getProperty('fl_rec_' + id + '_n') || 0); for (var i = 0; i < n; i++) p.deleteProperty('fl_rec_' + id + '_' + i); p.deleteProperty('fl_rec_' + id + '_n'); } // ---------------------------------------------------------------- helpers function flMe_() { try { return Session.getActiveUser().getEmail() || ''; } catch (x) { return ''; } } function flEnc_(v) { return (v instanceof Date) ? { d: v.getTime() } : v; } function flDec_(v) { return (v && typeof v === 'object' && v.hasOwnProperty('d')) ? new Date(v.d) : v; } function flLicensed_() { return flProps_().getProperty('fl_pro') === '1'; } function flIntersect_(a, b) { var r1 = Math.max(a.getRow(), b.getRow()), c1 = Math.max(a.getColumn(), b.getColumn()); var r2 = Math.min(a.getLastRow(), b.getLastRow()), c2 = Math.min(a.getLastColumn(), b.getLastColumn()); return (r1 <= r2 && c1 <= c2) ? { r1: r1, c1: c1, r2: r2, c2: c2 } : null; } function flSnapshot_(range, formulasOnly) { var f = range.getFormulas(); var v = formulasOnly ? null : range.getValues().map(function (row) { return row.map(flEnc_); }); return { f: f, v: v }; } function flLog_(sheetName, a1, who, what) { var p = flProps_(), log = []; try { log = JSON.parse(p.getProperty('fl_log') || '[]'); } catch (x) {} log.unshift({ t: new Date().toISOString(), s: sheetName, a: a1, u: who || 'a collaborator', m: what }); p.setProperty('fl_log', JSON.stringify(log.slice(0, 30))); } function flLocksOn_(sheet) { var ss = sheet.getParent(), sid = sheet.getSheetId(); return flIndex_().map(function (it) { var r = ss.getRangeByName(it.name); return (r && r.getSheet().getSheetId() === sid) ? { it: it, range: r } : null; }).filter(function (x) { return x; }); } function flCheckTabQuota_(sheet) { if (flLicensed_()) return; var sid = sheet.getSheetId(); var tabs = {}; flIndex_().forEach(function (it) { tabs[it.sheetId] = 1; }); tabs[sid] = 1; if (Object.keys(tabs).length > 1) { throw new Error('The free version locks cells on one tab. FormulaLock Pro ($19 once) unlocks every tab ' + 'and every spreadsheet you own: ' + FL_BUY + ' Already bought? FormulaLock > Enter Pro licence key.'); } } // ---------------------------------------------------------------- lock / unlock function flLockRange_(range, formulasOnly) { var ss = range.getSheet().getParent(), sh = range.getSheet(); var cells = range.getNumRows() * range.getNumColumns(); if (cells > FL_MAX_CELLS) throw new Error('That range has ' + cells + ' cells. One lock can hold up to ' + FL_MAX_CELLS + '. Lock it in smaller blocks.'); flCheckTabQuota_(sh); var id = Utilities.getUuid().replace(/-/g, '').slice(0, 12); var name = 'FormulaLock_' + id; var snap = flSnapshot_(range, formulasOnly); var bg = range.getBackgrounds(); var rec = { id: id, sheetId: sh.getSheetId(), name: name, rows: range.getNumRows(), cols: range.getNumColumns(), fo: !!formulasOnly, f: snap.f, v: snap.v, bg: bg, owner: flMe_(), t: Date.now() }; try { flSave_(rec); } catch (x) { flDeleteRec_(id); throw new Error('This sheet has no room left to store another lock (Google limits script storage). Unlock an old lock or lock a smaller block.'); } ss.setNamedRange(name, range); if (formulasOnly) { range.setBackgrounds(bg.map(function (row, r) { return row.map(function (b, c) { return snap.f[r][c] ? FL_BG : b; }); })); } else { range.setBackground(FL_BG); range.protect().setDescription(FL_TAG + id).setWarningOnly(true); } var idx = flIndex_(); idx.push({ id: id, sheetId: rec.sheetId, name: name, fo: rec.fo }); flSetIndex_(idx); return rec; } function flRemoveLock_(ss, it) { var r = ss.getRangeByName(it.name); var rec = flLoad_(it.id); if (r) { var sh = r.getSheet(); sh.getProtections(SpreadsheetApp.ProtectionType.RANGE).forEach(function (p) { if (p.getDescription() === FL_TAG + it.id) p.remove(); }); if (rec && rec.bg && r.getNumRows() === rec.rows && r.getNumColumns() === rec.cols) r.setBackgrounds(rec.bg); ss.removeNamedRange(it.name); } flDeleteRec_(it.id); flSetIndex_(flIndex_().filter(function (x) { return x.id !== it.id; })); } // Unlock/strict only by whoever locked (blank owner = legacy lock, allowed). function flOwnsAll_(hits) { var me = flMe_().toLowerCase(); return hits.every(function (x) { var rec = flLoad_(x.it.id); var owner = rec && rec.owner ? rec.owner.toLowerCase() : ''; return !owner || owner === me; }); } function flLockSelection() { var ss = SpreadsheetApp.getActive(), ui = SpreadsheetApp.getUi(); var list = ss.getActiveRangeList(); if (!list) { ui.alert('Select the cells you want to lock first.'); return; } try { var n = 0; list.getRanges().forEach(function (r) { flLockRange_(r, false); n++; }); ss.toast('Locked ' + n + ' range(s). Collaborators see a warning, and FormulaLock puts the cells back if they change them anyway.', 'FormulaLock', 6); } catch (e) { ui.alert(e.message); } } function flLockTabFormulas() { var ss = SpreadsheetApp.getActive(), ui = SpreadsheetApp.getUi(), sh = ss.getActiveSheet(); var range = sh.getDataRange(); var f = range.getFormulas(), count = 0; f.forEach(function (row) { row.forEach(function (x) { if (x) count++; }); }); if (!count) { ui.alert('No formulas found on this tab.'); return; } try { flLockRange_(range, true); ss.toast('Locked ' + count + ' formula cell(s) on "' + sh.getName() + '". Typing in other cells still works.', 'FormulaLock', 6); } catch (e) { ui.alert(e.message); } } function flUnlockSelection() { var ss = SpreadsheetApp.getActive(), ui = SpreadsheetApp.getUi(); var sel = ss.getActiveRange(); if (!sel) { ui.alert('Select a locked cell first.'); return; } var hits = flLocksOn_(sel.getSheet()).filter(function (x) { return flIntersect_(sel, x.range); }); if (!hits.length) { ui.alert('No FormulaLock lock touches the selected cells.'); return; } if (!flOwnsAll_(hits)) { ui.alert('Only the person who locked these cells can unlock them.'); return; } hits.reverse().forEach(function (x) { flRemoveLock_(ss, x.it); }); // newest first so overlapping colours unwind ss.toast('Unlocked ' + hits.length + ' lock(s). Edit freely, then lock again.', 'FormulaLock', 5); } function flStrictSelection() { var ss = SpreadsheetApp.getActive(), ui = SpreadsheetApp.getUi(); var sel = ss.getActiveRange(); if (!sel) { ui.alert('Select a locked cell first.'); return; } var me = Session.getEffectiveUser(); var hits = flLocksOn_(sel.getSheet()).filter(function (x) { return !x.it.fo && flIntersect_(sel, x.range); }); if (!hits.length) { ui.alert('Select cells locked with "Lock selected cells" first. Formula-only tab locks cannot be strict.'); return; } if (!flOwnsAll_(hits)) { ui.alert('Only the person who locked these cells can change their lock.'); return; } var done = 0; hits.forEach(function (x) { x.range.getSheet().getProtections(SpreadsheetApp.ProtectionType.RANGE).forEach(function (p) { if (p.getDescription() !== FL_TAG + x.it.id) return; p.setWarningOnly(false); p.addEditor(me); p.removeEditors(p.getEditors()); if (p.canDomainEdit()) p.setDomainEdit(false); done++; }); }); ss.toast(done + ' lock(s) are now strict: only you (and the file owner) can edit them.', 'FormulaLock', 6); } // ---------------------------------------------------------------- restore on edit function onEdit(e) { if (!e || !e.range) return; var idx; try { idx = flIndex_(); } catch (x) { return; } if (!idx.length) return; var range = e.range, sh = range.getSheet(), sid = sh.getSheetId(); var ss = e.source || SpreadsheetApp.getActive(); var me = flMe_(), restored = 0; idx.forEach(function (it) { if (it.sheetId !== sid) return; var lr = ss.getRangeByName(it.name); if (!lr || lr.getSheet().getSheetId() !== sid) return; var box = flIntersect_(range, lr); if (!box) return; var rec = flLoad_(it.id); if (!rec) return; if (lr.getNumRows() !== rec.rows || lr.getNumColumns() !== rec.cols) { flLog_(sh.getName(), lr.getA1Notation(), me, 'not restored: rows/columns were added inside this lock, unlock and lock it again'); return; } if (me && rec.owner && me.toLowerCase() === rec.owner.toLowerCase()) { var snap = flSnapshot_(lr, rec.fo); // the owner's edit becomes the new locked version rec.f = snap.f; rec.v = snap.v; flSave_(rec); return; } restored += flRestore_(sh, lr, rec, box); flLog_(sh.getName(), sh.getRange(box.r1, box.c1, box.r2 - box.r1 + 1, box.c2 - box.c1 + 1).getA1Notation(), me, 'put back'); }); if (restored) ss.toast('Those cells are locked, so FormulaLock put them back. Ask the sheet owner if they need changing.', 'FormulaLock', 6); } function flRestore_(sh, lr, rec, box) { var r0 = lr.getRow(), c0 = lr.getColumn(), n = 0, r, c; if (!rec.fo) { var out = []; for (r = box.r1; r <= box.r2; r++) { var row = []; for (c = box.c1; c <= box.c2; c++) { var f = rec.f[r - r0][c - c0]; row.push(f ? f : flDec_(rec.v[r - r0][c - c0])); n++; } out.push(row); } var target = sh.getRange(box.r1, box.c1, box.r2 - box.r1 + 1, box.c2 - box.c1 + 1); target.setValues(out); target.setBackground(FL_BG); } else { for (r = box.r1; r <= box.r2; r++) { for (c = box.c1; c <= box.c2; c++) { var g = rec.f[r - r0][c - c0]; if (!g) continue; var cell = sh.getRange(r, c); if (cell.getFormula() !== g) { cell.setFormula(g); n++; } cell.setBackground(FL_BG); } } } return n; } // ---------------------------------------------------------------- info dialogs function flShowLocks() { var ss = SpreadsheetApp.getActive(), lines = []; flIndex_().forEach(function (it) { var r = ss.getRangeByName(it.name), rec = flLoad_(it.id); if (!r) { lines.push('(missing range, unlock to clean up) ' + it.name); return; } var shape = rec && (r.getNumRows() !== rec.rows || r.getNumColumns() !== rec.cols) ? ' [changed shape: unlock + lock again]' : ''; lines.push(r.getSheet().getName() + '!' + r.getA1Notation() + ' · ' + (it.fo ? 'formulas only' : 'all cells') + shape); }); SpreadsheetApp.getUi().alert('FormulaLock locks', lines.length ? lines.join('\n') : 'No locks yet. Select cells, then FormulaLock > Lock selected cells.', SpreadsheetApp.getUi().ButtonSet.OK); } function flShowLog() { var log = []; try { log = JSON.parse(flProps_().getProperty('fl_log') || '[]'); } catch (x) {} var lines = log.map(function (l) { return l.t.replace('T', ' ').slice(0, 16) + ' UTC ' + l.s + '!' + l.a + ' ' + l.m + ' (' + l.u + ')'; }); SpreadsheetApp.getUi().alert('FormulaLock restore log (last 30)', lines.length ? lines.join('\n') : 'Nothing restored yet.', SpreadsheetApp.getUi().ButtonSet.OK); } function flEnterKey() { var ui = SpreadsheetApp.getUi(); var r = ui.prompt('FormulaLock Pro', 'Paste your licence key (FL-XXXX-XXXX-XXXX-XXXX):', ui.ButtonSet.OK_CANCEL); if (r.getSelectedButton() !== ui.Button.OK) return; var key = r.getResponseText().trim().toUpperCase(); var ok = false; try { var res = UrlFetchApp.fetch(FL_API + '?k=' + encodeURIComponent(key), { muteHttpExceptions: true }); ok = JSON.parse(res.getContentText()).valid === true; } catch (x) {} if (ok) { flProps_().setProperties({ fl_pro: '1', fl_key: key }); ui.alert('Pro is on for this spreadsheet: lock as many tabs as you like. Thanks for buying FormulaLock.'); } else { ui.alert('That key did not verify. Check it for typos, or email hello@formulalock.com with your receipt.'); } } function flAbout() { SpreadsheetApp.getUi().alert('FormulaLock ' + FL_VERSION, 'What it does: marks locked cells, shows Google\'s edit warning, and puts locked cells back when a collaborator changes them. ' + 'Your own edits (as the person who locked them) become the new locked version.\n\n' + 'What it cannot do: it is not encryption and not a permissions system. Anyone with edit access can delete this script ' + '(Extensions > Apps Script). Edits from other scripts, the Sheets API, imports or sorting are not caught. ' + 'If someone adds rows or columns inside a lock, that lock pauses until you unlock and lock it again. ' + 'For a hard block, use "Make selected lock strict".\n\n' + (flLicensed_() ? 'Plan: Pro.' : 'Plan: Free (one tab). Pro $19 once: ' + FL_BUY) + '\nHelp: hello@formulalock.com', SpreadsheetApp.getUi().ButtonSet.OK); }