Skip to content

Commit 3d03cb7

Browse files
Suncussclaude
andcommitted
feat: notification service editing with round-trip URL validation
- Add edit support for notification services with structured form UI - Add round-trip URL verification: fall back to custom editor when parsers lose query params or produce different URLs - Add duplicate URL deduplication on save - Update changelog Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent e9e68d2 commit 3d03cb7

5 files changed

Lines changed: 215 additions & 68 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [Unreleased]
44

5+
- Improved notification URL editing: round-trip verification falls back to custom editor when parsers lose query params, and duplicate URLs are deduplicated on save
56
- Added global recorder health warning pill — amber FAB appears on all pages (except Settings) when audio sources are degraded or stopped, with 24-hour dismiss and priority over the update indicator
67
- Added recorder status to stream config API response for REST-based health checks
78
- Added auto-expanded error details on Settings page when audio sources have issues

frontend/src/components/AddNotificationModal.vue

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
<!-- Back + title -->
6666
<div class="flex items-center gap-2 mb-4">
6767
<button
68+
v-if="!isEditing"
6869
class="text-gray-400 hover:text-gray-600"
6970
@click="goBack"
7071
>
@@ -83,7 +84,7 @@
8384
</svg>
8485
</button>
8586
<h3 class="text-lg font-semibold text-gray-900">
86-
{{ selectedService.label }}
87+
{{ isEditing ? 'Edit' : '' }} {{ selectedService.label }}
8788
</h3>
8889
</div>
8990

@@ -127,20 +128,29 @@
127128

128129
<!-- Actions -->
129130
<div class="flex items-center justify-between pt-2">
130-
<a
131-
v-if="selectedService.helpUrl"
132-
:href="selectedService.helpUrl"
133-
target="_blank"
134-
rel="noopener noreferrer"
135-
class="text-xs text-blue-500 hover:underline"
136-
>Setup guide</a>
137-
<span v-else />
131+
<div>
132+
<button
133+
v-if="isEditing"
134+
type="button"
135+
class="text-xs text-red-500 hover:text-red-700 transition-colors"
136+
@click="$emit('delete')"
137+
>
138+
Remove service
139+
</button>
140+
<a
141+
v-else-if="selectedService.helpUrl"
142+
:href="selectedService.helpUrl"
143+
target="_blank"
144+
rel="noopener noreferrer"
145+
class="text-xs text-blue-500 hover:underline"
146+
>Setup guide</a>
147+
</div>
138148
<button
139149
type="submit"
140150
:disabled="!canSubmit || testing"
141151
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:bg-gray-400"
142152
>
143-
{{ testing ? 'Testing...' : 'Test & Add' }}
153+
{{ testing ? 'Testing...' : (isEditing ? 'Test & Save' : 'Test & Add') }}
144154
</button>
145155
</div>
146156
</form>
@@ -152,7 +162,7 @@
152162

