Regenerate rule templates (#3197)

This PR depends on https://github.com/openhab/openhab-core/pull/4718.

It does several things, but the major points are:
* It enables regeneration of rules based on rule templates from the UI.
Regeneration can be desirable if the rule template has been updated and
you want the rule(s) to reflect the change, or if you wish to change one
of the parameters, for example to make the rule work with a different
Item.
* It restores the display of read-only/unmanaged rules in the UI. It is
currently broken because all read-only rules are forwarded to the script
editor, effectively hiding the rule editor, unless you modify the URL
manually. This is very relevant for
https://github.com/openhab/openhab-core/pull/4633, whose rules will
otherwise be quite meaningless in the UI (they will still work, but you
won't be able to see what they do from the UI).
* It provides a third tab "Source" to the rule editor that shows the
"source script" used to create the rule, if the rule is supplied via a
JSR223 based scripting add-on, *if* the "source script" is embedded as
metadata with the rule. This is the current practice with most JSR223
based scripting add-on provided rules, and is the only situation where
showing read-only rules in the script editor makes any sense. The
"Source" tab is thus a replacement for removing the forwarding to the
script editor. Rules that have embedded "source scripts" will by default
open in the "Source" tab, so that the difference for these rules will be
minimal, while it also allows other read-only rules to work.

In addition to the above points, there are quite a few bug fixes that I
have come over while debugging/testing this.

There's a lot that could be said about the details here, but I know from
experience that if I attempt to describe everything, people won't read
it. So, I tried to make this description brief, and can instead
elaborate on any subjects on demand.

---------

Signed-off-by: Ravi Nadahar <nadahar@rediffmail.com>
Co-authored-by: Yannick Schaus <github@schaus.net>
main
Nadahar 2025-06-15 19:49:05 +02:00 committed by GitHub
parent f1bc650743
commit 548d1c6d36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 641 additions and 204 deletions

View File

@ -6,6 +6,7 @@
"dialogs.save": "Save",
"dialogs.close": "Close",
"dialogs.copy": "Copy",
"dialogs.create": "Create",
"dialogs.delete": "Delete",
"dialogs.reload": "Reload",
"dialogs.retry": "Try Again",

View File

@ -3,6 +3,7 @@
"HANDLER_INITIALIZING_ERROR": "ERROR: HANDLER",
"HANDLER_MISSING_ERROR": "ERROR: HANDLER",
"TEMPLATE_MISSING_ERROR": "ERROR: TEMPLATE",
"TEMPLATE_PENDING": "TEMPLATE PENDING",
"INVALID_RULE": "INVALID",
"DISABLED": "DISABLED"
}

View File

@ -178,6 +178,7 @@ export default {
// See https://codemirror.net/5/mode/index.html for supported language names & MIME types
if (!mode) return mode
if (mode.indexOf('yaml') >= 0) return 'text/x-yaml'
if (mode === 'application/json' || mode === 'json') return 'application/json'
if (mode.startsWith('application/javascript') || mode === 'js') return 'text/javascript'
if (mode === 'application/vnd.openhab.dsl.rule') return 'text/x-java'
if (mode === 'application/x-groovy' || mode === 'groovy') return 'text/x-groovy'
@ -234,7 +235,7 @@ export default {
onCmReady (cm) {
const self = this
let extraKeys = {}
if (this.mode.indexOf('application/javascript') === 0) {
if (this.mode && this.mode.indexOf('application/javascript') === 0) {
window.tern = tern
if (this.ternAutocompletionHook) {
tern.registerPlugin('openhab-tern-hook', (server, options) => {
@ -281,7 +282,7 @@ export default {
closeOnUnfocus: false,
completeSingle: self.mode && self.mode.indexOf('yaml') > 0,
hint (cm, option) {
if (self.mode.indexOf('application/vnd.openhab.uicomponent') === 0) {
if (self.mode && self.mode.indexOf('application/vnd.openhab.uicomponent') === 0) {
return componentsHint(cm, option, self.mode)
} else if (self.mode === 'application/vnd.openhab.item+yaml') {
return itemsHint(cm, option, self.mode)

View File

@ -151,7 +151,7 @@ import ConfigSheet from '@/components/config/config-sheet.vue'
export default {
mixins: [ModuleWizard],
props: ['currentModule', 'currentModuleType'],
props: ['currentModule', 'currentModuleType', 'moduleTypes'],
components: {
ItemPicker,
ConfigSheet
@ -196,18 +196,16 @@ export default {
},
chooseScriptCategory () {
this.category = 'script'
this.$emit('typeSelect', 'script.ScriptAction')
this.$nextTick(() => {
this.$set(this, 'languages', this.currentModuleType.configDescriptions
.find((c) => c.name === 'type').options
.map((l) => {
let moduleType = this.moduleTypes.find((t) => t.uid === 'script.ScriptAction')
if (moduleType) {
this.$set(this, 'languages', moduleType.configDescriptions.find((c) => c.name === 'type').options.map((l) => {
return {
contentType: l.value,
name: l.label.split(' (')[0],
version: l.label.split(' (')[1].replace(')', '')
}
}))
})
}
},
chooseRulesCategory () {
this.category = 'rules'
@ -272,7 +270,10 @@ export default {
// this.$set(this.currentModule.configuration, 'command', hsb.join(','))
},
scriptLanguagePicked (value) {
this.$emit('typeSelect', 'script.ScriptAction')
this.$nextTick(() => {
this.$emit('startScript', value)
})
},
itemPicked (value) {
this.category = 'item'

View File

@ -129,7 +129,7 @@ import ConfigSheet from '@/components/config/config-sheet.vue'
export default {
mixins: [ModuleWizard],
props: ['currentModule', 'currentModuleType'],
props: ['currentModule', 'currentModuleType', 'moduleTypes'],
components: {
ItemPicker,
ConfigSheet
@ -158,18 +158,16 @@ export default {
},
chooseScriptCategory () {
this.category = 'script'
this.$emit('typeSelect', 'script.ScriptCondition')
this.$nextTick(() => {
this.$set(this, 'languages', this.currentModuleType.configDescriptions
.find((c) => c.name === 'type').options
.map((l) => {
let moduleType = this.moduleTypes.find((t) => t.uid === 'script.ScriptCondition')
if (moduleType) {
this.$set(this, 'languages', moduleType.configDescriptions.find((c) => c.name === 'type').options.map((l) => {
return {
contentType: l.value,
name: l.label.split(' (')[0],
version: l.label.split(' (')[1].replace(')', '')
}
}))
})
}
},
chooseTimeCategory () {
this.category = 'time'
@ -228,7 +226,10 @@ export default {
}
},
scriptLanguagePicked (value) {
this.$emit('typeSelect', 'script.ScriptCondition')
this.$nextTick(() => {
this.$emit('startScript', value)
})
},
itemPicked (value) {
this.category = 'item'

View File

@ -3,19 +3,20 @@
<f7-block v-if="ready" class="block-narrow">
<f7-col>
<f7-list inline-labels no-hairlines-md>
<f7-list-input ref="ruleId" :label="`${type} ID`" type="text" :placeholder="`A unique identifier for the ${type.toLowerCase()}`" :value="rule.uid" required validate
<f7-list-input ref="ruleId" :label="`${type} ID`" type="text" :placeholder="`A unique identifier for the ${type.toLowerCase()}`" :value="rule.uid" required :validate="editable"
:disabled="!createMode" :info="(createMode) ? 'Required. Note: cannot be changed after the creation' : ''" input-id="input"
pattern="[A-Za-z0-9_\-]+" error-message="Required. A-Z,a-z,0-9,_,- only"
@input="rule.uid = $event.target.value" :clear-button="createMode">
<f7-link slot="inner" icon-f7="hammer_fill" style="margin-top: 4px; margin-left: 4px; margin-bottom: auto" tooltip="Fix ID" v-if="createMode && $refs.ruleId?.state?.inputInvalid && rule.uid.trim()" @click="$oh.utils.normalizeInput('#input')" />
</f7-list-input>
<f7-list-input v-if="!createMode && templateName" label="Template" type="text" :value="templateName" disabled />
<f7-list-input label="Label" type="text" :placeholder="`${type} label for display purposes`" :info="(createMode) ? 'Required' : ''" :value="rule.name" required validate
:disabled="!editable" @input="rule.name = $event.target.value" :clear-button="editable" />
<f7-list-input label="Description" type="text" :value="rule.description"
:disabled="!editable" @input="rule.description = $event.target.value" :clear-button="editable" />
</f7-list>
<f7-list inline-labels no-hairlines-md>
<tag-input v-if="!createMode || !hasRuleTemplate" title="Tags" :item="rule" :disabled="!editable" :showSemanticTags="true" :inScriptEditor="inScriptEditor" :inSceneEditor="inSceneEditor" />
<tag-input v-if="!stubMode" title="Tags" :item="rule" :disabled="!editable" :showSemanticTags="true" :inScriptEditor="inScriptEditor" :inSceneEditor="inSceneEditor" />
</f7-list>
</f7-col>
</f7-block>
@ -24,7 +25,7 @@
<f7-block v-else class="block-narrow">
<f7-col class="skeleton-text skeleton-effect-blink">
<f7-list inline-labels no-hairlines-md>
<f7-list-input label="Rule ID" type="text" placeholder="Required" value="_______" required validate
<f7-list-input label="Rule ID" type="text" placeholder="Required" value="_______" required :validate="editable"
:disabled="true" :info="(createMode) ? 'Note: cannot be changed after the creation' : ''"
@input="rule.uid = $event.target.value" :clear-button="createMode" />
<f7-list-input label="Name" type="text" placeholder="Required" required validate
@ -33,7 +34,7 @@
:disabled="true" @input="rule.description = $event.target.value" :clear-button="editable" />
</f7-list>
<f7-list inline-labels no-hairlines-md>
<tag-input v-if="!createMode || !hasRuleTemplate" :item="rule" :disabled="!editable" :showSemanticTags="true" :inScriptEditor="inScriptEditor" :inSceneEditor="inSceneEditor" />
<tag-input v-if="!stubMode" :item="rule" :disabled="!editable" :showSemanticTags="true" :inScriptEditor="inScriptEditor" :inSceneEditor="inSceneEditor" />
</f7-list>
</f7-col>
</f7-block>
@ -44,7 +45,7 @@
import TagInput from '@/components/tags/tag-input.vue'
export default {
props: ['rule', 'ready', 'createMode', 'hasRuleTemplate', 'inScriptEditor', 'inSceneEditor'],
props: ['rule', 'ready', 'createMode', 'stubMode', 'templateName', 'inScriptEditor', 'inSceneEditor'],
components: {
TagInput
},

View File

@ -3,14 +3,15 @@ import RuleStatusLabels from '@/assets/i18n/rule-status/en'
export default {
methods: {
ruleStatusBadgeColor (statusInfo) {
if (statusInfo.status === 'IDLE') return 'green'
if (statusInfo.statusDetail === 'DISABLED') return 'gray'
if (statusInfo.status === 'UNINITIALIZED') return 'red'
if (statusInfo.status === 'INITIALIZING') return 'yellow'
if (statusInfo.status === 'RUNNING') return 'orange'
if (statusInfo?.status === 'IDLE') return 'green'
if (statusInfo?.statusDetail === 'DISABLED') return 'gray'
if (statusInfo?.status === 'UNINITIALIZED') return statusInfo.statusDetail === 'TEMPLATE_PENDING' ? 'orange' : 'red'
if (statusInfo?.status === 'INITIALIZING') return 'yellow'
if (statusInfo?.status === 'RUNNING') return 'orange'
return 'green'
},
ruleStatusBadgeText (statusInfo) {
if (!statusInfo?.status) return ''
if (statusInfo.status === 'IDLE') return 'IDLE'
if (statusInfo.statusDetail !== 'NONE') return RuleStatusLabels[statusInfo.statusDetail]
return statusInfo.status

View File

@ -341,6 +341,12 @@ export default [
beforeLeave: [checkDirtyBeforeLeave],
async: loadAsync(RuleEditPage, { createMode: true })
},
{
path: 'stub',
beforeEnter: [enforceAdminForRoute],
beforeLeave: [checkDirtyBeforeLeave],
async: loadAsync(RuleEditPage, { createMode: false, stubMode: true })
},
{
path: ':ruleId',
beforeEnter: [enforceAdminForRoute],

View File

@ -40,8 +40,10 @@ export default {
switchTab (tab, onSuccessCallback) {
if (this.currentTab !== tab) {
this.currentTab = tab
if (onSuccessCallback) {
onSuccessCallback()
}
}
}
}
}

View File

@ -2,9 +2,30 @@ import cronstrue from 'cronstrue'
export default {
methods: {
findModuleType (mod, section) {
if (!mod || !this.moduleTypes) {
return undefined
}
let result
if (section) {
return this.moduleTypes[section]?.find((m) => m.uid === mod.type)
} else {
if (this.moduleTypes.actions) {
result = this.moduleTypes.actions.find((m) => m.uid === mod.type)
}
if (!result && this.moduleTypes.triggers) {
result = this.moduleTypes.triggers.find((m) => m.uid === mod.type)
}
if (!result && this.moduleTypes.conditions) {
result = this.moduleTypes.conditions.find((m) => m.uid === mod.type)
}
return result
}
},
suggestedModuleTitle (mod, moduleType, section) {
if (!moduleType) {
moduleType = this.moduleTypes[section].find((m) => m.uid === mod.type)
if (!this.moduleTypes) return 'Name'
moduleType = this.findModuleType(mod, section)
if (!moduleType) return 'Name'
}
const config = mod.configuration
@ -93,7 +114,8 @@ export default {
},
suggestedModuleDescription (mod, moduleType, section) {
if (!moduleType) {
moduleType = this.moduleTypes[section].find((m) => m.uid === mod.type)
if (!this.moduleTypes) return 'Description'
moduleType = this.findModuleType(mod, section)
if (!moduleType) return 'Description'
}
const config = mod.configuration

View File

@ -100,6 +100,12 @@ export default {
case 'state':
this.$set(this.rule, 'status', JSON.parse(event.payload)) // e.g. {"status":"RUNNING","statusDetail":"NONE"}
break
case 'added':
case 'updated':
if (!this.dirty) {
this.load()
}
break
}
})
},

View File

@ -1,14 +1,15 @@
<template>
<f7-page @page:afterin="onPageAfterIn" @page:afterout="onPageAfterOut">
<f7-navbar :title="(createMode ? 'Create rule' : rule.name) + dirtyIndicator" back-link="Back" no-hairline>
<f7-navbar :title="(createMode ? (ruleCopy ? 'Duplicate rule' : 'Create rule') : stubMode ? 'Regenerate rule from template' : rule.name) + dirtyIndicator" back-link="Back" no-hairline>
<f7-nav-right>
<developer-dock-icon />
<template v-if="isEditable">
<f7-link @click="save()" v-if="$theme.md" icon-md="material:save" icon-only />
<f7-link @click="save()" v-if="!$theme.md">
Save<span v-if="$device.desktop">&nbsp;(Ctrl-S)</span>
{{ stubMode ? 'Regenerate' : $t(createMode ? 'dialogs.create' : 'dialogs.save') }} <span v-if="$device.desktop">&nbsp;(Ctrl-S)</span>
</f7-link>
</template>
<f7-link v-else icon-f7="lock_fill" icon-only tooltip="This rule is not editable through the UI" />
</f7-nav-right>
</f7-navbar>
<f7-toolbar tabbar position="top">
@ -18,13 +19,17 @@
<f7-link @click="switchTab('code', toYaml)" :tab-link-active="currentTab === 'code'" class="tab-link">
Code
</f7-link>
<f7-link v-if="ready && hasSource" @click="switchTab('source')" :tab-link-active="currentTab === 'source'" class="tab-link">
Source
</f7-link>
</f7-toolbar>
<f7-tabs class="sitemap-editor-tabs">
<f7-tab id="design" @tab:show="() => this.currentTab = 'design'" :tab-active="currentTab === 'design'">
<f7-block v-if="ready && rule.status && (!createMode)" class="block-narrow padding-left padding-right" strong>
<f7-col v-if="!createMode">
<f7-block v-if="ready && rule.status && !createMode && !stubMode" class="block-narrow padding-left padding-right" strong>
<f7-col v-if="!createMode && !stubMode">
<div class="float-right align-items-flex-start align-items-center">
<!-- <f7-toggle class="enable-toggle"></f7-toggle> -->
<f7-link v-if="canRegenerate" :icon-color="'deeppurple'" :tooltip="'Regenerate from template'" icon-md="f7:arrow_2_circlepath" icon-ios="f7:arrow_2_circlepath" icon-aurora="f7:arrow_2_circlepath" icon-size="32" color="deeppurple" @click="regenerateFromTemplate" />
<f7-link :icon-color="(rule.status.statusDetail === 'DISABLED') ? 'orange' : 'gray'" :tooltip="((rule.status.statusDetail === 'DISABLED') ? 'Enable' : 'Disable') + (($device.desktop) ? ' (Ctrl-D)' : '')" icon-ios="f7:pause_circle" icon-md="f7:pause_circle" icon-aurora="f7:pause_circle" icon-size="32" color="orange" @click="toggleDisabled" />
<f7-link :tooltip="'Run Now' + (($device.desktop) ? ' (Ctrl-R)' : '')" icon-ios="f7:play_round" icon-md="f7:play_round" icon-aurora="f7:play_round" icon-size="32" :color="(rule.status.status === 'IDLE') ? 'blue' : 'gray'" @click="runNow" />
</div>
@ -42,7 +47,7 @@
</f7-col>
</f7-block>
<!-- skeletons for not ready -->
<f7-block v-else-if="!createMode" class="block-narrow padding-left padding-right skeleton-text skeleton-effect-blink" strong>
<f7-block v-else-if="!createMode && !stubMode" class="block-narrow padding-left padding-right skeleton-text skeleton-effect-blink" strong>
<f7-col>
______:
<f7-chip class="margin-left" text="________" />
@ -53,15 +58,15 @@
</f7-col>
</f7-block>
<rule-general-settings :rule="rule" :ready="ready" :createMode="createMode" :hasTemplate="hasTemplate" />
<rule-general-settings :rule="rule" :ready="ready" :createMode="createMode" :stubMode="stubMode" :templateName="templateName" />
<f7-block v-if="ready" class="block-narrow">
<f7-block-footer v-if="!isEditable" class="no-margin padding-left">
<f7-icon f7="lock_fill" size="12" color="gray" />&nbsp;Note: this rule is not editable because it has been provisioned from a file.
<f7-icon f7="lock_fill" size="12" color="gray" />&nbsp;Note: this rule is not editable.
</f7-block-footer>
<!-- <f7-col v-if="isEditable" class="text-align-right justify-content-flex-end">
</f7-col> -->
<f7-col v-if="createMode && templates.length > 0" class="new-rule-from-template">
<f7-col v-if="createMode && templates.length > 0 && !ruleCopy" class="new-rule-from-template">
<f7-block-title medium class="margin-bottom">
Create from Template
</f7-block-title>
@ -91,7 +96,50 @@
:parameter-groups="[]" :parameters="currentTemplate.configDescriptions"
:configuration="rule.configuration" />
</f7-col>
<f7-col v-if="!hasTemplate" class="rule-modules">
<f7-col v-else-if="currentTemplate && stubMode" class="show-associated-template">
<f7-block-title medium class="margin-vertical padding-top">
Template
</f7-block-title>
<f7-list media-list>
<f7-list-item :title="currentTemplate.label" :footer="currentTemplate.description" :value="currentTemplate.uid" />
</f7-list>
<f7-block-title medium class="margin-vertical padding-top">
Template Configuration
</f7-block-title>
<f7-link v-if="templateTopicLink" target="_blank" class="external margin-left" color="blue" :href="templateTopicLink">
Template Community Marketplace Topic
</f7-link>
<config-sheet :parameter-groups="[]" :parameters="currentTemplate.configDescriptions" :configuration="rule.configuration" />
</f7-col>
<f7-col v-else-if="currentTemplate && createMode && ruleCopy?.templateUID" class="select-integrate-template">
<f7-block-title medium class="margin-vertical padding-top">
Template
</f7-block-title>
<f7-list media-list>
<f7-list-item
:title="'Keep template: ' + currentTemplate.label"
footer="The rule will still be linked to the template and can be regenerated if the template changes."
:value="currentTemplate.uid"
radio :checked="Boolean(rule.templateUID)" radio-icon="start"
@change="keepTemplate(true)" />
<f7-list-item
title="Integrate template"
footer="Integrates the template in the rule so that the rule is no longer linked to the template."
value="integrate"
radio :checked="!rule.templateUID" radio-icon="start"
@change="keepTemplate(false)" />
</f7-list>
<div v-if="rule.templateUID">
<f7-block-title medium class="margin-vertical padding-top">
Template Configuration
</f7-block-title>
<f7-link v-if="templateTopicLink" target="_blank" class="external margin-left" color="blue" :href="templateTopicLink">
Template Community Marketplace Topic
</f7-link>
<config-sheet :parameter-groups="[]" :parameters="currentTemplate.configDescriptions" :configuration="rule.configuration" />
</div>
</f7-col>
<f7-col v-if="!hasTemplate || (createMode && ruleCopy?.templateUID && !rule.templateUID)" class="rule-modules">
<div v-if="isEditable" class="no-padding float-right">
<f7-button @click="toggleModuleControls" small outline :fill="showModuleControls" sortable-toggle=".sortable" style="margin-top: -3px; margin-right: 5px"
color="gray" icon-size="12" icon-ios="material:wrap_text" icon-md="material:wrap_text" icon-aurora="material:wrap_text">
@ -112,7 +160,7 @@
:title="mod.label || suggestedModuleTitle(mod, null, section)"
:footer="mod.description || suggestedModuleDescription(mod, null, section)"
v-for="mod in rule[section]" :key="mod.id"
:link="isEditable && !showModuleControls"
:link="!showModuleControls && !isOpaqueModule(mod)"
@click.native="(ev) => editModule(ev, section, mod)" swipeout>
<f7-link slot="media" v-if="isEditable" icon-color="red" icon-aurora="f7:minus_circle_filled" icon-ios="f7:minus_circle_filled" icon-md="material:remove_circle_outline" @click="showSwipeout" />
<f7-swipeout-actions right v-if="isEditable">
@ -130,22 +178,46 @@
</f7-list>
</div>
</f7-col>
<f7-col v-if="isEditable && (!createMode)">
<f7-col v-if="!createMode && !stubMode">
<f7-list>
<f7-list-button color="blue" @click="duplicateRule">
<f7-list-button v-if="isEditable || !hasOpaqueModule" color="blue" @click="duplicateRule">
Duplicate Rule
</f7-list-button>
<f7-list-button color="red" @click="deleteRule">
Remove Rule
<f7-list-button v-if="isEditable" color="red" @click="deleteRule">
Delete Rule
</f7-list-button>
</f7-list>
</f7-col>
</f7-block>
</f7-tab>
<f7-tab id="code" @tab:show="() => { this.currentTab = 'code'; toYaml() }" :tab-active="currentTab === 'code'">
<editor v-if="currentTab === 'code'" class="rule-code-editor" mode="application/vnd.openhab.rule+yaml" :value="ruleYaml" @input="onEditorInput" />
<f7-icon v-if="!createMode && !isEditable" f7="lock" class="float-right margin" style="opacity:0.5; z-index: 4000; user-select: none;" size="50" color="gray" tooltip="This code is not editable" />
<editor v-if="currentTab === 'code'" class="rule-code-editor" mode="application/vnd.openhab.rule+yaml" :value="ruleYaml" :readOnly="!isEditable" @input="onEditorInput" />
<!-- <pre class="yaml-message padding-horizontal" :class="[yamlError === 'OK' ? 'text-color-green' : 'text-color-red']">{{yamlError}}</pre> -->
</f7-tab>
<f7-tab v-if="ready && (hasSource)" id="source" @tab:show="() => { this.currentTab = 'source'} " :tab-active="currentTab === 'source'">
<f7-block v-if="ready && !createMode && !stubMode" class="block-narrow padding-left padding-right">
<f7-col>
<div v-if="rule.status" class="left">
Status:
<f7-chip class="margin-left"
:text="rule.status.status"
:color="ruleStatusBadgeColor(rule.status)"
:tooltip="rule.status.statusDetail !== 'NONE' ? rule.status.statusDetail : undefined" />
</div>
<div v-if="sourceType" class="middle source-type-text">
{{ sourceTypeText }}
</div>
<div class="right">
<f7-link v-if="canRegenerate" :icon-color="'deeppurple'" :tooltip="'Regenerate from template'" icon-md="f7:arrow_2_circlepath" icon-ios="f7:arrow_2_circlepath" icon-aurora="f7:arrow_2_circlepath" icon-size="32" color="deeppurple" @click="regenerateFromTemplate" />
<f7-link :icon-color="(rule.status.statusDetail === 'DISABLED') ? 'orange' : 'gray'" :tooltip="((rule.status.statusDetail === 'DISABLED') ? 'Enable' : 'Disable') + (($device.desktop) ? ' (Ctrl-D)' : '')" icon-ios="f7:pause_circle" icon-md="f7:pause_circle" icon-aurora="f7:pause_circle" icon-size="32" color="orange" @click="toggleDisabled" />
<f7-link :tooltip="'Run Now' + (($device.desktop) ? ' (Ctrl-R)' : '')" icon-ios="f7:play_round" icon-md="f7:play_round" icon-aurora="f7:play_round" icon-size="32" :color="(rule.status.status === 'IDLE') ? 'blue' : 'gray'" @click="runNow" />
</div>
</f7-col>
</f7-block>
<f7-icon f7="lock" class="float-right margin" style="opacity:0.5; z-index: 4000; user-select: none;" size="50" color="gray" tooltip="Source code is not editable" />
<editor v-if="currentTab === 'source'" class="rule-source-viewer" :mode="sourceType" :value="source" :readOnly="true" />
</f7-tab>
</f7-tabs>
</f7-page>
</template>
@ -178,7 +250,36 @@
position absolute
top 80%
white-space pre-wrap
#source
.block-narrow
position relative
height var(--f7-toolbar-height)
color var(--f7-block-strong-text-color)
background-color var(--f7-block-strong-bg-color)
.col
display flex
position relative
top 50%
transform translate(0, -50%)
flex-wrap nowrap
justify-content space-between
align-items center
.left
margin-right auto
.middle
position: absolute;
left: 50%;
transform: translate(-50%, 0);
.right
margin-left auto
.rule-source-viewer.vue-codemirror
display block
top calc(var(--f7-navbar-height) + var(--f7-tabbar-height))
height calc(100% - 2*var(--f7-navbar-height) - var(--f7-toolbar-height) - var(--f7-block-margin-vertical) - 1rem)
width 100%
.source-type-text
font-size var(--f7-navbar-subtitle-font-size)
color var(--f7-block-footer-text-color)
</style>
<script>
@ -195,6 +296,7 @@ import DirtyMixin from '../dirty-mixin'
import ConfigSheet from '@/components/config/config-sheet.vue'
import RuleGeneralSettings from '@/components/rule/rule-general-settings.vue'
import AUTOMATION_LANGUAGES from '@/assets/automation-languages'
export default {
mixins: [RuleMixin, ModuleDescriptionSuggestions, RuleStatus, DirtyMixin],
@ -203,7 +305,7 @@ export default {
ConfigSheet,
'editor': () => import(/* webpackChunkName: "script-editor" */ '@/components/config/controls/script-editor.vue')
},
props: ['ruleId', 'createMode', 'ruleCopy', 'schedule'],
props: ['ruleId', 'createMode', 'ruleCopy', 'stubMode', 'schedule'],
data () {
return {
SECTION_LABELS: {
@ -258,24 +360,34 @@ export default {
if (this.loading) return
this.loading = true
const loadModules1 = this.$oh.api.get('/rest/module-types?type=action')
const loadModules2 = this.$oh.api.get('/rest/module-types?type=condition')
const loadModules3 = this.$oh.api.get('/rest/module-types?type=trigger')
const loadingFinished = () => {
this.$nextTick(() => {
this.savedRule = cloneDeep(this.rule)
this.ready = true
this.loading = false
if (!this.createMode && !this.stubMode && this.hasOpaqueModule && this.hasSource) {
this.switchTab('source')
}
})
}
Promise.all([loadModules1, loadModules2, loadModules3]).then((data) => {
this.moduleTypes.actions = data[0]
this.moduleTypes.conditions = data[1]
this.moduleTypes.triggers = data[2]
this.$oh.api.get('/rest/module-types?asMap=true').then((data) => {
this.moduleTypes = data
if (this.createMode) {
const newRule = this.ruleCopy || {
let newRule
if (this.ruleCopy) {
newRule = cloneDeep(this.ruleCopy)
newRule.uid = this.$f7.utils.id()
if (newRule.templateUID) {
newRule.triggers = []
newRule.actions = []
newRule.conditions = []
if (newRule.templateState === 'instantiated') {
newRule.templateState = 'pending'
}
}
} else {
newRule = {
uid: this.$f7.utils.id(),
name: '',
triggers: [],
@ -289,19 +401,64 @@ export default {
status: 'NEW'
}
}
if (this.ruleCopy) newRule.uid = this.$f7.utils.id()
}
this.$set(this, 'rule', newRule)
this.$oh.api.get('/rest/templates').then((data2) => {
this.$set(this, 'templates', data2)
this.$oh.api.get('/rest/templates').then((templateData) => {
this.$set(this, 'templates', templateData)
if (newRule.templateUID) {
const currentTemplate = templateData.find((t) => t.uid === newRule.templateUID) || {
uid: newRule.templateUID,
label: newRule.templateUID
}
this.$set(this, 'currentTemplate', currentTemplate)
}
loadingFinished()
})
// no need for an event source, the rule doesn't exist yet
} else if (this.stubMode) {
if (!this.ruleCopy || !this.ruleCopy.templateUID) {
this.$f7.toast.create({
text: !this.ruleCopy ? 'Failed to create rule stub because there\'s no source rule' : 'Failed to create rule stub because there\'s no template UID',
destroyOnClose: true,
closeTimeout: 4000
}).open()
this.$f7router.back()
}
const ruleStub = this.ruleCopy
ruleStub.triggers = []
ruleStub.actions = []
ruleStub.conditions = []
ruleStub.templateState = 'pending'
this.$set(this, 'rule', ruleStub)
this.$oh.api.get('/rest/templates').then((templateData) => {
this.$set(this, 'templates', templateData)
let template = this.templates.find((t) => t.uid === ruleStub.templateUID)
if (!template) {
this.$f7.toast.create({
text: 'Template "' + ruleStub.templateUID + '" not found',
destroyOnClose: true,
closeTimeout: 4000
}).open()
this.$f7router.back()
}
this.$set(this, 'currentTemplate', template)
loadingFinished()
})
// no need for an event source, we're going to overwrite the existing rule
} else {
this.$oh.api.get('/rest/rules/' + this.ruleId).then((data2) => {
this.$set(this, 'rule', data2)
if (data2.templateUID) {
this.$oh.api.get('/rest/templates').then((templateData) => {
this.$set(this, 'templates', templateData)
if (!this.eventSource) this.startEventSource()
loadingFinished()
})
} else {
if (!this.eventSource) this.startEventSource()
loadingFinished()
}
})
}
})
},
@ -336,6 +493,16 @@ export default {
.replace('/duplicate', '/' + this.rule.uid)
.replace('/schedule/', '/rules/'), { reloadCurrent: true })
this.load()
} else if (this.stubMode) {
this.$f7.toast.create({
text: 'Rule regenerated',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.$f7router.navigate(this.$f7route.url
.replace('/stub', '/' + this.rule.uid)
.replace('/schedule/', '/rules/'), { reloadCurrent: true })
this.load()
} else {
if (!noToast) {
this.$f7.toast.create({
@ -357,6 +524,8 @@ export default {
},
duplicateRule () {
let ruleClone = cloneDeep(this.rule)
ruleClone.name = (ruleClone.name || '') + ' copy'
ruleClone.editable = true
this.$f7router.navigate({
url: '/settings/rules/duplicate'
}, {
@ -365,6 +534,33 @@ export default {
}
})
},
regenerateFromTemplate () {
if (this.isEditable) {
this.createStub()
} else {
this.$oh.api.postPlain('/rest/rules/' + this.rule.uid + '/regenerate').then(() => {
this.$f7.toast.create({
text: 'Rule regenerated from template',
destroyOnClose: true,
closeTimeout: 2000
}).open()
this.load()
}).catch((err) => {
this.$f7.dialog.alert('An error occurred when trying to regenerate rule "' + this.rule.uid + '" from template: ' + err)
})
}
},
createStub () {
let ruleClone = cloneDeep(this.rule)
this.$f7router.navigate({
url: '/settings/rules/stub'
}, {
reloadCurrent: true,
props: {
ruleCopy: ruleClone
}
})
},
runNow () {
if (this.createMode) return
if (this.rule.status.status === 'RUNNING' || this.rule.status.status === 'UNINITIALIZED') {
@ -415,10 +611,43 @@ export default {
}
this.$set(this, 'currentTemplate', this.templates.find((t) => t.uid === uid))
this.rule.templateUID = uid
this.rule.templateState = 'pending'
},
keepTemplate (keep) {
if (!this.ruleCopy) return
let newRule = this.rule
if (keep) {
newRule.triggers = []
newRule.actions = []
newRule.conditions = []
newRule.configuration = this.ruleCopy.configuration
newRule.templateUID = this.ruleCopy.templateUID
newRule.templateState = 'pending'
if (!newRule.tags?.some((t) => t.indexOf('marketplace:') === 0)) {
const tag = this.ruleCopy.tags?.find((t) => t.indexOf('marketplace:') === 0)
if (tag) {
if (!newRule.tags) {
newRule.tags = [tag]
} else {
newRule.tags.push(tag)
}
}
}
} else {
newRule.triggers = this.ruleCopy.triggers
newRule.actions = this.ruleCopy.actions
newRule.conditions = this.ruleCopy.conditions
newRule.configuration = {}
newRule.templateUID = null
newRule.templateState = 'no-template'
if (newRule.tags) {
newRule.tags = newRule.tags.filter((t) => t.indexOf('marketplace:') !== 0)
}
}
this.$set(this, 'rule', newRule)
},
editModule (ev, section, mod) {
if (this.showModuleControls) return
if (!this.isEditable) return
if (this.showModuleControls || this.isOpaqueModule(mod)) return
let swipeoutElement = ev.target
ev.cancelBubble = true
while (!swipeoutElement.classList.contains('swipeout')) {
@ -452,15 +681,18 @@ export default {
currentSection: this.currentSection,
ruleModule: this.currentModule,
ruleModuleType: this.currentModuleType,
moduleTypes: this.moduleTypes
moduleTypes: this.moduleTypes,
readOnly: !this.isEditable
}
})
if (this.isEditable) {
this.$f7.once('ruleModuleConfigUpdate', this.saveModule)
this.$f7.once('ruleModuleConfigClosed', () => {
this.$f7.off('ruleModuleConfigUpdate', this.saveModule)
this.moduleConfigClosed()
})
}
},
deleteModule (ev, section, mod) {
let swipeoutElement = ev.target
@ -564,7 +796,7 @@ export default {
triggers: this.rule.triggers,
conditions: this.rule.conditions,
actions: this.rule.actions
})
}, this.isEditable ? undefined : this.replacer)
},
fromYaml () {
if (!this.isEditable) return
@ -579,11 +811,83 @@ export default {
this.$f7.dialog.alert(e).open()
return false
}
},
/**
* Replaces CRLF (Windows) or CR (Mac) with LF in scripts before YAMLification.
*
* @param key the key being processed
* @param value the value being processed
*/
replacer (key, value) {
switch (key) {
case 'script':
return value ? value.replaceAll(/(\r\n|\r)/g, '\n') : value
default:
return value
}
},
/**
* Determines if the module is "opaque" in that it doesn't actually execute the content of the module, but instead executes
* a referenced in-memory runnable method.
*
* @param module the module to evaluate
*/
isOpaqueModule (module) {
if (!module?.type) return false
return module.type === 'jsr223.ScriptedAction' || module.type === 'jsr223.ScriptedCondition' || module.type === 'jsr223.ScriptedTrigger'
}
},
computed: {
hasTemplate () {
return this.rule && this.currentTemplate !== null
return this.rule && (this.stubMode || this.currentTemplate !== null)
},
templateName () {
if (!this.rule || !this.rule.templateUID || !this.templates) {
return undefined
}
let result = this.templates.find((t) => t.uid === this.rule.templateUID)
return result ? result.label : this.rule.templateUID
},
canRegenerate () {
if (!this.rule || !this.rule.templateUID || !this.rule.templateState || this.rule.templateState === 'no-template' || this.rule.templateState === 'template-missing') {
return false
}
return this.templates ? this.templates.some((t) => t.uid === this.rule.templateUID) : false
},
hasOpaqueModule () {
if (!this.rule) return false
return [...this.rule.actions || [], this.rule.triggers || [], this.rule.conditions || []].some((m) => this.isOpaqueModule(m))
},
hasSource () {
let sourceContainer = this.sourceSource
return sourceContainer ? sourceContainer.source || sourceContainer.script : false
},
source () {
let sourceContainer = this.sourceSource
if (!sourceContainer) return ''
return sourceContainer.source || sourceContainer.script || ''
},
sourceTypeText () {
let result = this.sourceType
return result ? AUTOMATION_LANGUAGES[result]?.name || result : result
},
sourceType () {
let sourceContainer = this.sourceSource
return sourceContainer ? sourceContainer.sourceType || sourceContainer.type : undefined
},
sourceSource () {
if (!this.rule) return undefined
if (this.rule.configuration?.source) {
return this.rule.configuration
}
if (this.rule.actions?.length) {
for (const action of this.rule.actions) {
if (this.isOpaqueModule(action)) {
return action.configuration
}
}
}
return undefined
},
templateTopicLink () {
if (!this.currentTemplate) return null

View File

@ -8,12 +8,18 @@
<f7-nav-title v-if="ruleModule && ruleModule.new">
Add {{ SectionLabels[currentSection][1] }}
</f7-nav-title>
<f7-nav-title v-else-if="readOnly">
View {{ SectionLabels[currentSection][1] }}
</f7-nav-title>
<f7-nav-title v-else>
Edit {{ SectionLabels[currentSection][1] }}
</f7-nav-title>
<f7-nav-right>
<f7-link v-show="currentRuleModuleType && currentRuleModuleType.uid !== 'script.ScriptAction'" @click="updateModuleConfig">
Save
<f7-link v-if="!readOnly && currentRuleModuleType && dirty" @click="updateModuleConfig">
{{ $t('dialogs.save') }}
</f7-link>
<f7-link v-else @click="close">
{{ $t('dialogs.close') }}
</f7-link>
</f7-nav-right>
</f7-navbar>
@ -21,9 +27,9 @@
<f7-col v-if="currentRuleModuleType" class="margin-top">
<f7-list inline-labels no-hairlines-md class="no-margin">
<f7-list-input type="text" :placeholder="moduleTitleSuggestion" :value="ruleModule.label" required
@input="ruleModule.label = $event.target.value" clear-button />
@input="ruleModule.label = $event.target.value" :disabled="readOnly" clear-button />
<f7-list-input type="text" :placeholder="moduleDescriptionSuggestion" :value="ruleModule.description"
@input="ruleModule.description = $event.target.value" clear-button />
@input="ruleModule.description = $event.target.value" :disabled="readOnly" clear-button />
</f7-list>
</f7-col>
<!-- <f7-block-footer class="no-margin padding-left"><small>Tip: leave fields blank to set automatically to the suggested name and description. <f7-link @click="ruleModule.label = null; ruleModule.description = null">Clear</f7-link></small></f7-block-footer> -->
@ -44,14 +50,14 @@
</ul>
</f7-list>
<trigger-module-wizard v-else-if="!advancedTypePicker && currentSection === 'triggers'" :current-module="ruleModule" :current-module-type="currentRuleModuleType" @typeSelect="setModuleType" @showAdvanced="advancedTypePicker = true" />
<condition-module-wizard v-else-if="!advancedTypePicker && currentSection === 'conditions'" :current-module="ruleModule" :current-module-type="currentRuleModuleType" @typeSelect="setModuleType" @showAdvanced="advancedTypePicker = true" @startScript="startScripting" />
<action-module-wizard v-else-if="!advancedTypePicker && currentSection === 'actions'" :current-module="ruleModule" :current-module-type="currentRuleModuleType" @typeSelect="setModuleType" @showAdvanced="advancedTypePicker = true" @startScript="startScripting" />
<condition-module-wizard v-else-if="!advancedTypePicker && currentSection === 'conditions'" :current-module="ruleModule" :current-module-type="currentRuleModuleType" :module-types="moduleTypes['conditions']" @typeSelect="setModuleType" @showAdvanced="advancedTypePicker = true" @startScript="startScripting" />
<action-module-wizard v-else-if="!advancedTypePicker && currentSection === 'actions'" :current-module="ruleModule" :current-module-type="currentRuleModuleType" :module-types="moduleTypes['actions']" @typeSelect="setModuleType" @showAdvanced="advancedTypePicker = true" @startScript="startScripting" />
</f7-col>
<!-- module configuration -->
<f7-col v-if="ruleModule.type && (!ruleModule.new || advancedTypePicker)" class="margin-top">
<f7-list>
<f7-list-item :title="SectionLabels[currentSection][0]" ref="ruleModuleTypeSmartSelect" smart-select :smart-select-params="{ view: $f7.views.main, openIn: 'popup', closeOnSelect: true }">
<f7-list-item :disabled="readOnly" :title="SectionLabels[currentSection][0]" ref="ruleModuleTypeSmartSelect" smart-select :smart-select-params="{ view: $f7.views.main, openIn: 'popup', closeOnSelect: true }">
<select name="ruleModuleType"
@change="setModuleType(moduleTypes[currentSection].find((t) => t.uid === $refs.ruleModuleTypeSmartSelect.f7SmartSelect.getValue()), true)">
<optgroup v-for="(mt, scope) in groupedModuleTypes(currentSection)" :key="scope" :label="scope">
@ -74,6 +80,7 @@
:parameterGroups="[]"
:parameters="currentRuleModuleType.configDescriptions"
:configuration="ruleModule.configuration"
:readOnly="readOnly"
@updated="dirty = true" />
<f7-block v-else>
<f7-button @click="editBlockly" color="blue" outline fill>
@ -105,7 +112,7 @@ export default {
ActionModuleWizard,
ConfigSheet
},
props: ['rule', 'ruleModule', 'ruleModuleType', 'moduleTypes', 'currentSection'],
props: ['rule', 'ruleModule', 'ruleModuleType', 'moduleTypes', 'currentSection', 'readOnly'],
data () {
return {
currentRuleModuleType: this.ruleModuleType,
@ -187,6 +194,9 @@ export default {
} else {
this.$refs.modulePopup.close()
}
},
close () {
this.$refs.modulePopup.close()
}
},
created () {

View File

@ -20,8 +20,8 @@
</f7-subnavbar>
</f7-navbar>
<f7-toolbar class="contextual-toolbar" :class="{ 'navbar': $theme.md }" v-if="showCheckboxes" bottom-ios bottom-aurora>
<f7-link color="red" v-show="selectedItems.length" v-if="!$theme.md" class="delete" icon-ios="f7:trash" icon-aurora="f7:trash" @click="removeSelected">
Remove {{ selectedItems.length }}
<f7-link color="red" v-show="selectedDeletableItems.length" v-if="!$theme.md" class="delete" icon-ios="f7:trash" icon-aurora="f7:trash" @click="removeSelected">
Remove {{ selectedDeletableItems.length }}
</f7-link>
<f7-link color="orange" v-show="selectedItems.length" v-if="!$theme.md && !showScenes" class="disable" @click="doDisableEnableSelected(false)" icon-ios="f7:pause_circle" icon-aurora="f7:pause_circle">
&nbsp;Disable {{ selectedItems.length }}
@ -29,14 +29,18 @@
<f7-link color="green" v-show="selectedItems.length" v-if="!$theme.md && !showScenes" class="enable" @click="doDisableEnableSelected(true)" icon-ios="f7:play_circle" icon-aurora="f7:play_circle">
&nbsp;Enable {{ selectedItems.length }}
</f7-link>
<!-- <f7-link color="deeppurple" v-show="selectedItems.length === 1 && canRegenerateItem(selectedItems[0])" v-if="!$theme.md && !showScenes" class="enable" @click="regenerateSelected()" icon-ios="f7:arrow_2_circlepath" icon-aurora="f7:arrow_2_circlepath">
&nbsp;Regenerate from template
</f7-link> -->
<f7-link v-if="$theme.md" icon-md="material:close" icon-color="white" @click="showCheckboxes = false" />
<div class="title" v-if="$theme.md">
{{ selectedItems.length }} selected
</div>
<div class="right" v-if="$theme.md">
<f7-link v-if="!showScenes" v-show="selectedItems.length === 1 && canRegenerateItem(selectedItems[0])" tooltip="Regenerate selected from template" icon-md="material:autorenew" icon-color="white" @click="regenerateSelected()" />
<f7-link v-if="!showScenes" v-show="selectedItems.length" tooltip="Disable selected" icon-md="material:pause_circle_outline" icon-color="white" @click="doDisableEnableSelected(false)" />
<f7-link v-if="!showScenes" v-show="selectedItems.length" tooltip="Enable selected" icon-md="material:play_circle_outline" icon-color="white" @click="doDisableEnableSelected(true)" />
<f7-link v-show="selectedItems.length" icon-md="material:delete" icon-color="white" @click="removeSelected" />
<f7-link v-show="selectedDeletableItems.length" icon-md="material:delete" icon-color="white" @click="removeSelected" />
</div>
</f7-toolbar>
@ -130,7 +134,10 @@
:footer="rule.description"
:badge="showScenes ? '' : ruleStatusBadgeText(ruleStatuses[rule.uid])"
:badge-color="ruleStatusBadgeColor(ruleStatuses[rule.uid])">
<div slot="footer">
<div slot="footer" class="footer-inner">
<f7-chip v-if="rule.templateUID" :text="templateName(rule)" media-bg-color="orange" style="margin-right: 2px">
<f7-icon slot="media" ios="f7:doc_on_doc_fill" md="material:file_copy" aurora="f7:doc_on_doc_fill" />
</f7-chip>
<f7-chip v-for="tag in rule.tags.filter((t) => t !== 'Script' && t !== 'Scene')" :key="tag" :text="tag" media-bg-color="blue" style="margin-right: 6px">
<f7-icon slot="media" ios="f7:tag_fill" md="material:label" aurora="f7:tag_fill" />
</f7-chip>
@ -150,6 +157,14 @@
</f7-page>
</template>
<style lang="stylus">
.item-footer
margin-block-start 4px
margin-block-end 2px
.footer-inner
margin-block-start 2px
</style>
<script>
import RuleStatus from '@/components/rule/rule-status-mixin'
@ -170,8 +185,10 @@ export default {
uniqueTags: [],
selectedTags: [],
selectedItems: [],
selectedDeletableItems: [],
showCheckboxes: false,
eventSource: null
eventSource: null,
templates: null
}
},
computed: {
@ -218,6 +235,7 @@ export default {
this.initSearchbar = false
this.$set(this, 'selectedItems', [])
this.$set(this, 'selectedDeletableItems', [])
this.showCheckboxes = false
let filter = ''
if (this.showScripts) {
@ -227,20 +245,30 @@ export default {
filter = '&tags=Scene'
}
this.$oh.api.get('/rest/rules?summary=true' + filter).then(data => {
this.rules = data.sort((a, b) => {
const promises = [this.$oh.api.get('/rest/templates'), this.$oh.api.get('/rest/rules?summary=true' + filter)]
Promise.allSettled(promises).then((results) => {
const templateData = results[0]
const ruleData = results[1]
if (templateData.status === 'fulfilled') {
this.$set(this, 'templates', templateData.value)
} else {
console.warn('Failed to retrieve rule templates. Status: "' + templateData.status + '", Reason: "' + templateData.reason + '"')
}
if (ruleData.status === 'fulfilled') {
let rules = ruleData.value.sort((a, b) => {
return a.name.localeCompare(b.name)
})
if (!this.showScripts) {
this.rules = this.rules.filter((r) => !r.tags || r.tags.indexOf('Script') < 0)
rules = rules.filter((r) => !r.tags || r.tags.indexOf('Script') < 0)
}
if (!this.showScenes) {
this.rules = this.rules.filter((r) => !r.tags || r.tags.indexOf('Scene') < 0)
rules = rules.filter((r) => !r.tags || r.tags.indexOf('Scene') < 0)
}
this.$set(this, 'rules', rules)
this.rules.forEach(rule => {
rules.forEach(rule => {
this.ruleStatuses[rule.uid] = rule.status
rule.tags.forEach(t => {
@ -255,6 +283,7 @@ export default {
this.loading = false
this.ready = true
this.noRuleEngine = false
this.$nextTick(() => {
if (this.$refs.listIndex) this.$refs.listIndex.update()
@ -265,10 +294,17 @@ export default {
})
if (!this.eventSource) this.startEventSource()
}).catch((err, status) => {
if (err === 'Not Found' || status === 404) {
} else {
console.warn('Failed to retrieve rule templates. Status: "' + ruleData.status + '", Reason: "' + ruleData.reason + '"')
if (ruleData.reason === 'Not Found') {
this.noRuleEngine = true
}
this.loading = false
let self = this
setTimeout(() => {
self.load()
}, 2000)
}
})
},
startEventSource () {
@ -306,7 +342,7 @@ export default {
if (this.showCheckboxes) {
this.toggleItemCheck(event, item.uid, item)
} else {
this.$f7router.navigate((item.editable) ? item.uid : '/settings/scripts/' + item.uid)
this.$f7router.navigate(item.uid)
}
},
ctrlClick (event, item) {
@ -317,15 +353,23 @@ export default {
if (!this.showCheckboxes) this.showCheckboxes = true
if (this.isChecked(item)) {
this.selectedItems.splice(this.selectedItems.indexOf(item), 1)
let idx = this.selectedDeletableItems.indexOf(item)
if (idx >= 0) {
this.selectedDeletableItems.splice(idx, 1)
}
} else {
this.selectedItems.push(item)
const rule = this.rules.find((r) => r.uid === item)
if (rule?.editable) {
this.selectedDeletableItems.push(item)
}
}
},
removeSelected () {
const vm = this
this.$f7.dialog.confirm(
`Remove ${this.selectedItems.length} selected rules?`,
`Remove ${this.selectedDeletableItems.length} rules?`,
'Remove Rules',
() => {
vm.doRemoveSelected()
@ -333,14 +377,9 @@ export default {
)
},
doRemoveSelected () {
if (this.selectedItems.some((i) => this.rules.find((rule) => rule.uid === i).editable === false)) {
this.$f7.dialog.alert('Some of the selected rules are not modifiable because they have been provisioned by files')
return
}
let dialog = this.$f7.dialog.progress('Deleting Rules...')
const promises = this.selectedItems.map((i) => this.$oh.api.delete('/rest/rules/' + i))
const promises = this.selectedDeletableItems.map((i) => this.$oh.api.delete('/rest/rules/' + i))
Promise.all(promises).then((data) => {
this.$f7.toast.create({
text: 'Rules removed',
@ -348,6 +387,7 @@ export default {
closeTimeout: 2000
}).open()
this.selectedItems = []
this.selectedDeletableItems = []
dialog.close()
this.load()
}).catch((err) => {
@ -377,6 +417,39 @@ export default {
this.$f7.dialog.alert('An error occurred while enabling/disabling: ' + err)
})
},
regenerateSelected () {
if (!this.selectedItems || this.selectedItems.length !== 1) {
return
}
let selectedRule = this.rules.find((rule) => rule.uid === this.selectedItems[0])
if (!selectedRule) {
return
}
if (selectedRule.editable) {
this.$oh.api.get('/rest/rules/' + this.selectedItems[0]).then((rule) => {
this.$f7router.navigate({
url: '/settings/rules/stub'
}, {
reloadCurrent: false,
props: {
ruleCopy: rule
}
})
}).catch((err) => {
this.$f7.dialog.alert('An error occurred when retrieving rule "' + this.selectedItems[0] + '": ' + err)
})
} else {
this.$oh.api.postPlain('/rest/rules/' + this.selectedItems[0] + '/regenerate').then(() => {
this.$f7.toast.create({
text: 'Rule regenerated from template',
destroyOnClose: true,
closeTimeout: 2000
}).open()
}).catch((err) => {
this.$f7.dialog.alert('An error occurred when trying to regenerate rule "' + this.selectedItems[0] + '" from template: ' + err)
})
}
},
toggleSearchTag (e, item) {
const idx = this.selectedTags.indexOf(item)
if (idx !== -1) {
@ -389,6 +462,23 @@ export default {
},
isTagSelected (tag) {
return this.selectedTags.includes(tag)
},
templateName (rule) {
let template = this.templates ? this.templates.find((t) => t.uid === rule.templateUID) : undefined
return template ? template.label : rule.templateUID
},
canRegenerateItem (item) {
if (!this.rules) {
return false
}
let rule = this.rules.find((r) => r.uid === item)
return rule ? this.canRegenerate(rule) : false
},
canRegenerate (rule) {
if (!rule || !rule.templateUID || !rule.templateState || rule.templateState === 'no-template' || rule.templateState === 'template-missing') {
return false
}
return this.templates ? this.templates.some((t) => t.uid === rule.templateUID) : false
}
}
}

View File

@ -15,6 +15,7 @@
Create
</f7-link>
</template>
<f7-link v-if="!editable" icon-f7="lock_fill" icon-only tooltip="This script is not editable through the UI" />
</f7-nav-right>
</f7-navbar>
@ -75,7 +76,7 @@
</f7-toolbar>
<f7-icon v-if="!createMode && (!isBlockly && !editable) || (blocklyCodePreview && isBlockly)" f7="lock" class="float-right margin" style="opacity:0.5; z-index: 4000; user-select: none;" size="50" color="gray"
:tooltip="(isBlockly) ? 'Cannot edit the code generated by Blockly' : 'This rule is not editable because it has been provisioned from a file'" />
:tooltip="(isBlockly) ? 'Cannot edit the code generated by Blockly' : 'This code is not editable'" />
<editor v-if="!createMode && (!isBlockly || blocklyCodePreview)" class="rule-script-editor" :mode="mode" :value="script" @input="onEditorInput" :read-only="isBlockly || !editable" :tern-autocompletion-hook="true" />
<blockly-editor ref="blocklyEditor" v-else-if="!createMode && isBlockly" :blocks="currentModule.configuration.blockSource" @change="scriptDirty = true" @mounted="onBlocklyMounted" @ready="onBlocklyReady" />
<script-general-settings v-else-if="createMode" :createMode="true" :rule="rule" />
@ -127,7 +128,7 @@
</f7-link>
</div>
</f7-toolbar>
<script-general-settings class="margin-top" :createMode="createMode" :rule="rule" :module="currentModule" :module-type="scriptModuleType" :module-types="moduleTypes" :isScriptRule="isScriptRule" :mode="mode" :languages="languages" @newLanguage="changeLanguage" />
<script-general-settings class="margin-top" :createMode="createMode" :rule="rule" :module="currentModule" :module-type="scriptModuleType" :isScriptRule="isScriptRule" :mode="mode" :languages="languages" @newLanguage="changeLanguage" />
<f7-block class="block-narrow" v-if="editable && isScriptRule">
<f7-col>
<f7-list>
@ -192,12 +193,6 @@ export default {
mode: '',
savedMode: '',
moduleTypes: {
actions: [],
conditions: [],
triggers: []
},
currentModuleConfig: {},
scriptModuleType: null,
languages: null,
@ -216,7 +211,7 @@ export default {
if (this.createMode) return 'Create Script'
if (this.isScriptRule) return this.rule.name
if (this.currentModule) {
let title = 'Edit'
let title = this.editable ? 'Edit' : 'View'
switch (this.currentModule.type) {
case 'script.ScriptAction':
case 'script.ScriptCondition':
@ -229,7 +224,7 @@ export default {
}
return title
}
return 'Edit Script'
return this.editable ? 'Edit Script' : 'View Script'
},
editable () {
return this.rule && this.rule.editable !== false
@ -351,7 +346,7 @@ export default {
if (this.ruleCopy) this.rule.uid = this.$f7.utils.id()
this.savedRule = cloneDeep(this.rule)
this.savedMode = this.mode = 'application/javascript+blockly'
this.loadScriptModuleTypes().then(() => {
this.loadScriptModuleType().then(() => {
this.ready = true
})
},
@ -395,17 +390,17 @@ export default {
return this.languages.map(l => l.contentType).includes(mimeType)
},
mimeTypeDescription (mode) {
return AUTOMATION_LANGUAGES[mode]?.name || mode
return mode ? AUTOMATION_LANGUAGES[mode]?.name || mode : mode
},
documentationLink (mode) {
return AUTOMATION_LANGUAGES[mode]?.documentationLink
return mode ? AUTOMATION_LANGUAGES[mode]?.documentationLink : mode
},
/**
* Load the script module type, i.e. the available script languages
* @returns {Promise}
*/
loadScriptModuleTypes () {
return this.$oh.api.get('/rest/module-types/script.ScriptAction').then((data) => {
loadScriptModuleType () {
return this.$oh.api.get('/rest/module-types/' + (this.currentModule?.type ? this.currentModule.type : 'script.ScriptAction')).then((data) => {
this.$set(this, 'scriptModuleType', data)
let languages = this.scriptModuleType.configDescriptions
.find((c) => c.name === 'type').options
@ -425,10 +420,8 @@ export default {
if (this.loading) return
this.loading = true
Promise.all([this.$oh.api.get('/rest/module-types?type=trigger'), this.$oh.api.get('/rest/module-types?type=condition'), this.$oh.api.get('/rest/rules/' + this.ruleId)]).then((data) => {
this.$set(this.moduleTypes, 'triggers', data[0])
this.$set(this.moduleTypes, 'conditions', data[1])
this.$set(this, 'rule', data[2])
this.$oh.api.get('/rest/rules/' + this.ruleId).then((data) => {
this.$set(this, 'rule', data)
if (this.moduleId) {
this.$set(this, 'currentModule', this.rule.actions.concat(this.rule.conditions).find((m) => m.id === this.moduleId))
@ -441,34 +434,7 @@ export default {
this.initDirty()
if (!this.rule.editable) {
const commentChar = AUTOMATION_LANGUAGES[this.mode]?.commentChar
let preamble = `${commentChar} Triggers:\n`
for (const trigger of this.rule.triggers) {
const triggerModuleType = this.moduleTypes.triggers.find((t) => t.uid === trigger.type)
let description = trigger.label || this.suggestedModuleTitle(trigger, triggerModuleType, 'trigger')
if (triggerModuleType.uid === 'timer.GenericCronTrigger') {
description = description.charAt(0).toUpperCase() + description.slice(1)
} else {
description = 'When ' + description
}
preamble += `${commentChar} - ${description}\n`
}
if (this.rule.conditions.length > 0) {
preamble += `\n${commentChar} Conditions:\n`
for (const condition of this.rule.conditions) {
const conditionModuleType = this.moduleTypes.conditions.find((t) => t.uid === condition.type)
let description = condition.label || this.suggestedModuleTitle(condition, conditionModuleType, 'condition')
description = 'Only If ' + description
preamble += `${commentChar} - ${description}\n`
}
}
this.script = preamble + '\n' + this.script
}
this.loadScriptModuleTypes().then(() => {
this.loadScriptModuleType().then(() => {
if (this.rule.editable && this.mode === 'application/javascript;version=ECMAScript-2021') {
const message = 'Your JavaScript script was created with a previous version of openHAB. Please save your script.'
@ -697,6 +663,12 @@ export default {
case 'state':
this.$set(this.rule, 'status', JSON.parse(event.payload)) // e.g. {"status":"RUNNING","statusDetail":"NONE"}
break
case 'added':
case 'updated':
if (!this.dirty) {
this.load()
}
break
}
})
},

View File

@ -5,13 +5,13 @@
<f7-col v-if="!createMode && languages">
<f7-list inline-labels style="margin-top: 0">
<template v-if="module && !isScriptRule">
<f7-list-input :label="scriptType + ' Title'" type="text" :value="module.label" :placeholder="suggestedModuleTitle(module, moduleType)" @input="$set(module, 'label', $event.target.value)" :disabled="!editable" :clear-button="editable" />
<f7-list-input label="Description" type="text" :value="module.description" :placeholder="suggestedModuleDescription(module, moduleType)" @input="$set(module, 'description', $event.target.value)" :disabled="!editable" :clear-button="editable" />
<f7-list-input :label="scriptType + ' Title'" type="text" :value="moduleTitle" :placeholder="sugModuleTitle" @input="$set(module, 'label', $event.target.value)" :disabled="!editable" :clear-button="editable" />
<f7-list-input label="Description" type="text" :value="moduleDescription" :placeholder="sugModuleDescription" @input="$set(module, 'description', $event.target.value)" :disabled="!editable" :clear-button="editable" />
</template>
<f7-list-item title="Scripting Language" class="aligned-smart-select" :disabled="!editable" :key="mode" smart-select :smart-select-params="{openIn: 'sheet', closeOnSelect: true}">
<select @change="$emit('newLanguage', $event.target.value)">
<option v-if="!languages.map(l => l.contentType).includes(mode)" :key="mode" :value="mode" selected="true">
{{ mode }} (not installed)
{{ mode ? mode + ' (not installed)' : 'Unknown' }}
</option>
<option v-for="language in languages" :key="language.contentType" :value="language.contentType" :selected="language.contentType === mode">
{{ language.name }} ({{ language.version }})
@ -30,20 +30,38 @@ import ModuleDescriptionSuggestions from '../module-description-suggestions'
export default {
mixins: [ModuleDescriptionSuggestions],
props: ['rule', 'module', 'moduleType', 'moduleTypes', 'createMode', 'isScriptRule', 'languages', 'mode'],
props: ['rule', 'module', 'moduleType', 'createMode', 'isScriptRule', 'languages', 'mode'],
emits: ['newLanguage'],
components: {
RuleGeneralSettings
},
computed: {
moduleTitle () {
return this.editable || this.module?.label ? this.module.label : this.sugModuleTitle
},
sugModuleTitle () {
return this.suggestedModuleTitle(this.module, this.moduleType)
},
moduleDescription () {
return this.editable || this.module?.description ? this.module.description : this.sugModuleDescription
},
sugModuleDescription () {
return this.suggestedModuleDescription(this.module, this.moduleType)
},
editable () {
return this.createMode || (this.rule && this.rule.editable)
},
scriptType () {
switch (this.module.type) {
case 'script.ScriptAction':
case 'jsr223.ScriptedAction':
return 'Action'
case 'script.ScriptCondition':
return this.module.type.slice('script.Script'.length)
case 'jsr223.ScriptedCondition':
return 'Condition'
case 'script.ScriptTrigger':
case 'jsr223.ScriptedTrigger':
return 'Trigger'
default:
return 'Module'
}