import { sdVersion, msVersion, moduleName, getPermission, enableModule, streamDeck, macroControl,soundboard,playlistControl, minimumMSversion, minimumSDversion } from "../MaterialDeck.js"; export function compareVersions(checkedVersion, requiredVersion) { requiredVersion = requiredVersion.split("."); checkedVersion = checkedVersion.split("."); for (let i=0; i<3; i++) { requiredVersion[i] = isNaN(parseInt(requiredVersion[i])) ? 0 : parseInt(requiredVersion[i]); checkedVersion[i] = isNaN(parseInt(checkedVersion[i])) ? 0 : parseInt(checkedVersion[i]); } if (checkedVersion[0] > requiredVersion[0]) return false; if (checkedVersion[0] < requiredVersion[0]) return true; if (checkedVersion[1] > requiredVersion[1]) return false; if (checkedVersion[1] < requiredVersion[1]) return true; if (checkedVersion[2] > requiredVersion[2]) return false; return true; } export function compatibleCore(compatibleVersion){ const split = compatibleVersion.split("."); if (split.length == 2) compatibleVersion = `0.${compatibleVersion}`; let coreVersion = game.version == undefined ? game.data.version : `0.${game.version}`; return compareVersions(compatibleVersion, coreVersion); } export function compatibleSystem(compatibleVersion){ const split = compatibleVersion.split("."); if (split.length == 2) compatibleVersion = `0.${compatibleVersion}`; let coreVersion = game.system.data.version; return compareVersions(compatibleVersion, coreVersion); } export class playlistConfigForm extends FormApplication { constructor(data, options) { super(data, options); this.data = data; this.playlistNr; } /** * Default Options for this FormApplication */ static get defaultOptions() { return mergeObject(super.defaultOptions, { id: "playlist-config", title: "Material Deck: "+game.i18n.localize("MaterialDeck.Sett.PlaylistConfig"), template: "./modules/MaterialDeck/templates/playlistConfig.html", classes: ["sheet"], width: 500, height: "auto" }); } /** * Provide data to the template */ getData() { if (getPermission('PLAYLIST','CONFIGURE') == false ) { ui.notifications.warn(game.i18n.localize("MaterialDeck.Notifications.Playlist.NoPermission")); return; } //Get the playlist settings let settings = game.settings.get(moduleName,'playlists'); //Get values from the settings, and check if they are defined let selectedPlaylists = settings.selectedPlaylist; if (selectedPlaylists == undefined) selectedPlaylists = []; let selectedPlaylistMode = settings.playlistMode; if (selectedPlaylistMode == undefined) selectedPlaylistMode = []; let numberOfPlaylists = settings.playlistNumber; if (this.updatePlaylistNr) numberOfPlaylists = this.playlistNr; if (numberOfPlaylists == undefined) numberOfPlaylists = 9; let playMode = settings.playMode; if (playMode == undefined) playMode = 0; //Create array to store all the data for each playlist let playlistData = []; for (let i=0; i { this.data.playMode=event.target.value; this.updateSettings(this.data); }); numberOfPlaylists.on("change", event => { this.playlistNr = event.target.value; this.data.playlistNumber=event.target.value; this.updateSettings(this.data,true); }); selectedPlaylist.on("change", event => { let id = event.target.id.replace('playlist',''); this.data.selectedPlaylist[id-1]=event.target.value; this.updateSettings(this.data); }); playlistMode.on("change", event => { let id = event.target.id.replace('playlistMode',''); this.data.playlistMode[id-1]=event.target.value; this.updateSettings(this.data); }); } async updateSettings(settings,render){ if (game.user.isGM) { await game.settings.set(moduleName,'playlists', settings); if (enableModule) playlistControl.updateAll(); if (render) this.render(); } else { const payload = { "msgType": "playlistUpdate", "settings": settings, "render": render }; game.socket.emit(`module.MaterialDeck`, payload); } } } ////////////////////////////////////////////////////////////////////////////////////////////////////////////// export class macroConfigForm extends FormApplication { constructor(data, options) { super(data, options); this.data = data; this.page = 0; } /** * Default Options for this FormApplication */ static get defaultOptions() { return mergeObject(super.defaultOptions, { id: "materialDeck_macroConfig", title: "Material Deck: "+game.i18n.localize("MaterialDeck.Sett.MacroConfig"), template: "./modules/MaterialDeck/templates/macroConfig.html", classes: ["sheet"] }); } /** * Provide data to the template */ getData() { if (getPermission('MACRO','MACROBOARD_CONFIGURE') == false ) { ui.notifications.warn(game.i18n.localize("MaterialDeck.Notifications.Macroboard.NoPermission")); return; } //Get the settings var selectedMacros = game.settings.get(moduleName,'macroSettings').macros; var color = game.settings.get(moduleName,'macroSettings').color; var args = game.settings.get(moduleName,'macroSettings').args; //Check if the settings are defined if (selectedMacros == undefined) selectedMacros = []; if (color == undefined) color = []; if (args == undefined) args = []; //Check if the Furnace is installed and enabled let height = 95; let advancedMacrosEnabled = false; let advancedMacros = game.modules.get("advanced-macros"); if (advancedMacros != undefined && advancedMacros.active) advancedMacrosEnabled = true; if (advancedMacrosEnabled) { advancedMacrosEnabled = true; height += 50; } let iteration = this.page*32; let macroData = []; for (let j=0; j<4; j++){ let macroThis = []; for (let i=0; i<8; i++){ let colorData = color[iteration]; if (colorData != undefined){ let colorCorrect = true; if (colorData[0] != '#') colorCorrect = false; for (let k=0; k<6; k++){ if (parseInt(colorData[k+1],16)>15) colorCorrect = false; } if (colorCorrect == false) colorData = '#000000'; } else colorData = '#000000'; let dataThis = { iteration: iteration+1, macro: selectedMacros[iteration], color: colorData, args: args[iteration] } macroThis.push(dataThis); iteration++; } macroData.push({dataThis: macroThis}); } return { height: height, macros: game.macros, selectedMacros: selectedMacros, macroData: macroData, furnace: advancedMacrosEnabled, macroRange: `${this.page*32 + 1} - ${this.page*32 + 32}`, prevDisabled: this.page == 0 ? 'disabled' : '', totalMacros: Math.max(Math.ceil(selectedMacros.length/32)*32, this.page*32 + 32) } } /** * Update on form submit * @param {*} event * @param {*} formData */ async _updateObject(event, formData) { } activateListeners(html) { super.activateListeners(html); const navNext = html.find("button[name='navNext']"); const navPrev = html.find("button[name='navPrev']"); const clearAll = html.find("button[name='clearAll']"); const clearPage = html.find("button[name='clearPage']"); const importBtn = html.find("button[name='import']"); const exportBtn = html.find("button[name='export']"); const macro = html.find("select[name='macros']"); const args = html.find("input[name='args']"); const color = html.find("input[name='colorPicker']"); importBtn.on('click', async(event) => { let importDialog = new importConfigForm(); importDialog.setData('macroboard',this) importDialog.render(true); }); exportBtn.on('click', async(event) => { const settings = game.settings.get(moduleName,'macroSettings'); let exportDialog = new exportConfigForm(); exportDialog.setData(settings,'macroboard') exportDialog.render(true); }); navNext.on('click',async (event) => { this.page++; this.render(true); }); navPrev.on('click',async (event) => { const settings = game.settings.get(moduleName,'macroSettings'); this.page--; if (this.page < 0) this.page = 0; else { const totalMacros = Math.ceil(settings.macros.length/32)*32; if ((this.page + 2)*32 == totalMacros) { let pageEmpty = this.getPageEmpty(totalMacros-32); if (pageEmpty) { await this.clearPage(totalMacros-32,true) } } } this.render(true); }); clearAll.on('click',async (event) => { const parent = this; let d = new Dialog({ title: game.i18n.localize("MaterialDeck.ClearAll"), content: game.i18n.localize("MaterialDeck.ClearAll_Content"), buttons: { continue: { icon: '', label: game.i18n.localize("MaterialDeck.Continue"), callback: async () => { this.page = 0; await parent.clearAllSettings(); parent.render(true); } }, cancel: { icon: '', label: game.i18n.localize("MaterialDeck.Cancel") } }, default: "cancel" }); d.render(true); }) clearPage.on('click',(event) => { const parent = this; let d = new Dialog({ title: game.i18n.localize("MaterialDeck.ClearPage"), content: game.i18n.localize("MaterialDeck.ClearPage_Content"), buttons: { continue: { icon: '', label: game.i18n.localize("MaterialDeck.Continue"), callback: async () => { await parent.clearPage(parent.page*32) parent.render(true); } }, cancel: { icon: '', label: game.i18n.localize("MaterialDeck.Cancel") } }, default: "cancel" }); d.render(true); }) macro.on("change", event => { let id = event.target.id.replace('materialDeck_macroConfig_macros',''); let settings = game.settings.get(moduleName,'macroSettings'); settings.macros[id-1]=event.target.value; this.updateSettings(settings); }); args.on("change", event => { let id = event.target.id.replace('materialDeck_macroConfig_args',''); let settings = game.settings.get(moduleName,'macroSettings'); settings.args[id-1]=event.target.value; this.updateSettings(settings); }); color.on("change", event => { let id = event.target.id.replace('materialDeck_macroConfig_colorpicker',''); let settings = game.settings.get(moduleName,'macroSettings'); settings.color[id-1]=event.target.value; this.updateSettings(settings); }); } async updateSettings(settings){ if (game.user.isGM) { await game.settings.set(moduleName,'macroSettings',settings); if (enableModule) macroControl.updateAll(); } else { const payload = { "msgType": "macroboardUpdate", "settings": settings }; game.socket.emit(`module.MaterialDeck`, payload); } } getPageEmpty(pageStart) { const settings = game.settings.get(moduleName,'macroSettings'); let pageEmpty = true; for (let i=pageStart; i p.id == this.settings.selectedPlaylists[iteration]) if (pl == undefined){ selectedPlaylist = 'none'; sounds = []; } else { //Add the sound name and id to the sounds array for (let sound of pl.sounds.contents) sounds.push({ name: sound.name, id: sound.id }); //Get the playlist id selectedPlaylist = pl.id; } } //Determine whether the sound selector or file picker should be displayed let styleSS = ""; let styleFP ="display:none"; if (selectedPlaylist == 'FP') { styleSS = 'display:none'; styleFP = '' } //Create and fill the data object for this sound let dataThis = { iteration: iteration+1, selectedPlaylist: selectedPlaylist, sound: this.settings.sounds[iteration], sounds: sounds, srcPath: this.settings.src[iteration], colorOn: this.settings.colorOn[iteration] == 0 ? '#000000' : this.settings.colorOn[iteration], colorOff: this.settings.colorOff[iteration] == 0 ? '#000000' : this.settings.colorOff[iteration], mode: this.settings.mode[iteration], volume: this.settings.volume[iteration], imgPath: this.settings.img[iteration], name: this.settings.name[iteration], styleSS: styleSS, styleFP: styleFP } //Push the data to soundsThis (row array) soundsThis.push(dataThis); iteration++; } //Push soundsThis (row array) to soundData (full data array) soundData.push({dataThis: soundsThis}); } return { soundData: soundData, playlists, soundRange: `${this.page*16 + 1} - ${this.page*16 + 16}`, prevDisabled: this.page == 0 ? 'disabled' : '', totalSounds: this.settings.volume.length } } /** * Update on form submit * @param {*} event * @param {*} formData */ async _updateObject(event, formData) { } async activateListeners(html) { super.activateListeners(html); const navNext = html.find("button[name='navNext']"); const navPrev = html.find("button[name='navPrev']"); const clearAll = html.find("button[name='clearAll']"); const clearPage = html.find("button[name='clearPage']"); const importBtn = html.find("button[name='import']"); const exportBtn = html.find("button[name='export']"); const nameField = html.find("input[name='namebox']"); const playlistSelect = html.find("select[name='playlist']"); const soundSelect = html.find("select[name='sounds']"); const soundFP = html.find("input[name2='soundSrc']"); const imgFP = html.find("input[name2='imgSrc']"); const onCP = html.find("input[name='colorOn']"); const offCP = html.find("input[name='colorOff']"); const playMode = html.find("select[name='mode']"); const volume = html.find("input[name='volume']"); importBtn.on('click', async(event) => { let importDialog = new importConfigForm(); importDialog.setData('soundboard',this) importDialog.render(true); }); exportBtn.on('click', async(event) => { const settings = game.settings.get(moduleName,'soundboardSettings'); let exportDialog = new exportConfigForm(); exportDialog.setData(settings,'soundboard') exportDialog.render(true); }); navNext.on('click',async (event) => { this.page++; this.render(true); }); navPrev.on('click',async (event) => { this.page--; if (this.page < 0) this.page = 0; else { const totalSounds = this.settings.volume.length; if ((this.page + 2)*16 == totalSounds) { let pageEmpty = this.getPageEmpty(totalSounds-16); if (pageEmpty) { await this.clearPage(totalSounds-16,true) } } } this.render(true); }); clearAll.on('click',async (event) => { const parent = this; let d = new Dialog({ title: game.i18n.localize("MaterialDeck.ClearAll"), content: game.i18n.localize("MaterialDeck.ClearAll_Content"), buttons: { continue: { icon: '', label: game.i18n.localize("MaterialDeck.Continue"), callback: async () => { this.page = 0; await parent.clearAllSettings(); parent.render(true); } }, cancel: { icon: '', label: game.i18n.localize("MaterialDeck.Cancel") } }, default: "cancel" }); d.render(true); }) clearPage.on('click',(event) => { const parent = this; let d = new Dialog({ title: game.i18n.localize("MaterialDeck.ClearPage"), content: game.i18n.localize("MaterialDeck.ClearPage_Content"), buttons: { continue: { icon: '', label: game.i18n.localize("MaterialDeck.Continue"), callback: async () => { await parent.clearPage(parent.page*16) parent.render(true); } }, cancel: { icon: '', label: game.i18n.localize("MaterialDeck.Cancel") } }, default: "cancel" }); d.render(true); }) nameField.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_name','')-1; this.settings.name[id]=event.target.value; this.updateSettings(this.settings); }); if (playlistSelect.length > 0) { //Listener for when the playlist is changed playlistSelect.on("change", event => { //Get the sound number const iteration = event.target.id.replace('materialDeck_sbConfig_playlists',''); //Get the selected playlist and the sounds of that playlist let selectedPlaylist; //let sounds = []; if (event.target.value==undefined) selectedPlaylist = 'none'; else if (event.target.value == 'none') selectedPlaylist = 'none'; else if (event.target.value == 'FP') { selectedPlaylist = 'FP'; //Show the file picker document.querySelector(`#materialDeck_sbConfig_fp${iteration}`).style=''; //Hide the sound selector document.querySelector(`#materialDeck_sbConfig_ss${iteration}`).style='display:none'; } else { //Hide the file picker document.querySelector(`#materialDeck_sbConfig_fp${iteration}`).style='display:none'; //Show the sound selector document.querySelector(`#materialDeck_sbConfig_ss${iteration}`).style=''; const playlistArray = game.playlists.contents; const pl = playlistArray.find(p => p.id == event.target.value) selectedPlaylist = pl.id; //Get the sound select element let SSpicker = document.getElementById(`materialDeck_sbConfig_soundSelect${iteration}`); //Empty ss element SSpicker.options.length=0; //Create new options and append them let optionNone = document.createElement('option'); optionNone.value = ""; optionNone.innerHTML = game.i18n.localize("MaterialDeck.None"); SSpicker.appendChild(optionNone); for (let sound of pl.sounds.contents) { let newOption = document.createElement('option'); newOption.value = sound.id; newOption.innerHTML = sound.name; SSpicker.appendChild(newOption); } } //Save the new playlist to this.settings, and update the settings this.settings.selectedPlaylists[iteration-1]=event.target.value; this.updateSettings(this.settings); }); } soundSelect.on("change", event => { let id = event.target.id.replace('materialDeck_sbConfig_soundSelect','')-1; this.settings.sounds[id]=event.target.value; this.updateSettings(this.settings); }); soundFP.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_srcPath','')-1; this.settings.src[id]=event.target.value; this.updateSettings(this.settings); }); imgFP.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_imgPath','')-1; this.settings.img[id]=event.target.value; this.updateSettings(this.settings); }); onCP.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_colorOn','')-1; this.settings.colorOn[id]=event.target.value; this.updateSettings(this.settings); }); offCP.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_colorOff','')-1; this.settings.colorOff[id]=event.target.value; this.updateSettings(this.settings); }); playMode.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_playmode','')-1; this.settings.mode[id]=event.target.value; this.updateSettings(this.settings); }); volume.on("change",event => { let id = event.target.id.replace('materialDeck_sbConfig_volume','')-1; this.settings.volume[id]=event.target.value; this.updateSettings(this.settings); }); } async updateSettings(settings){ if (game.user.isGM) { await game.settings.set(moduleName,'soundboardSettings',settings); if (enableModule) soundboard.updateAll(); } else { const payload = { "msgType": "soundboardUpdate", "settings": settings }; game.socket.emit(`module.MaterialDeck`, payload); } } getPageEmpty(pageStart) { let pageEmpty = true; for (let i=pageStart; i { event.preventDefault(); this.readJsonFile(event.target.files[0]); }) } readJsonFile(jsonFile) { var reader = new FileReader(); reader.addEventListener('load', (loadEvent) => { try { let json = JSON.parse(loadEvent.target.result); this.data = json; } catch (error) { console.error(error); } }); reader.readAsText(jsonFile); } } export class downloadUtility extends FormApplication { constructor(data, options) { super(data, options); this.localSDversion = sdVersion; this.masterSDversion; this.localMSversion = msVersion; this.masterMSversion; this.releaseAssets = []; this.profiles = []; let parent = this; setTimeout(function(){ parent.checkForUpdate('SD'); parent.checkForUpdate('MS'); parent.getReleaseData(); },100) } /** * Default Options for this FormApplication */ static get defaultOptions() { return mergeObject(super.defaultOptions, { id: "materialDeck_downloadUtility", title: "Material Deck: " + game.i18n.localize("MaterialDeck.DownloadUtility.Title"), template: "./modules/MaterialDeck/templates/downloadUtility.html", width: 500, height: "auto" }); } /** * Provide data to the template */ getData() { let dlDisabled = true; this.profiles = []; let iteration = 0; for (let asset of this.releaseAssets) { let split = asset.name.split('.'); if (split[split.length-1] == 'streamDeckProfile') { this.profiles.push({id: iteration, label:split[0], url:asset.browser_download_url}); iteration++; dlDisabled = false; } } if (this.localMSversion == undefined) this.localMSversion = 'unknown'; return { minimumSdVersion: minimumSDversion, localSdVersion: this.localSDversion, masterSdVersion: this.masterSDversion, sdDlDisable: this.masterSDversion == undefined, minimumMsVersion: minimumMSversion, localMsVersion: this.localMSversion, masterMsVersion: this.masterMSversion, msDlDisable: this.masterMSversion == undefined, profiles: this.profiles, profileDlDisable: dlDisabled } } /** * Update on form submit * @param {*} event * @param {*} formData */ async _updateObject(event, formData) { } activateListeners(html) { super.activateListeners(html); const downloadSd = html.find("button[id='materialDeck_dlUtil_downloadSd']"); const downloadMs = html.find("button[id='materialDeck_dlUtil_downloadMs']"); const downloadProfile = html.find("button[name='downloadProfile']") const refresh = html.find("button[id='materialDeck_dlUtil_refresh']"); downloadSd.on('click', () => { const version = document.getElementById('materialDeck_dlUtil_masterSdVersion').innerHTML; if (version == '' || version == undefined || version == 'Error') return; const url = `https://github.com/CDeenen/MaterialDeck_SD/releases/download/v${version}/com.cdeenen.materialdeck.streamDeckPlugin`; this.downloadURI(url,'com.cdeenen.materialdeck.streamDeckPlugin') }) downloadMs.on('click', () => { const version = document.getElementById('materialDeck_dlUtil_masterMsVersion').innerHTML; const os = document.getElementById('materialDeck_dlUtil_os').value; if (version == '' || version == undefined || version == 'Error') return; let name = `MaterialServer-${os}.zip`; let url; if (os == 'source') url = `https://github.com/CDeenen/MaterialServer/archive/refs/tags/v${version}.zip`; else url = `https://github.com/CDeenen/MaterialServer/releases/download/v${version}/${name}`; this.downloadURI(url,name) }) downloadProfile.on('click',(event) => { let id = event.currentTarget.id.replace('materialDeck_dlUtil_dlProfile-',''); this.downloadURI(this.profiles[id].url,`${this.profiles[id].label}.streamDeckProfile`); }) refresh.on('click', () => { document.getElementById('materialDeck_dlUtil_masterSdVersion').value = 'Getting data'; this.checkForUpdate('SD'); document.getElementById('materialDeck_dlUtil_masterMsVersion').value = 'Getting data'; this.checkForUpdate('MS'); this.getReleaseData(); }) } downloadURI(uri, name) { var link = document.createElement("a"); link.download = name; link.href = uri; document.body.appendChild(link); link.click(); document.body.removeChild(link); } getReleaseData() { let parent = this; const url = 'https://api.github.com/repos/CDeenen/MaterialDeck_SD/releases/latest'; var request = new XMLHttpRequest(); request.open('GET', url, true); request.send(null); request.onreadystatechange = function () { if (request.readyState === 4 && request.status === 200) { var type = request.getResponseHeader('Content-Type'); const data = JSON.parse(request.responseText); parent.releaseAssets = data.assets; parent.render(true); if (type.indexOf("text") !== 1) return; } } request.onerror = function () {} } checkForUpdate(reqType) { let parent = this; let url; if (reqType == 'SD') url = 'https://raw.githubusercontent.com/CDeenen/MaterialDeck_SD/master/Plugin/com.cdeenen.materialdeck.sdPlugin/manifest.json'; else if (reqType == 'MS') url = 'https://raw.githubusercontent.com/CDeenen/MaterialServer/master/package.json'; const elementId = reqType == 'SD' ? 'materialDeck_dlUtil_masterSdVersion' : 'materialDeck_dlUtil_masterMsVersion'; var request = new XMLHttpRequest(); request.open('GET', url, true); request.send(null); request.onreadystatechange = function () { if (request.readyState === 4 && request.status === 200) { var type = request.getResponseHeader('Content-Type'); if (type.indexOf("text") !== 1) { if (reqType == 'SD') parent.masterSDversion = JSON.parse(request.responseText).Version; else if (reqType == 'MS') parent.masterMSversion = JSON.parse(request.responseText).version; parent.render(true); return; } } } request.onerror = function () { document.getElementById(elementId).innerHTML = 'Error'; } } } export class deviceConfig extends FormApplication { constructor(data, options) { super(data, options); this.devices = []; } /** * Default Options for this FormApplication */ static get defaultOptions() { return mergeObject(super.defaultOptions, { id: "materialDeck_deviceConfig", title: "Material Deck: " + game.i18n.localize("MaterialDeck.DeviceConfig.Title"), template: "./modules/MaterialDeck/templates/deviceConfig.html", width: 500, height: "auto" }); } /** * Provide data to the template */ getData() { this.devices = []; let dConfig = game.settings.get(moduleName, 'devices'); if (Object.prototype.toString.call(game.settings.get('MaterialDeck', 'devices')) === "[object String]") { dConfig = {}; game.settings.set(moduleName, 'devices', dConfig); } for (let d of streamDeck.buttonContext) { if (d == undefined) continue; let type; if (d.type == 0) type = 'Stream Deck'; else if (d.type == 1) type = 'Stream Deck Mini'; else if (d.type == 2) type = 'Stream Deck XL'; else if (d.type == 3) type = 'Stream Deck Mobile'; else if (d.type == 4) type = 'Corsair G Keys'; const name = d.name; const id = d.device; let enable; if (dConfig?.[id] == undefined) enable = true; else enable = dConfig?.[id].enable; const device = { id, name, type, en: enable } this.devices.push(device); } return { devices: this.devices } } /** * Update on form submit * @param {*} event * @param {*} formData */ async _updateObject(event, formData) { } activateListeners(html) { super.activateListeners(html); html.find("input[name='enable']").on('change', (event) => { const id = event.currentTarget.id.replace('materialDeck_devConf_','');; for (let d of this.devices) { if (d.id == id) { let dConfig = game.settings.get(moduleName, 'devices'); delete dConfig[id]; dConfig[id] = {enable: event.currentTarget.checked} game.settings.set(moduleName, 'devices', dConfig); } } }) } }