153163
<script>
154164
import { ref, computed } from 'vue'
155-
import { SERVICES } from '@/utils/notificationServices'
165+
import { SERVICES, parseAppriseUrl } from '@/utils/notificationServices'
156166
import api from '@/services/api'
157167
158168
// SVG path data for each service icon (Heroicons outline)
@@ -167,7 +177,13 @@ const SERVICE_ICONS = {
167177
168178
export default {
169179
name: 'AddNotificationModal',
170-
emits: ['close', 'add'],
180+
props: {
181+
editUrl: {
182+
type: String,
183+
default: null
184+
}
185+
},
186+
emits: ['close', 'add', 'save', 'delete'],
171187
setup(props, { emit }) {
172188
const services = SERVICES
173189
const selectedService = ref(null)
@@ -176,9 +192,16 @@ export default {
176192
const error = ref('')
177193
const testSuccess = ref('')
178194
195+
const isEditing = computed(() => !!props.editUrl)
196+
197+
if (props.editUrl) {
198+
const parsed = parseAppriseUrl(props.editUrl)
199+
selectedService.value = parsed.service
200+
formValues.value = parsed.values || {}
201+
}
202+
179203
const selectService = (service) => {
180204
selectedService.value = service
181-
// Initialize form values
182205
const values = {}
183206
for (const field of service.fields) {
184207
values[field.key] = ''
@@ -221,7 +244,7 @@ export default {
221244
try {
222245
const { data } = await api.post('/notifications/test', { apprise_url: url })
223246
testSuccess.value = data.message || 'Test notification sent!'
224-
emit('add', url)
247+
emit(isEditing.value ? 'save' : 'add', url)
225248
} catch (err) {
226249
error.value = err.response?.data?.error || 'Failed to send test notification.'
227250
} finally {
@@ -243,6 +266,7 @@ export default {
243266
testing,
244267
error,
245268
testSuccess,
269+
isEditing,
246270
canSubmit,
247271
selectService,
248272
goBack,

frontend/src/utils/notificationServices.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
const enc = encodeURIComponent
8+
const dec = decodeURIComponent
89

910
/**
1011
* Derive Apprise scheme from a server URL input.
@@ -35,6 +36,11 @@ export const SERVICES = [
3536
buildUrl(values) {
3637
return `tgram://${enc(values.bot_token)}/${enc(values.chat_id)}`
3738
},
39+
parseUrl(url) {
40+
const rest = url.replace(/^tgram:\/\//, '')
41+
const parts = rest.split('/')
42+
return { bot_token: dec(parts[0] || ''), chat_id: dec(parts[1] || '') }
43+
},
3844
helpUrl: 'https://appriseit.com/services/telegram/'
3945
},
4046
{
@@ -55,6 +61,16 @@ export const SERVICES = [
5561
})
5662
return `${scheme}://${host}/${topic}`
5763
},
64+
parseUrl(url) {
65+
const match = url.match(/^(ntfys?):\/\/(.+)$/)
66+
if (!match) return null
67+
const [, scheme, rest] = match
68+
const slashIdx = rest.indexOf('/')
69+
if (slashIdx === -1) return { topic: dec(rest), server: '' }
70+
const host = rest.slice(0, slashIdx)
71+
const topic = rest.slice(slashIdx + 1)
72+
return { topic: dec(topic), server: `${scheme === 'ntfys' ? 'https' : 'http'}://${host}` }
73+
},
5874
helpUrl: 'https://appriseit.com/services/ntfy/'
5975
},
6076
{
@@ -76,6 +92,13 @@ export const SERVICES = [
7692
}
7793
return url
7894
},
95+
parseUrl(url) {
96+
const match = url.match(/^mailtos?:\/\/([^:]+):([^@]+)@([^?]+)(\?.*)?$/)
97+
if (!match) return null
98+
const [, user, password, domain, query] = match
99+
const smtp = query ? new URLSearchParams(query.slice(1)).get('smtp') : ''
100+
return { email: `${dec(user)}@${domain}`, password: dec(password), smtp: smtp ? dec(smtp) : '' }
101+
},
79102
parseError: 'Invalid email address format.',
80103
helpUrl: 'https://appriseit.com/services/email/'
81104
},
@@ -93,6 +116,12 @@ export const SERVICES = [
93116
})
94117
return `${scheme}://${host}/${enc(values.token)}`
95118
},
119+
parseUrl(url) {
120+
const match = url.match(/^(hassios?):\/\/(.+)\/([^/]+)$/)
121+
if (!match) return null
122+
const [, scheme, host, token] = match
123+
return { server: `${scheme === 'hassios' ? 'https' : 'http'}://${host}`, token: dec(token) }
124+
},
96125
helpUrl: 'https://appriseit.com/services/homeassistant/'
97126
},
98127
{
@@ -117,6 +146,17 @@ export const SERVICES = [
117146
}
118147
return `${scheme}://${auth}${host}/${enc(values.topic)}`
119148
},
149+
parseUrl(url) {
150+
const match = url.match(/^(mqtts?):\/\/(?:([^:@]+)(?::([^@]+))?@)?(.+?)\/([^/]+)$/)
151+
if (!match) return null
152+
const [, scheme, user, password, host, topic] = match
153+
return {
154+
server: `${scheme}://${host}`,
155+
topic: dec(topic),
156+
user: user ? dec(user) : '',
157+
password: password ? dec(password) : ''
158+
}
159+
},
120160
helpUrl: 'https://appriseit.com/services/mqtt/'
121161
},
122162
{
@@ -128,6 +168,9 @@ export const SERVICES = [
128168
buildUrl(values) {
129169
return values.url.trim()
130170
},
171+
parseUrl(url) {
172+
return { url }
173+
},
131174
helpUrl: 'https://appriseit.com/'
132175
}
133176
]
@@ -153,5 +196,35 @@ export const SCHEME_TO_SERVICE_NAME = {
153196
notica: 'Notica', simplepush: 'SimplePush', wp: 'WordPress',
154197
}
155198

199+
const SCHEME_TO_ID = {
200+
tgram: 'telegram',
201+
ntfy: 'ntfy', ntfys: 'ntfy',
202+
mailto: 'email', mailtos: 'email',
203+
hassio: 'homeassistant', hassios: 'homeassistant',
204+
mqtt: 'mqtt', mqtts: 'mqtt',
205+
}
206+
207+
/**
208+
* Given an Apprise URL, find the matching SERVICES entry and parse it.
209+
* Returns { service, values } or falls back to 'custom'.
210+
*/
211+
export function parseAppriseUrl(url) {
212+
const scheme = url.split('://')[0]?.toLowerCase() || ''
213+
const serviceId = SCHEME_TO_ID[scheme] || 'custom'
214+
const custom = SERVICES.find(s => s.id === 'custom')
215+
if (serviceId !== 'custom') {
216+
const service = SERVICES.find(s => s.id === serviceId)
217+
try {
218+
const values = service?.parseUrl(url)
219+
// Verify round-trip: if buildUrl produces a different URL, the parser
220+
// missed something (extra query params, scheme change) — use custom editor
221+
if (values && service.buildUrl(values) === url) {
222+
return { service, values }
223+
}
224+
} catch { /* fall through to custom */ }
225+
}
226+
return { service: custom, values: custom.parseUrl(url) }
227+
}
228+
156229
// Exported for testing
157230
export { deriveScheme }

0 commit comments

Comments
 (0)