/** OmaLista: future weeks. Bound to the customer's Google spreadsheet. */ const OL = Object.freeze({ zone: 'Europe/Helsinki', futureWeeks: 6, maxRows: 5000, venues: [ {name: 'Tuorlan Majatalo', tab: 'Majatalo'}, {name: 'Lilla Majatalo', tab: 'Lilla'}, {name: 'Helmikahvila', tab: 'Helmikahvila'}, {name: 'Kahvila Libris', tab: 'Libris'} ], menuHeaders: ['Päivä','Näytä sivulla','Ruoka','Ruokavaliot','Otsikko','Hinta','Lounasaika','Lisätieto','Ei lounasta'], weekHeaders: ['Toimipiste','Viikon maanantai','Julkaisutila','Täytettyjä ruokia'] }); function onOpen() { SpreadsheetApp.getUi().createMenu('OmaLista') .addItem('Ota ruokapankki ja viikkokopiointi käyttöön', 'otaKeittiotyokalutKayttoon') .addItem('Päivitä ruokapankki', 'paivitaRuokapankki') .addSeparator() .addItem('Ota automaattiset viikot käyttöön', 'otaAutomaatioKayttoon') .addItem('Tarkista tulevien viikkojen pohjat', 'tarkistaViikotNyt') .addSeparator().addItem('Pysäytä automaatio', 'pysaytaAutomaatio').addToUi(); } function otaAutomaatioKayttoon() { const ss = SpreadsheetApp.getActiveSpreadsheet(); if (!ss) throw new Error('Avaa tämä ohjelma lounastaulukon Laajennukset → Apps Script -valikosta.'); const props = PropertiesService.getScriptProperties(); const prior = props.getProperty('SPREADSHEET_ID'); if (prior && prior !== ss.getId()) throw new Error('Automaatio on jo kytketty eri taulukkoon.'); props.setProperty('SPREADSHEET_ID', ss.getId()); // Validate the actual workbook before installing a trigger. No trigger is needed for this run. paivitaViikkopohjat_(); let triggers = ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === 'paivitaViikkopohjat'); if (!triggers.length) triggers = [ScriptApp.newTrigger('paivitaViikkopohjat').timeBased() .everyDays(1).atHour(4).inTimezone(OL.zone).create()]; triggers.slice(1).forEach(t => ScriptApp.deleteTrigger(t)); props.setProperty('ENABLED', 'true'); kirjoitaTila_('Käytössä', 'Google tarkistaa joka aamuyö, että kuusi tulevaa viikkoa on valmiina.'); ss.toast('Automaattiset viikot ovat käytössä. Täytä ruoat ja julkaise valmis viikko.', 'OmaLista', 8); } function pysaytaAutomaatio() { PropertiesService.getScriptProperties().setProperty('ENABLED', 'false'); ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === 'paivitaViikkopohjat') .forEach(t => ScriptApp.deleteTrigger(t)); kirjoitaTila_('Pysäytetty', 'Valmiit pohjat ja ruoat säilyvät. Uusia viikkoja ei lisätä.'); } function tarkistaViikotNyt() { const summary = paivitaViikkopohjat_(); SpreadsheetApp.getActiveSpreadsheet().toast(summary, 'OmaLista', 8); } function paivitaViikkopohjat() { if (PropertiesService.getScriptProperties().getProperty('ENABLED') !== 'true') return; paivitaViikkopohjat_(); } function paivitaViikkopohjat_() { const lock = LockService.getScriptLock(); if (!lock.tryLock(30000)) throw new Error('Toinen OmaLista-päivitys on käynnissä. Yritä uudelleen.'); try { const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'); if (!id) throw new Error('Valitse ensin OmaLista → Ota automaattiset viikot käyttöön.'); if (typeof Sheets === 'undefined') throw new Error('Lisää Apps Scriptin Palvelut-kohtaan Google Sheets API.'); // Read typed values, formulas and native tables together; no literal copies of user data. const meta = Sheets.Spreadsheets.get(id, {fields:'sheets(properties(title,gridProperties(rowCount)))'}); const names = ['Asetukset','Viikot'].concat(OL.venues.map(v => v.tab)); const ranges = names.map(name => { const sheet = meta.sheets.find(s => s.properties.title === name); if (!sheet) throw new Error('Puuttuva välilehti: ' + name); const width = name === 'Asetukset' ? 'K' : name === 'Viikot' ? 'D' : 'I'; return "'" + name + "'!A1:" + width + Math.min(sheet.properties.gridProperties.rowCount, OL.maxRows + 1); }); // Advanced-service responses otherwise include every cell's formatting and can exceed Google's response limit. const snapshot = Sheets.Spreadsheets.get(id, {ranges: ranges, fields:'properties(locale,timeZone),sheets(properties(sheetId,title,gridProperties),tables(tableId,name,range,columnProperties),basicFilter,data(startRow,startColumn,rowData(values(userEnteredValue,effectiveValue))))'}); const date = Utilities.formatDate(new Date(), OL.zone, 'yyyy-MM-dd'); const plan = suunnitteleViikot(snapshot, date); if (plan.requests.length) Sheets.Spreadsheets.batchUpdate({requests: plan.requests}, id); if (propsForKitchen_()) olKitchenRefreshLibrary_(SpreadsheetApp.openById(id), snapshot); PropertiesService.getScriptProperties().setProperty('LAST_OK', new Date().toISOString()); kirjoitaTila_(PropertiesService.getScriptProperties().getProperty('ENABLED') === 'true' ? 'Käytössä' : 'Valmis aktivointiin', plan.summary); return plan.summary; } catch (error) { try { kirjoitaTila_('Tarkistus epäonnistui', String(error.message)); } catch (_) { /* Preserve original failure. */ } throw error; // Google sends the trigger owner a failure notification; no Gmail permission. } finally { lock.releaseLock(); } } function propsForKitchen_() { return typeof olKitchenRefreshLibrary_ === 'function' && PropertiesService.getScriptProperties().getProperty('KITCHEN_TOOLS') === 'true'; } function kirjoitaTila_(state, detail) { const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'); if (!id) return; const ss = SpreadsheetApp.openById(id); let sheet = ss.getSheetByName('Automaatio'); if (!sheet) sheet = ss.insertSheet('Automaatio'); sheet.getRange('A1:B6').setValues([ ['Automaattiset viikkopohjat', state], ['Toiminta', 'Kuusi tulevaa viikkoa. Uudet pohjat ovat luonnoksia.'], ['Viimeisin tarkistus', Utilities.formatDate(new Date(), OL.zone, 'dd.MM.yyyy HH:mm')], ['Tulos', detail], ['Keittiön tehtävä', 'Kirjoita ruoat valmiille riveille ja julkaise valmis viikko Viikot-välilehdellä.'], ['Hallinta', 'OmaLista-valikossa voit tarkistaa pohjat heti tai pysäyttää automaation.'] ]); sheet.setColumnWidth(1, 220).setColumnWidth(2, 580).setFrozenRows(1); sheet.getRange('A1:B6').setWrap(true).setVerticalAlignment('top'); sheet.getRange('A1:B1').setFontWeight('bold').setBackground('#eeeeee').setFontColor('#000000'); } // Pure planning: used by both Apps Script and deterministic/native API verification. function suunnitteleViikot(snapshot, isoDate) { if (snapshot.properties.timeZone !== OL.zone || snapshot.properties.locale !== 'fi_FI') throw new Error('Taulukon aikavyöhykkeen tulee olla Europe/Helsinki ja alueen Suomi.'); const sheets = new Map(snapshot.sheets.map(s => [s.properties.title, s])); const settings = olRows_(olSheet_(sheets, 'Asetukset')); const weeksSheet = olSheet_(sheets, 'Viikot'); const weekRows = olRows_(weeksSheet); olHeaders_(weekRows[0], OL.weekHeaders, 'Viikot'); olHeaders_(settings[0].slice(0,4), ['Toimipiste','Lounaspäivät','Näytä viikkoja','Vakio-otsikot'], 'Asetukset'); const monday = olMonday_(olSerial_(isoDate)); const controls = new Map(); const allowed = new Set(OL.venues.map(v => v.name)); weekRows.slice(1).forEach(r => { if (r.every(v => v === '')) return; if (!allowed.has(r[0]) || !Number.isInteger(r[1]) || olMonday_(r[1]) !== r[1] || !['Rivikohtainen','Luonnos','Julkaistu'].includes(r[2])) throw new Error('Viikot-välilehdellä on virheellinen paikka, maanantai tai tila.'); const key = r[0] + '/' + r[1]; if (controls.has(key)) throw new Error('Viikot-välilehdellä sama paikka ja viikko on kahdesti.'); controls.set(key, r[2]); }); const requests = [], report = [], newControls = []; for (const venue of OL.venues) { const configs = settings.slice(1).filter(r => r[0] === venue.name); if (configs.length !== 1) throw new Error(venue.name + ': Asetukset-rivin pitää olla yksilöllinen.'); const conf = configs[0], days = olDays_(conf[1]), slots = olSlots_(conf[3]); const sheet = olSheet_(sheets, venue.tab), rows = olRows_(sheet); olHeaders_(rows[0], OL.menuHeaders, venue.tab); const table = olTable_(sheet, 9); olInsideTable_(sheet, rows, table); const existingWeeks = new Set(); rows.slice(1).forEach(r => { if (r.every(v => v === '')) return; if (!Number.isInteger(r[0]) || r[0] < 36526 || r[0] > 73050) throw new Error(venue.tab + ': tarkista päivän oikea päivämäärä.'); existingWeeks.add(olMonday_(r[0])); }); const added = [], starts = []; for (let offset = 1; offset <= OL.futureWeeks; offset++) { const week = monday + offset * 7, key = venue.name + '/' + week; if (existingWeeks.has(week)) { // A manually created week may outlive the initially prepared Viikot controls. // Rivikohtainen preserves precisely the existing missing-control fallback. if (!controls.has(key)) { newControls.push([venue.name, week, 'Rivikohtainen', olCountFormula_(venue.tab, week)]); controls.set(key, 'Rivikohtainen'); } continue; // Never repair/delete/recreate any existing food week. } if (controls.has(key) && controls.get(key) === 'Julkaistu') throw new Error(venue.name + ': puuttuvan viikon julkaisutila on Julkaistu. Vaihda se ensin Luonnokseksi.'); for (const day of days) { starts.push(added.length); for (const group of slots) added.push([week + day - 1, false, '', '', group, '', '', '', false]); } if (!controls.has(key)) { newControls.push([venue.name, week, 'Luonnos', olCountFormula_(venue.tab, week)]); controls.set(key, 'Luonnos'); } else if (controls.get(key) !== 'Luonnos') { // Only untouched future weeks are moved to draft. Existing food/week statuses never change. const row = weekRows.findIndex(r => r[0] === venue.name && r[1] === week); requests.push(olUpdate_(weeksSheet.properties.sheetId, row, 2, [['Luonnos']])); } } if (added.length) { const columns = table.columnProperties || []; const groupColumn = columns.find(c => c.columnIndex === 4); if (!groupColumn || groupColumn.columnType !== 'DROPDOWN') throw new Error(venue.tab + ': Otsikko-sarakkeen tulee olla pudotusvalikko.'); const choices = groupColumn.dataValidationRule.condition.values.map(v => v.userEnteredValue); const missing = [...new Set(slots)].filter(s => !choices.includes(s)); if (missing.length) { const updated = JSON.parse(JSON.stringify(columns)); updated.find(c => c.columnIndex === 4).dataValidationRule.condition.values.push(...missing.map(v => ({userEnteredValue:v}))); requests.push({updateTable:{table:{tableId:table.tableId,columnProperties:updated},fields:'columnProperties'}}); requests.push({repeatCell:{range:olRange_(sheet.properties.sheetId,0,1,9), cell:{userEnteredFormat:{textFormat:{bold:true,foregroundColorStyle:{rgbColor:{red:0,green:0,blue:0}}}}},fields:'userEnteredFormat.textFormat'}}); } requests.push(...olAppend_(sheet, table, added)); const first = table.range.endRowIndex; requests.push({updateBorders:{range:olRange_(sheet.properties.sheetId,first,first+added.length,9), innerHorizontal:{style:'SOLID',color:{red:.87,green:.89,blue:.88}}}}); starts.forEach(i => requests.push({updateBorders:{range:olRange_(sheet.properties.sheetId,first+i,first+i+1,9), top:{style:'SOLID_MEDIUM',color:{red:.58,green:.64,blue:.60}}}})); requests.push({repeatCell:{range:{...olRange_(sheet.properties.sheetId,first,first+added.length,1)}, cell:{userEnteredFormat:{numberFormat:{type:'DATE',pattern:'dd.MM.yyyy'}}},fields:'userEnteredFormat.numberFormat'}}); } report.push(venue.tab + ': ' + added.length + ' uutta ruokapaikkaa'); } if (newControls.length) { const table = olTable_(weeksSheet,4); olInsideTable_(weeksSheet,weekRows,table); requests.push(...olAppend_(weeksSheet,table,newControls)); requests.push({repeatCell:{range:{sheetId:weeksSheet.properties.sheetId,startRowIndex:table.range.endRowIndex, endRowIndex:table.range.endRowIndex+newControls.length,startColumnIndex:1,endColumnIndex:2}, cell:{userEnteredFormat:{numberFormat:{type:'DATE',pattern:'dd.MM.yyyy'}}},fields:'userEnteredFormat.numberFormat'}}); } return {requests,summary:report.join(' · ')}; } function olRows_(sheet) { const rows = []; (sheet.data || []).forEach(d => (d.rowData || []).forEach((row,i) => { const values = row.values || []; rows[(d.startRow || 0)+i] = values.map(c => { const v = c.effectiveValue || c.userEnteredValue || {}; return Object.prototype.hasOwnProperty.call(v,'numberValue') ? v.numberValue : Object.prototype.hasOwnProperty.call(v,'boolValue') ? v.boolValue : (v.stringValue || ''); }); })); return Array.from(rows,r => r || []); } function olSheet_(sheets,name) { const s=sheets.get(name); if(!s)throw new Error('Puuttuva välilehti: '+name); return s; } function olHeaders_(actual,expected,name) { if(expected.some((v,i)=>actual[i]!==v))throw new Error(name+': sarakeotsikot eivät vastaa lounaspohjaa.'); } function olTable_(sheet,width) { const tables=(sheet.tables||[]).filter(t=>(t.range.startRowIndex||0)===0&&(t.range.startColumnIndex||0)===0&&t.range.endColumnIndex===width); if(tables.length!==1)throw new Error(sheet.properties.title+': tarvitaan yksi natiivi taulukko otsikkorivistä alkaen.'); if(sheet.basicFilter)throw new Error(sheet.properties.title+': sulje koko taulukon perussuodatin. Tallennetut näkymät voivat olla käytössä.'); return tables[0]; } function olInsideTable_(sheet,rows,table) { if(rows.slice(table.range.endRowIndex).some(r=>r.some(v=>v!==''))) throw new Error(sheet.properties.title+': taulukon alla on täytettyjä rivejä. Laajenna ensin natiivi taulukko niihin.'); } function olSerial_(iso) { const d=new Date(iso+'T00:00:00Z'); if(!/^\d{4}-\d{2}-\d{2}$/.test(iso)||!Number.isFinite(d.getTime())||d.toISOString().slice(0,10)!==iso)throw new Error('Virheellinen päivämäärä.'); return Math.floor(d.getTime()/86400000)+25569; } function olMonday_(serial) { return serial-((serial+5)%7); } function olDays_(s) { const codes=['ma','ti','ke','to','pe','la','su']; if(typeof s!=='string')throw new Error('Lounaspäivät puuttuvat.'); const days=s.split(',').map(s=>codes.indexOf(s.trim().toLowerCase())+1); if(!days.length||days.includes(0)||new Set(days).size!==days.length)throw new Error('Lounaspäivät: käytä ma, ti, ke, to, pe, la, su.'); return days.sort((a,b)=>a-b); } function olSlots_(s) { if(typeof s!=='string')throw new Error('Vakio-otsikot puuttuvat.'); const groups=s.split('|').map(s=>s.trim()), seen=new Set(), slots=[]; if(!groups.length||groups.length>6)throw new Error('Vakio-otsikoita voi olla 1–6.'); groups.forEach(g=>{ const match=g.match(/^(.*?)\s*×\s*(\d+)$/), name=(match?match[1]:g).trim(), count=match?Number(match[2]):1; if(!name||/[×]/.test(name)||seen.has(name)||count<1||count>6)throw new Error('Tarkista vakio-otsikon nimi ja × 1–6 -määrä.'); seen.add(name);for(let i=0;i18)throw new Error('Pohjassa voi olla enintään 18 ruokapaikkaa päivälle.'); return slots; } function olRange_(id,start,end,width) { return {sheetId:id,startRowIndex:start,endRowIndex:end,startColumnIndex:0,endColumnIndex:width}; } function olUpdate_(id,start,col,rows) { return {updateCells:{range:{sheetId:id,startRowIndex:start,endRowIndex:start+rows.length,startColumnIndex:col,endColumnIndex:col+rows[0].length}, rows:rows.map(row=>({values:row.map(v=>({userEnteredValue:typeof v==='number'?{numberValue:v}:typeof v==='boolean'?{boolValue:v}: typeof v==='object'?v:{stringValue:v}}))})),fields:'userEnteredValue'}}; } function olAppend_(sheet,table,rows) { const start=table.range.endRowIndex, end=start+rows.length,id=sheet.properties.sheetId,width=table.range.endColumnIndex; if(end-1>OL.maxRows)throw new Error(sheet.properties.title+': historia lähestyy 5000 rivin rajaa. Arkistoi vanhimmat viikot ennen jatkamista.'); return [ {insertDimension:{range:{sheetId:id,dimension:'ROWS',startIndex:start,endIndex:end},inheritFromBefore:true}}, {updateTable:{table:{tableId:table.tableId,range:{...table.range,endRowIndex:end}},fields:'range'}}, {copyPaste:{source:olRange_(id,start-1,start,width),destination:olRange_(id,start,end,width),pasteType:'PASTE_FORMAT'}}, olUpdate_(id,start,0,rows) ]; } function olCountFormula_(tab,serial) { // Open column ranges keep counts correct after insertion or later manual extra foods. return {formulaValue:'=SUMPRODUCT((\''+tab+'\'!A$2:A>='+serial+')*(\''+tab+'\'!A$2:A<'+(serial+7)+')*(LEN(\''+tab+'\'!C$2:C)>0))'}; } if(typeof module!=='undefined')module.exports={OL,suunnitteleViikot,olSerial_,olMonday_,olSlots_,olDays_}; /** Kitchen tools share the existing spreadsheet; no second editable menu database. */ function olKitchenLibrary(snapshot) { const entries = new Map(); for (const venue of OL.venues) { const sheet = snapshot.sheets.find(s => s.properties.title === venue.tab); if (!sheet) throw new Error('Puuttuva välilehti: ' + venue.tab); const rows = olRows_(sheet); olHeaders_(rows[0], OL.menuHeaders, venue.tab); rows.slice(1).forEach(row => { if (!row[2] || row[8] === true) return; const values = [venue.name].concat([2,3,4,5,6,7].map(i => String(row[i] || ''))); // Different diet, price or preparation notes remain separate choices. entries.set(JSON.stringify(values), values); }); } if (entries.size > 2000) throw new Error('Ruokapankissa on yli 2000 vaihtoehtoa. Rajaa historia ennen päivitystä.'); return Array.from(entries.values()).sort((a,b) => a[0].localeCompare(b[0],'fi') || a[1].localeCompare(b[1],'fi')); } function olKitchenCopyPlan(snapshot, options, isoToday) { const venues = new Map(OL.venues.map(v => [v.name,v])); const source = venues.get(options.source), target = venues.get(options.target); if (!source || !target) throw new Error('Valitse tunnettu lähde- ja kohdepaikka.'); const from = olSerial_(options.sourceWeek), to = olSerial_(options.targetWeek); if (olMonday_(from) !== from || olMonday_(to) !== to) throw new Error('Valitse viikon maanantai.'); const current = olMonday_(olSerial_(isoToday)); if (to < current || to > current + 42) throw new Error('Kohdeviikon tulee olla kuluva tai jokin kuudesta seuraavasta viikosta.'); if (from === to && source.name === target.name) throw new Error('Lähde ja kohde ovat sama viikko.'); const sheets = new Map(snapshot.sheets.map(s => [s.properties.title,s])); const sourceSheet = olSheet_(sheets,source.tab), targetSheet = olSheet_(sheets,target.tab), weekSheet = olSheet_(sheets,'Viikot'); const sourceRows = olRows_(sourceSheet), targetRows = olRows_(targetSheet), weekRows = olRows_(weekSheet); olHeaders_(sourceRows[0],OL.menuHeaders,source.tab);olHeaders_(targetRows[0],OL.menuHeaders,target.tab);olHeaders_(weekRows[0],OL.weekHeaders,'Viikot'); const table = olTable_(targetSheet,9); olInsideTable_(targetSheet,targetRows,table); const controls = weekRows.map((r,i)=>({r,i})).filter(({r})=>r[0]===target.name && r[1]===to); if (controls.length !== 1) throw new Error('Kohdeviikon pitää olla valmiina Viikot-välilehdellä.'); if (controls[0].r[2] !== 'Luonnos') throw new Error('Valitse ensin kohdeviikon tilaksi Luonnos Viikot-välilehdellä.'); const settings = olRows_(olSheet_(sheets,'Asetukset')).filter(r => r[0] === target.name); if (settings.length !== 1) throw new Error('Kohdepaikan asetuksia ei löytynyt yksilöllisesti.'); const days = olDays_(settings[0][1]); const targets = targetRows.map((r,i)=>({r,i})).filter(({r})=>Number.isInteger(r[0]) && r[0]>=to && r[0][1,2,3,5,6,7,8].some(i => r[i] !== '' && r[i] !== false && r[i] != null))) throw new Error('Kohdeviikolla on jo tietoja. Mitään ei korvattu. Valitse tyhjä viikko.'); const foods = sourceRows.filter(r=>Number.isInteger(r[0]) && r[0]>=from && r[0]c.columnIndex===4); if (groupColumn?.dataValidationRule?.condition?.type==='ONE_OF_LIST') { const values = (groupColumn.dataValidationRule.condition.values || []).map(v=>({...v})); const names = new Set(values.map(v=>v.userEnteredValue)); for (const food of foods) if (food[4] && !names.has(String(food[4]))) { values.push({userEnteredValue:String(food[4])}); names.add(String(food[4])); } requests.unshift({updateTable:{table:{tableId:table.tableId,columnProperties:columns.map(c=>c.columnIndex===4?{...c,dataValidationRule:{condition:{type:'ONE_OF_LIST',values}}}:c)},fields:'columnProperties'}}); } if (additions.length) { if (table.range.endRowIndex + additions.length > OL.maxRows + 1) throw new Error('Taulukko lähestyy 5000 rivin rajaa. Arkistoi vanhimmat viikot ensin.'); // Insert INSIDE the native table, before the target week. Existing cells move // together; Google expands the table without a stale endRowIndex overwrite. // appendCells.tableId currently creates blank rows in the live Google API. const start=targets[0].i; if(start<1)throw new Error('Natiivitaulukosta puuttuu mallirivi.'); requests.push({insertDimension:{range:{sheetId:targetSheet.properties.sheetId,dimension:'ROWS',startIndex:start,endIndex:start+additions.length},inheritFromBefore:true}}); for(const type of ['PASTE_FORMAT','PASTE_DATA_VALIDATION'])requests.push({copyPaste:{source:olRange_(targetSheet.properties.sheetId,table.range.endRowIndex+additions.length-1,table.range.endRowIndex+additions.length,9),destination:olRange_(targetSheet.properties.sheetId,start,start+additions.length,9),pasteType:type}}); requests.push(olUpdate_(targetSheet.properties.sheetId,start,0,additions)); } return {requests,options,foodCount:foods.length,targetSheetId:targetSheet.properties.sheetId, expectedRows:additions,fingerprint:olKitchenFingerprint_(snapshot),summary:foods.length+' ruokaa kopioitu luonnokseksi. Tarkista hinnat, ajat ja ruokavaliot ennen julkaisua.'}; } function olKitchenFingerprint_(snapshot) { // Includes native row structure so row moves or changes made in Sheets invalidate a plan. return JSON.stringify(snapshot.sheets.filter(s => OL.venues.some(v=>v.tab===s.properties.title)||['Asetukset','Viikot'].includes(s.properties.title))); } function olKitchenSnapshot_() { const id=PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'); if(!id)throw new Error('Viikkoautomaatio pitää olla otettu käyttöön ensin.'); return olKitchenReadSnapshot_(id); } function olKitchenReadSnapshot_(id) { const names=OL.venues.map(v=>v.tab).concat(['Asetukset','Viikot']); const meta=Sheets.Spreadsheets.get(id,{fields:'sheets(properties(title,gridProperties(rowCount)))'}); return Sheets.Spreadsheets.get(id,{ranges:names.map(n=>{ const sheet=meta.sheets.find(s=>s.properties.title===n);if(!sheet)throw new Error('Puuttuva välilehti: '+n); return "'"+n+"'!A1:"+(n==='Asetukset'?'K':n==='Viikot'?'D':'I')+Math.min(sheet.properties.gridProperties.rowCount,5001); }), fields:'properties(locale,timeZone),sheets(properties(sheetId,title,gridProperties),tables(tableId,name,range,columnProperties),basicFilter,data(startRow,startColumn,rowData(values(userEnteredValue,effectiveValue))))'}); } function otaKeittiotyokalutKayttoon() { const ss=SpreadsheetApp.getActiveSpreadsheet(); if(!ss || ss.getId()!==PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'))throw new Error('Avaa oma lounastaulukko.'); const lock=LockService.getScriptLock();lock.waitLock(30000); try { const snapshot=olKitchenSnapshot_(); olKitchenInstall_(ss,snapshot); const triggers=ScriptApp.getProjectTriggers().filter(t=>t.getHandlerFunction()==='omalistaKeittioMuokkaus'); if(!triggers.length)ScriptApp.newTrigger('omalistaKeittioMuokkaus').forSpreadsheet(ss).onEdit().create(); triggers.slice(1).forEach(t=>ScriptApp.deleteTrigger(t)); PropertiesService.getScriptProperties().setProperty('KITCHEN_TOOLS','true'); ss.toast('Ruokapankki ja turvallinen viikkokopiointi ovat käytössä.','OmaLista',8); } finally {lock.releaseLock();} } function paivitaRuokapankki() { const lock=LockService.getScriptLock();lock.waitLock(30000); try { olKitchenRefreshLibrary_(SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID')),olKitchenSnapshot_()); } finally {lock.releaseLock();} } function olKitchenInstall_(ss,snapshot) { const date=Utilities.formatDate(new Date(),OL.zone,'yyyy-MM-dd'), monday=olMonday_(olSerial_(date)); let tools=ss.getSheetByName('Kopioi viikko'); if(!tools) { Sheets.Spreadsheets.batchUpdate({requests:[{addSheet:{properties:{sheetId:1042702,title:'Kopioi viikko',gridProperties:{rowCount:40,columnCount:6}}}}]},ss.getId()); tools=olKitchenFreshSheet_(ss.getId(),'Kopioi viikko'); const names=OL.venues.map(v=>v.name); OL.venues.forEach((venue,i)=>{ const start=1+i*9; tools.getRange(start,1,8,2).setValues([ ['Kopioi viikko',venue.name],['Lähdepaikka',venue.name],['Lähdeviikon maanantai',monday], ['Kohdepaikka',venue.name],['Kohdeviikon maanantai',monday+7],['Kopioi',false], ['Tulos','Aseta kohdeviikko ensin Luonnokseksi Viikot-välilehdellä. Valitse lähde ja kohde ja rasti Kopioi.'], ['Muista tarkistaa','Kopioi vain tyhjään viikkoon. Hinnat ja ajat tulevat kohdepaikan oletuksista. Tarkista ruokavaliot.']]); tools.getRange(start+1,2).setDataValidation(SpreadsheetApp.newDataValidation().requireValueInList(names,true).setAllowInvalid(false).build()); [start+2,start+4].forEach(row=>tools.getRange(row,2).setNumberFormat('dd.MM.yyyy').setDataValidation(SpreadsheetApp.newDataValidation().requireDate().setAllowInvalid(false).build())); tools.getRange(start+5,2).insertCheckboxes(); tools.getRange(start,1,1,2).setFontWeight('bold').setBackground('#eeeeee'); tools.setRowHeights(start,8,48);tools.setRowHeight(start+6,110);tools.setRowHeight(start+7,110); tools.getRange(start+3,2).setNote('Tämän lomakkeen kohdepaikka. Älä muuta; käytä toisen paikan omaa lomaketta alempana.'); }); tools.setColumnWidth(1,150);tools.setColumnWidth(2,230);tools.hideColumns(3,tools.getMaxColumns()-2); tools.getRange('A1:B35').setWrap(true).setVerticalAlignment('top'); tools.setFrozenRows(1); SpreadsheetApp.flush(); } olKitchenRefreshLibrary_(ss,snapshot); } function olKitchenRefreshLibrary_(ss,snapshot) { const rows=olKitchenLibrary(snapshot);let bank=ss.getSheetByName('Ruokapankki'); if(!bank){Sheets.Spreadsheets.batchUpdate({requests:[{addSheet:{properties:{sheetId:1042701,title:'Ruokapankki',gridProperties:{rowCount:Math.max(100,rows.length+1),columnCount:7}}}}]},ss.getId());bank=olKitchenFreshSheet_(ss.getId(),'Ruokapankki');} const header=['Toimipiste','Ruoka','Ruokavaliot','Otsikko','Hinta','Lounasaika','Lisätieto']; const old=bank.getLastRow(); if(old && bank.getRange(1,1,1,7).getValues()[0].join('|')!==header.join('|'))throw new Error('Ruokapankin otsikot ovat muuttuneet. Sisältöä ei korvattu.'); if(bank.getMaxRows()r.map(v=>typeof v==='string' && v.startsWith('=')?"'"+v:v))); if(old>rows.length+1)bank.getRange(rows.length+2,1,old-rows.length-1,7).clearContent(); bank.setFrozenRows(1);bank.getRange(1,1,rows.length+1,7).setWrap(true).setVerticalAlignment('top');bank.getRange('A1:G1').setFontWeight('bold').setBackground('#eeeeee'); bank.setColumnWidth(1,180);bank.setColumnWidth(2,300);bank.setColumnWidths(3,5,150); bank.getRange('B1').setNote('Ruokapankki kootaan ruokariveistä. Kopioi tarvittavat tiedot oman paikan tyhjälle ruokariville. Päivitä pankki OmaLista-valikosta; pankin omia muokkauksia ei käytetä verkkolistalla.'); } function omalistaKeittioMuokkaus(event) { if(typeof olEditorOnEdit_==='function')olEditorOnEdit_(event); if(!event?.range || event.range.getSheet().getName()!=='Kopioi viikko' || event.range.getColumn()!==2 || event.range.getNumRows()!==1 || event.range.getNumColumns()!==1 || ![6,15,24,33].includes(event.range.getRow()) || event.value!=='TRUE')return; const props=PropertiesService.getScriptProperties(); if(props.getProperty('KITCHEN_TOOLS')!=='true' || event.source.getId()!==props.getProperty('SPREADSHEET_ID'))return; const lock=LockService.getScriptLock(); if(!lock.tryLock(30000)) {event.range.getSheet().getRange(event.range.getRow()+1,2).setValue('Toinen päivitys on käynnissä. Poista rasti ja yritä uudelleen.');return;} try { if(event.range.getValue()!==true)return; const command=event.range.getSheet().getRange(event.range.getRow()-4,2,4,1).getValues().map(r=>r[0]); const iso=d=>d instanceof Date?Utilities.formatDate(d,OL.zone,'yyyy-MM-dd'):''; const options={source:command[0],sourceWeek:iso(command[1]),target:command[2],targetWeek:iso(command[3])}; if(options.target!==OL.venues[(event.range.getRow()-6)/9].name)throw new Error('Kohdepaikka on muuttunut. Käytä kyseisen paikan omaa lomaketta.'); const today=Utilities.formatDate(new Date(),OL.zone,'yyyy-MM-dd'); const before=olKitchenSnapshot_(),plan=olKitchenCopyPlan(before,options,today); // Recheck immediately before a bounded, atomic write. Manual Sheets editing does not honor ScriptLock. const current=olKitchenSnapshot_(); if(plan.fingerprint!==olKitchenFingerprint_(current))throw new Error('Taulukkoa muokattiin tarkistuksen aikana. Mitään ei korvattu; yritä uudelleen.'); const actual=event.range.getSheet().getRange(event.range.getRow()-4,2,4,1).getValues().map(r=>r[0]); if(JSON.stringify(actual)!==JSON.stringify(command))throw new Error('Kopioinnin valintoja muutettiin. Tarkista ne ja yritä uudelleen.'); Sheets.Spreadsheets.batchUpdate({requests:plan.requests},props.getProperty('SPREADSHEET_ID')); olKitchenVerifyCopy_(olKitchenSnapshot_(),plan); event.range.getSheet().getRange(event.range.getRow()+1,2).setValue(plan.summary); } catch(error) {event.range.getSheet().getRange(event.range.getRow()+1,2).setValue('Kopiointi keskeytyi: '+String(error.message).slice(0,350));} finally {event.range.setValue(false);lock.releaseLock();} } function olKitchenVerifyCopy_(snapshot,plan) { const sheet=snapshot.sheets.find(s=>s.properties.sheetId===plan.targetSheetId); const rows=olRows_(sheet),available=new Map(); rows.slice(1).forEach(r=>{const key=JSON.stringify(Array.from({length:9},(_,i)=>r[i]??''));available.set(key,(available.get(key)||0)+1);}); for(const expected of plan.expectedRows){const key=JSON.stringify(expected),n=available.get(key)||0;if(!n)throw new Error('Kopioinnin lopputulosta ei voitu varmistaa. Tarkista kohdeviikko ennen uutta yritystä.');available.set(key,n-1);} } function olKitchenFreshSheet_(id,title) { for(let attempt=0;attempt<3;attempt++){ SpreadsheetApp.flush(); const sheet=SpreadsheetApp.openById(id).getSheetByName(title); if(sheet)return sheet; Utilities.sleep(500); } throw new Error('Uusi välilehti ei vielä päivittynyt Googlelta. Yritä käyttöönottoa uudelleen.'); } /** Private server bridge. Never deploy without a random OL_EDITOR_SECRET. */ const OLE_IDS=['majatalo','lilla','helmi','libris']; function olEditorPad_(rows,width){return rows.map(r=>Array.from({length:width},(_,i)=>r[i]??''));} function olEditorIso_(serial){return new Date((serial-25569)*86400000).toISOString().slice(0,10);} function olEditorHex_(bytes){return bytes.map(b=>('0'+((b+256)%256).toString(16)).slice(-2)).join('');} function olEditorHmac_(s,key){return olEditorHex_(Utilities.computeHmacSha256Signature(s,key,Utilities.Charset.UTF_8));} function olEditorDigest_(v){return olEditorHex_(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256,JSON.stringify(v),Utilities.Charset.UTF_8));} function olEditorSame_(a,b){if(typeof a!=='string'||typeof b!=='string'||a.length!==b.length)return false;let d=0;for(let i=0;i3000000)throw Error('Denied');env=JSON.parse(event.postData.contents); if(env.version!==1||!Number.isInteger(env.timestamp)||Math.abs(Math.floor(Date.now()/1000)-env.timestamp)>300||!/^[a-f0-9]{40}$/.test(env.nonce)||typeof env.payload!=='string'||!olEditorSame_(env.signature,olEditorHmac_('1\n'+env.timestamp+'\n'+env.nonce+'\n'+env.payload,secret)))throw Error('Denied'); }catch(e){return ContentService.createTextOutput('{"ok":false}').setMimeType(ContentService.MimeType.JSON);} const lock=LockService.getScriptLock();let result; try{lock.waitLock(30000); const now=Math.floor(Date.now()/1000),all=props.getProperties(); if(all['EN_'+env.nonce])throw Error('Pyyntö käytettiin jo.'); Object.keys(all).filter(k=>k.startsWith('EN_')&&Number(all[k])props.deleteProperty(k)); props.setProperty('EN_'+env.nonce,String(now)); const payload=JSON.parse(Utilities.newBlob(Utilities.base64Decode(env.payload)).getDataAsString('UTF-8')); result=olEditorDispatch_(payload); }catch(e){result={ok:false,code:String(e.message).startsWith('CONFLICT:')?'conflict':'error',message:String(e.message).replace(/^CONFLICT:/,'').slice(0,300)};} finally{try{lock.releaseLock();}catch(e){}} const encoded=Utilities.base64Encode(JSON.stringify(result),Utilities.Charset.UTF_8); return ContentService.createTextOutput(JSON.stringify({payload:encoded,signature:olEditorHmac_(env.nonce+'\n'+encoded,secret)})).setMimeType(ContentService.MimeType.JSON); } function olEditorDispatch_(p){ if(!p||typeof p.action!=='string')throw Error('Virheellinen toiminto.'); if(p.action==='snapshot')return olEditorSnapshot_(); if(p.action==='operation'){if(!/^[a-f0-9-]{32,40}$/.test(p.id||''))throw Error('Virheellinen tunniste.');const v=PropertiesService.getScriptProperties().getProperty('EO_'+p.id);return {ok:true,operation:v?JSON.parse(v):null};} if(p.action==='save')return olEditorSave_(p); if(p.action==='publication')return olEditorPublication_(p); if(p.action==='ack'){const props=PropertiesService.getScriptProperties();for(const id of (p.ids||[])){if(/^[a-f0-9-]{32,40}$/.test(id))props.deleteProperty('EP_'+id);}return {ok:true};} throw Error('Tuntematon toiminto.'); } function olEditorSnapshot_(specifiedId){ const id=specifiedId||PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'),snapshot=olKitchenReadSnapshot_(id); const extra=Sheets.Spreadsheets.get(id,{ranges:["'Hinnasto'!A1:H100","'Automaatio'!A1:B15"],fields:'sheets(properties(title),data(startRow,startColumn,rowData(values(userEnteredValue,effectiveValue))))'}); const data={ok:true,menus:{},settings:olEditorPad_(olRows_(snapshot.sheets.find(s=>s.properties.title==='Asetukset')),11),weekStates:olEditorPad_(olRows_(snapshot.sheets.find(s=>s.properties.title==='Viikot')),4),prices:olEditorPad_(olRows_(extra.sheets.find(s=>s.properties.title==='Hinnasto')),8),automation:olRows_(extra.sheets.find(s=>s.properties.title==='Automaatio')),publications:[]}; OL.venues.forEach((v,i)=>{const sheet=snapshot.sheets.find(s=>s.properties.title===v.tab);olHeaders_(olRows_(sheet)[0],OL.menuHeaders,v.tab);data.menus[OLE_IDS[i]]={rows:olEditorPad_(olRows_(sheet),9),formulas:(sheet.data?.[0]?.rowData||[]).map(r=>(r.values||[]).map(c=>c.userEnteredValue?.formulaValue||''))};}); const props=PropertiesService.getScriptProperties().getProperties();Object.keys(props).filter(k=>k.startsWith('EP_')).sort().slice(0,20).forEach(k=>{const ref=JSON.parse(props[k]);const ss=SpreadsheetApp.openById(id),log=ss.getSheetByName('Editorijulkaisut');if(log){const chunks=log.getRange(ref.start,5,ref.count,1).getValues().map(r=>String(r[0])).join('');data.publications.push(JSON.parse(chunks));}}); return data; } function olEditorWeek_(snapshot,location,week){ const index=OLE_IDS.indexOf(location);if(index<0)throw Error('Tuntematon toimipiste.');const serial=olSerial_(week);if(olMonday_(serial)!==serial)throw Error('Valitse maanantai.'); const menu=snapshot.menus[location];const rows=menu.rows.map((r,i)=>({row:i,values:r})).filter(x=>Number.isInteger(x.values[0])&&x.values[0]>=serial&&x.values[0]i===0||r[0]===OL.venues[index].name),prices=snapshot.prices.filter((r,i)=>i===0||r[0]===OL.venues[index].name); const controls=snapshot.weekStates.map((r,i)=>({row:i,values:r})).filter(x=>x.values[0]===OL.venues[index].name&&x.values[1]===serial); if(controls.length!==1)throw Error('Viikon julkaisurivi puuttuu tai on kahdesti.'); return {location,week,rows,control:controls[0],hash:olEditorDigest_([rows.map(r=>r.values),settings,prices,controls.map(r=>r.values)]),settings,prices}; } function olEditorValidate_(rows,week){ if(!Array.isArray(rows)||rows.length>200)throw Error('Liikaa ruokarivejä.');const serial=olSerial_(week),states=new Map(); return rows.map(r=>{ if(!r||Object.keys(r).some(k=>!['date','group','dish','diets','price','hours','note','closed'].includes(k)))throw Error('Virheellinen ruokarivi.'); const day=olSerial_(r.date);if(dayserial+6)throw Error('Ruoka kuuluu eri viikolle.'); for(const k of ['group','dish','diets','price','hours','note'])if(typeof r[k]!=='string'||r[k].length>1000||/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(r[k]))throw Error('Virheellinen teksti.'); if(typeof r.closed!=='boolean'||(r.closed&&r.dish)||(!r.closed&&!r.dish))throw Error('Ruoka tai sulkumerkintä puuttuu.'); if(states.has(day)&&states.get(day)!==r.closed)throw Error('Päivä on sekä suljettu että avoinna.');states.set(day,r.closed); return [day,false,r.dish,r.diets,r.group,r.price,r.hours,r.note,r.closed]; }); } function olEditorSave_(p,testingContext){ const props=testingContext?.props||PropertiesService.getScriptProperties();const bridgeSnapshot=()=>olEditorSnapshot_(props.getProperty('SPREADSHEET_ID'));if(!/^[a-f0-9-]{32,40}$/.test(p.id||''))throw Error('Virheellinen tunniste.'); const previous=props.getProperty('EO_'+p.id),requestHash=olEditorDigest_([p.location,p.week,p.rows,p.expectedHash]); if(previous){const old=JSON.parse(previous);if(old.requestHash!==requestHash)throw Error('Tunniste kuuluu eri muutokseen.');if(old.state==='done')return {...old.result,ok:true};throw Error('Tallennuksen aiempi tulos on epäselvä. Tarkista taulukko.');} const snapshot=bridgeSnapshot(),week=olEditorWeek_(snapshot,p.location,p.week),values=olEditorValidate_(p.rows,p.week); if(week.hash!==p.expectedHash)throw Error('CONFLICT:Taulukkoa muokattiin muualla. Mitään ei korvattu.');if(!week.rows.length)throw Error('Viikkopohja puuttuu. Tarkista nykyinen automaatio.'); const venue=OL.venues[OLE_IDS.indexOf(p.location)],id=props.getProperty('SPREADSHEET_ID'); const meta=Sheets.Spreadsheets.get(id,{fields:'sheets(properties(sheetId,title),tables(tableId,name,range,columnProperties))'}),sheet=meta.sheets.find(s=>s.properties.title===venue.tab),table=olTable_(sheet,9);let protection; if(week.rows.some(x=>(snapshot.menus[p.location].formulas[x.row]||[]).some(Boolean)))throw Error('Viikon ruokariveillä on kaavoja. Muokkaa niitä Sheetsissä; editori ei korvaa kaavoja.'); try{ const protectedResult=Sheets.Spreadsheets.batchUpdate({requests:[{addProtectedRange:{protectedRange:{range:{sheetId:sheet.properties.sheetId,startRowIndex:1,endRowIndex:table.range.endRowIndex,startColumnIndex:0,endColumnIndex:9},description:'OmaLista tallennus '+p.id,warningOnly:false,editors:{users:[],domainUsersCanEdit:false}}}}]},id); protection=protectedResult.replies[0].addProtectedRange.protectedRange.protectedRangeId; if(olEditorWeek_(bridgeSnapshot(),p.location,p.week).hash!==week.hash)throw Error('CONFLICT:Taulukko muuttui tarkistuksen aikana. Mitään ei korvattu.'); // Keep the desired food order, even when native row insertion is needed. // Existing empty slots remain available; no physical source row is deleted. const requests=[],slotCount=week.rows.length; const extra=values.length>slotCount?values.slice(slotCount-1,-1):[]; week.rows.forEach((old,i)=>{ const next=i===slotCount-1&&extra.length?values[values.length-1]:(values[i]||[old.values[0],false,'','',String(old.values[4]||''),'','','',false]); requests.push(olUpdate_(sheet.properties.sheetId,old.row,0,[next])); }); if(table.range.endRowIndex+extra.length>5001)throw Error('Arkistoi vanhimmat viikot ennen uusien rivien lisäystä.'); const groupCol=table.columnProperties.find(c=>c.columnIndex===4),groups=new Set((groupCol.dataValidationRule?.condition?.values||[]).map(v=>v.userEnteredValue));for(const r of values)if(r[4])groups.add(r[4]); requests.unshift({updateTable:{table:{tableId:table.tableId,columnProperties:table.columnProperties.map(c=>c.columnIndex===4?{...c,dataValidationRule:{condition:{type:'ONE_OF_LIST',values:Array.from(groups,x=>({userEnteredValue:x}))}}}:c)},fields:'columnProperties'}}); if(extra.length){const start=week.rows[week.rows.length-1].row;requests.push({insertDimension:{range:{sheetId:sheet.properties.sheetId,dimension:'ROWS',startIndex:start,endIndex:start+extra.length},inheritFromBefore:true}});for(const type of ['PASTE_FORMAT','PASTE_DATA_VALIDATION'])requests.push({copyPaste:{source:olRange_(sheet.properties.sheetId,start+extra.length,start+extra.length+1,9),destination:olRange_(sheet.properties.sheetId,start,start+extra.length,9),pasteType:type}});requests.push(olUpdate_(sheet.properties.sheetId,start,0,extra));} const weekSheet=meta.sheets.find(s=>s.properties.title==='Viikot');requests.push(olUpdate_(weekSheet.properties.sheetId,week.control.row,2,[['Luonnos']])); props.setProperty('EO_'+p.id,JSON.stringify({state:'started',requestHash,time:Date.now()})); Sheets.Spreadsheets.batchUpdate({requests},id);const after=olEditorWeek_(bridgeSnapshot(),p.location,p.week); const actual=after.rows.map(x=>x.values).filter(r=>r[2]||r[8]===true),normalize=r=>[r[0],false,...[2,3,4,5,6,7].map(i=>String(r[i]||'')),r[8]===true]; if(olEditorDigest_(actual.map(normalize).sort())!==olEditorDigest_(values.map(normalize).sort()))throw Error('CONFLICT:Tallennuksen lopputulos muuttui. Tarkista taulukon ja editorin versiot.'); const result={ok:true,hash:after.hash,week:after};props.setProperty('EO_'+p.id,JSON.stringify({state:'done',requestHash,time:Date.now(),result:{ok:true,hash:after.hash}})); // Completed operation receipts contain no menu data and expire after one day. const all=props.getProperties();Object.keys(all).filter(k=>k.startsWith('EO_')).sort((a,b)=>JSON.parse(all[b]).time-JSON.parse(all[a]).time).forEach((k,i)=>{try{if(i>=500||JSON.parse(all[k]).times.properties.title==='Viikot'); Sheets.Spreadsheets.batchUpdate({requests:[olUpdate_(sheet.properties.sheetId,week.control.row,2,[['Julkaistu']])]},id);return {ok:true,hash:olEditorWeek_(olEditorSnapshot_(),p.location,p.week).hash}; } function olEditorRecordPublication_(location,week,snapshot){ const doc=olEditorWeek_(snapshot,location,week),id=Utilities.getUuid(),payload={id,location,week,hash:doc.hash,rows:doc.rows.map(r=>r.values),settings:doc.settings,prices:doc.prices,created:Date.now()}; const ss=SpreadsheetApp.openById(PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID'));let sheet=ss.getSheetByName('Editorijulkaisut'); if(!sheet){sheet=ss.insertSheet('Editorijulkaisut');sheet.getRange(1,1,1,5).setValues([['Tunniste','Toimipiste','Viikko','Aika','Versio']]);sheet.hideSheet();const protection=sheet.protect().setDescription('OmaListan julkaisuversiot');protection.removeEditors(protection.getEditors());if(protection.canDomainEdit())protection.setDomainEdit(false);} const text=JSON.stringify(payload),chunks=[];for(let i=0;isheet.getMaxRows())sheet.insertRowsAfter(sheet.getMaxRows(),chunks.length+10);sheet.getRange(start,1,chunks.length,5).setValues(chunks); PropertiesService.getScriptProperties().setProperty('EP_'+id,JSON.stringify({start,count:chunks.length})); } function olEditorOnEdit_(event){ const props=PropertiesService.getScriptProperties();if(props.getProperty('OL_EDITOR_ENABLED')!=='true'||!event?.range||event.source.getId()!==props.getProperty('SPREADSHEET_ID'))return; const name=event.range.getSheet().getName(),index=OL.venues.findIndex(v=>v.tab===name);if(index<0&&name!=='Viikot')return; const lock=LockService.getScriptLock();lock.waitLock(30000); try{ const snapshot=olEditorSnapshot_(),ss=event.source; if(name==='Viikot'){ if(event.range.getColumn()>3||event.range.getLastColumn()<3)return; const rows=snapshot.weekStates;for(let row=event.range.getRow()-1;rowv.name===r[0]);if(i>=0)olEditorRecordPublication_(OLE_IDS[i],olEditorIso_(r[1]),snapshot);} }else{ const touched=snapshot.menus[OLE_IDS[index]].rows.slice(event.range.getRow()-1,event.range.getLastRow()),weeks=new Set(touched.filter(r=>Number.isInteger(r[0])).map(r=>olMonday_(r[0]))); if(event.range.getColumn()===1&&event.oldValue){try{weeks.add(olMonday_(olSerial_(event.oldValue)));}catch(e){}} const sheet=ss.getSheetByName('Viikot');snapshot.weekStates.forEach((r,i)=>{if(r[0]===OL.venues[index].name&&weeks.has(r[1])&&r[2]==='Julkaistu'){sheet.getRange(i+1,3).setValue('Luonnos');ss.toast('Asiakkaat näkevät aiemman version. Julkaise korjattu viikko Viikot-välilehdellä valitsemalla Julkaistu.', 'Muutoksia julkaisematta',10);}}); } }finally{lock.releaseLock();} } /** Owner runs this once after reviewing the editor bridge. No secret is logged. */ function alustaEditorinYhteys(){ const props=PropertiesService.getScriptProperties(); if(!props.getProperty('SPREADSHEET_ID'))throw Error('Nykyinen viikkoautomaatio pitää olla ensin käytössä.'); if(!props.getProperty('OL_EDITOR_SECRET'))props.setProperty('OL_EDITOR_SECRET',Utilities.getUuid()+Utilities.getUuid()); if(!props.getProperty('OL_EDITOR_ENABLED'))props.setProperty('OL_EDITOR_ENABLED','false'); console.log('Yksityisen editoriyhteyden avain on valmis Script Properties -asetuksissa. Avainta ei tulosteta.'); } /** Administrator only: enable the installed phone editor; preserve existing triggers. */ function otaPuhelineditoriKayttoon(){ const p=PropertiesService.getScriptProperties();if(!p.getProperty('SPREADSHEET_ID')||!p.getProperty('OL_EDITOR_SECRET'))throw Error('Yksityinen palvelinyhteys puuttuu.'); if(!ScriptApp.getProjectTriggers().some(t=>t.getHandlerFunction()==='omalistaKeittioMuokkaus'))throw Error('Keittiön muutoskäsittelijä puuttuu.'); p.setProperty('OL_EDITOR_ENABLED','true'); const ss=SpreadsheetApp.openById(p.getProperty('SPREADSHEET_ID'));ss.getSheetByName('Viikot').getRange('C1').setNote('Julkaistu siirtää viikon asiakkaille. Myöhemmät ruokamuutokset ovat luonnoksia: asiakkaat näkevät aiemman version, kunnes julkaiset korjatun viikon. Palvelin tarkistaa muutokset minuutin välein. Puhelineditori: https://omatukku.fi/lounaslista/editor.php'); console.log('Puhelineditorin muutosseuranta käytössä. Nykyiset viikkoajastukset säilyivät.'); }