From 2cbff3d3a18ffce7949ccd0c04588dbb01a01c24 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:05:50 +0100 Subject: [PATCH] Fix: Copy url in frontend does not resolve provider:// (#673) * Fix: Copy url in frontend does not resolve provider:// * Fix: Filter editor on demand validation * Fix: redirect to login screen if auto logout happens --- backend/src/api/endpoints/v1_api_playlist.rs | 333 +++++++++++++++++- docs/src/configuration/source.md | 2 + frontend/scss/main.scss | 5 + frontend/src/app/components/authentication.rs | 19 +- frontend/src/app/components/content_dialog.rs | 65 +++- .../app/components/dashboard/streams_table.rs | 20 +- .../app/components/filter/filter_editor.rs | 89 +++-- .../src/app/components/filter/filter_input.rs | 179 +++++----- .../src/app/components/playlist/epg_view.rs | 20 +- .../components/playlist/playlist_explorer.rs | 40 ++- frontend/src/model/dialog.rs | 8 +- .../src/provider/icon_context_provider.rs | 8 + frontend/src/services/playlist_service.rs | 26 +- shared/src/model/mod.rs | 3 +- shared/src/model/playlist_request.rs | 6 + shared/src/model/webplayer_url_request.rs | 9 - 16 files changed, 644 insertions(+), 188 deletions(-) delete mode 100644 shared/src/model/webplayer_url_request.rs diff --git a/backend/src/api/endpoints/v1_api_playlist.rs b/backend/src/api/endpoints/v1_api_playlist.rs index c3096b24e..038f836a9 100644 --- a/backend/src/api/endpoints/v1_api_playlist.rs +++ b/backend/src/api/endpoints/v1_api_playlist.rs @@ -7,14 +7,14 @@ use crate::{api::{ xtream_api::xtream_get_stream_info_response, }, model::AppState, -}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_url, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id}; +}, auth::create_access_token, auth::permission_layer, model::{parse_xmltv_for_web_ui_from_url, AppConfig, ConfigInput, ConfigInputFlags, ConfigInputOptions}, repository::xtream_get_item_for_stream_id}; use axum::{response::IntoResponse, Router}; use log::{debug, error}; use serde_json::json; use shared::{ model::{ permission::Permission, - InputType, PlaylistEpgRequest, PlaylistRequest, ProxyType, TargetType, UiPlaylistItem, WebplayerUrlRequest, + InputType, PlaylistEpgRequest, PlaylistRequest, PlaylistUrlResolveRequest, ProxyType, TargetType, UiPlaylistItem, XtreamCluster, }, utils::{sanitize_sensitive_info, Internable}, @@ -60,6 +60,46 @@ fn create_config_input_for_xtream(username: &str, password: &str, host: &str) -> } } +fn resolve_provider_url_with_input(input: &ConfigInput, url: &str) -> String { + match input.resolve_url(url) { + Ok(resolved) => resolved.into_owned(), + Err(err) => { + let sanitized_url = sanitize_sensitive_info(url); + let err_text = err.to_string(); + let sanitized_err = sanitize_sensitive_info(&err_text); + error!("resolve_provider_url_with_input failed for url '{sanitized_url}': {sanitized_err}"); + url.to_string() + } + } +} + +fn resolve_provider_url_for_request(app_config: &AppConfig, playlist_request: &PlaylistRequest, url: &str) -> String { + if !url.starts_with(shared::utils::PROVIDER_SCHEME_PREFIX) { + return url.to_string(); + } + + match playlist_request { + PlaylistRequest::Input(input_id) => app_config + .get_input_by_id(*input_id) + .map_or_else(|| url.to_string(), |input| resolve_provider_url_with_input(input.as_ref(), url)), + PlaylistRequest::Target(target_id) => app_config + .get_target_by_id(*target_id) + .and_then(|target| app_config.get_inputs_for_target(&target.name)) + .and_then(|inputs| { + let mut matches = inputs + .into_iter() + .filter(|input| input.get_resolve_provider(url).is_some()); + let first = matches.next()?; + if matches.next().is_some() { + return None; + } + Some(resolve_provider_url_with_input(first.as_ref(), url)) + }) + .unwrap_or_else(|| url.to_string()), + PlaylistRequest::CustomXtream(_) | PlaylistRequest::CustomM3u(_) => url.to_string(), + } +} + async fn playlist_update( axum::extract::State(app_state): axum::extract::State>, axum::extract::Json(targets): axum::extract::Json>, @@ -213,9 +253,11 @@ async fn playlist_series_info( axum::http::StatusCode::NO_CONTENT.into_response() } -async fn playlist_webplayer( +fn playlist_webplayer( axum::extract::State(app_state): axum::extract::State>, - axum::extract::Json(playlist_item): axum::extract::Json, + target_id: u16, + virtual_id: u32, + cluster: XtreamCluster, ) -> impl axum::response::IntoResponse + Send { let access_token = create_access_token(&app_state.app_config.access_token_secret, 30); let config = app_state.app_config.config.load(); @@ -228,9 +270,9 @@ async fn playlist_webplayer( let base_url = server_info.get_base_url(); format!( "{base_url}/token/{access_token}/{}/{}/{}", - playlist_item.target_id, - playlist_item.cluster.as_stream_type(), - playlist_item.virtual_id + target_id, + cluster.as_stream_type(), + virtual_id ) .into_response() } @@ -282,9 +324,29 @@ async fn playlist_resource( } } +async fn playlist_resolve_url( + axum::extract::State(app_state): axum::extract::State>, + axum::extract::Json(request): axum::extract::Json, +) -> impl IntoResponse + Send { + match request { + PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster } => { + playlist_webplayer( + axum::extract::State(app_state), + target_id, + virtual_id, + cluster, + ) + .into_response() + } + PlaylistUrlResolveRequest::Provider { playlist_request, url } => { + resolve_provider_url_for_request(&app_state.app_config, &playlist_request, &url).into_response() + } + } +} + pub fn v1_api_playlist_register_protected(router: Router>) -> axum::Router> { router - .route("/playlist/webplayer", axum::routing::post(playlist_webplayer)) + .route("/playlist/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/playlist/update", axum::routing::post(playlist_update)) .route("/playlist/epg", axum::routing::post(playlist_epg)) .route("/playlist/live", axum::routing::post(playlist_content_live)) @@ -309,9 +371,9 @@ pub fn v1_api_playlist_register_with_permissions( .route("/live", axum::routing::post(playlist_content_live)) .route("/vod", axum::routing::post(playlist_content_vod)) .route("/series", axum::routing::post(playlist_content_series)) + .route("/resolve_url", axum::routing::post(playlist_resolve_url)) .route("/series_info/{virtual_id}/{provider_id}", axum::routing::post(playlist_series_info)) .route("/series/episode/{virtual_id}", axum::routing::post(playlist_episode_item)) - .route("/webplayer", axum::routing::post(playlist_webplayer)) .layer(permission_layer!(app_state, Permission::PlaylistRead)); let write_routes = Router::new() @@ -329,6 +391,259 @@ pub fn v1_api_playlist_register_with_permissions( ) } +#[cfg(test)] +mod tests { + use super::resolve_provider_url_for_request; + use crate::model::{AppConfig, Config, ConfigInput, ConfigProvider, ConfigSource, ConfigTarget, SourcesConfig}; + use arc_swap::{ArcSwap, ArcSwapOption}; + use shared::foundation::Filter; + use shared::{ + model::{ConfigPaths, ConfigProviderDto, PlaylistRequest}, + utils::Internable, + }; + use std::sync::Arc; + + fn test_app_config(input: Arc, source: ConfigSource) -> AppConfig { + let sources = SourcesConfig { + batch_files: vec![], + provider: vec![], + inputs: vec![input], + sources: vec![source], + templates: None, + }; + + AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::empty()), + api_proxy: Arc::new(ArcSwapOption::empty()), + file_locks: Arc::new(crate::utils::FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::empty()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + } + } + + #[test] + fn resolve_provider_url_for_input_request_rewrites_provider_scheme() { + let provider = ConfigProvider::from(&ConfigProviderDto { + name: "demo".intern(), + urls: vec!["http://provider.example".intern()], + dns: None, + }); + let input = Arc::new(ConfigInput { + id: 7, + name: "input".intern(), + provider_configs: Some(vec![Arc::new(provider)]), + ..Default::default() + }); + let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] }; + let app_config = test_app_config(input, source); + let resolved = resolve_provider_url_for_request( + &app_config, + &PlaylistRequest::Input(7), + "provider://demo/live/user/pass/1359.ts", + ); + + assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts"); + } + + #[test] + fn resolve_provider_url_for_target_request_rewrites_provider_scheme() { + let provider = ConfigProvider::from(&ConfigProviderDto { + name: "demo".intern(), + urls: vec!["http://provider.example".intern()], + dns: None, + }); + let input = Arc::new(ConfigInput { + id: 7, + name: "input".intern(), + provider_configs: Some(vec![Arc::new(provider)]), + ..Default::default() + }); + let target = Arc::new(ConfigTarget { + id: 11, + enabled: true, + name: "target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![], + rename: None, + mapping_ids: None, + mapping: Arc::default(), + favourites: None, + processing_order: Default::default(), + watch: None, + use_memory_cache: false, + }); + let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] }; + let app_config = test_app_config(input, source); + let resolved = resolve_provider_url_for_request( + &app_config, + &PlaylistRequest::Target(11), + "provider://demo/live/user/pass/1359.ts", + ); + + assert_eq!(resolved, "http://provider.example/live/user/pass/1359.ts"); + } + + #[test] + fn resolve_provider_url_passthrough_for_unresolved_provider_input_request() { + let provider = ConfigProvider::from(&ConfigProviderDto { + name: "demo".intern(), + urls: vec!["http://provider.example".intern()], + dns: None, + }); + let input = Arc::new(ConfigInput { + id: 7, + name: "input".intern(), + provider_configs: Some(vec![Arc::new(provider)]), + ..Default::default() + }); + let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![] }; + let app_config = test_app_config(input, source); + let original = "provider://unknown/live/user/pass/1359.ts"; + let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Input(7), original); + + assert_eq!(resolved, original); + } + + #[test] + fn resolve_provider_url_passthrough_for_unresolved_provider_target_request() { + let provider = ConfigProvider::from(&ConfigProviderDto { + name: "demo".intern(), + urls: vec!["http://provider.example".intern()], + dns: None, + }); + let input = Arc::new(ConfigInput { + id: 7, + name: "input".intern(), + provider_configs: Some(vec![Arc::new(provider)]), + ..Default::default() + }); + let target = Arc::new(ConfigTarget { + id: 11, + enabled: true, + name: "target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![], + rename: None, + mapping_ids: None, + mapping: Arc::default(), + favourites: None, + processing_order: Default::default(), + watch: None, + use_memory_cache: false, + }); + let source = ConfigSource { inputs: vec![Arc::clone(&input.name)], targets: vec![target] }; + let app_config = test_app_config(input, source); + let original = "provider://unknown/live/user/pass/1359.ts"; + let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original); + + assert_eq!(resolved, original); + } + + #[test] + fn resolve_provider_url_passthrough_for_ambiguous_target_request() { + let provider_a = ConfigProvider::from(&ConfigProviderDto { + name: "shared".intern(), + urls: vec!["http://provider-a.example".intern()], + dns: None, + }); + let provider_b = ConfigProvider::from(&ConfigProviderDto { + name: "shared".intern(), + urls: vec!["http://provider-b.example".intern()], + dns: None, + }); + let input_a = Arc::new(ConfigInput { + id: 7, + name: "input-a".intern(), + provider_configs: Some(vec![Arc::new(provider_a)]), + ..Default::default() + }); + let input_b = Arc::new(ConfigInput { + id: 8, + name: "input-b".intern(), + provider_configs: Some(vec![Arc::new(provider_b)]), + ..Default::default() + }); + let target = Arc::new(ConfigTarget { + id: 11, + enabled: true, + name: "target".to_string(), + options: None, + sort: None, + filter: Filter::default(), + output: vec![], + rename: None, + mapping_ids: None, + mapping: Arc::default(), + favourites: None, + processing_order: Default::default(), + watch: None, + use_memory_cache: false, + }); + let source = ConfigSource { + inputs: vec![Arc::clone(&input_a.name), Arc::clone(&input_b.name)], + targets: vec![target], + }; + let sources = SourcesConfig { + batch_files: vec![], + provider: vec![], + inputs: vec![input_a, input_b], + sources: vec![source], + templates: None, + }; + + let app_config = AppConfig { + config: Arc::new(ArcSwap::from_pointee(Config::default())), + sources: Arc::new(ArcSwap::from_pointee(sources)), + hdhomerun: Arc::new(ArcSwapOption::empty()), + api_proxy: Arc::new(ArcSwapOption::empty()), + file_locks: Arc::new(crate::utils::FileLockManager::default()), + paths: Arc::new(ArcSwap::from_pointee(ConfigPaths { + home_path: String::new(), + config_path: String::new(), + storage_path: String::new(), + config_file_path: String::new(), + sources_file_path: String::new(), + mapping_file_path: None, + mapping_files_used: None, + template_file_path: None, + template_files_used: None, + api_proxy_file_path: String::new(), + custom_stream_response_path: None, + })), + custom_stream_response: Arc::new(ArcSwapOption::empty()), + access_token_secret: [0; 32], + encrypt_secret: [0; 16], + media_tools: Arc::new(crate::model::MediaToolCapabilities::default()), + }; + + let original = "provider://shared/live/user/pass/1359.ts"; + let resolved = resolve_provider_url_for_request(&app_config, &PlaylistRequest::Target(11), original); + + assert_eq!(resolved, original); + } +} + async fn playlist_episode_item( axum::extract::Path(virtual_id): axum::extract::Path, axum::extract::State(app_state): axum::extract::State>, diff --git a/docs/src/configuration/source.md b/docs/src/configuration/source.md index d3c1b41ef..0fef61739 100644 --- a/docs/src/configuration/source.md +++ b/docs/src/configuration/source.md @@ -704,6 +704,8 @@ Tuliprox supports the following filter expression types: * Type Comparison: `Type = vod` or `Type = live` or `Type = series` * Regular expression comparison: `([fieldanme]) ~ "regexp"`
The `[fieldanme]` can be `Group`, `Title`, `Name`, `Caption`, `Url`, `Genre`, `Input` or `Type`. +* Filters don't have operator precedence, so please use parentheses +* You can apply Morgan’s Law `NOT (A) AND NOT (B)`is the same as `NOT( A OR B)` > **Note:** > diff --git a/frontend/scss/main.scss b/frontend/scss/main.scss index 4fbba0c3e..8c721d271 100644 --- a/frontend/scss/main.scss +++ b/frontend/scss/main.scss @@ -33,6 +33,11 @@ body { margin: var(--margin-mini) 0; } + button[disabled] { + opacity: 0.5; + filter: grayscale(1); + cursor: not-allowed; + } } #root { diff --git a/frontend/src/app/components/authentication.rs b/frontend/src/app/components/authentication.rs index 7ca1a2e91..08a54eefa 100644 --- a/frontend/src/app/components/authentication.rs +++ b/frontend/src/app/components/authentication.rs @@ -1,8 +1,12 @@ -use crate::{app::components::login::Login, hooks::use_service_context}; +use crate::{ + app::{components::login::Login, AppRoute}, + hooks::use_service_context, +}; use shared::model::permission::Permission; use std::future; use yew::{prelude::*, suspense::use_future}; use yew_hooks::{use_async_with_options, UseAsyncOptions}; +use yew_router::prelude::use_navigator; fn should_connect_websocket(success: bool, setup_mode: bool, can_read_system: bool) -> bool { success && !setup_mode && can_read_system @@ -18,6 +22,7 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { let services = use_service_context(); let loading = use_state(|| true); let authenticated = use_state(|| false); + let navigator = use_navigator(); { let services_ctx = services.clone(); @@ -56,6 +61,18 @@ pub fn Authentication(props: &AuthenticationProps) -> Html { ); } + { + let navigator = navigator.clone(); + use_effect_with((*loading, *authenticated), move |(loading, authenticated)| { + if !*loading && !*authenticated { + if let Some(navigator) = navigator.clone() { + navigator.replace(&AppRoute::Login); + } + } + || () + }); + } + if *loading { html! {} } else if *authenticated { diff --git a/frontend/src/app/components/content_dialog.rs b/frontend/src/app/components/content_dialog.rs index 0254c81a3..4e01cded5 100644 --- a/frontend/src/app/components/content_dialog.rs +++ b/frontend/src/app/components/content_dialog.rs @@ -5,6 +5,24 @@ use crate::{ }; use yew::prelude::*; +fn resolve_close_result(actions: &DialogActions) -> Option { + actions + .left + .as_ref() + .into_iter() + .flatten() + .chain(actions.right.iter()) + .find(|action| !action.disabled && matches!(action.result, DialogResult::Cancel)) + .map(|action| action.result.clone()) + .or_else(|| actions.right.iter().find(|action| !action.disabled).map(|action| action.result.clone())) + .or_else(|| { + actions + .left + .as_ref() + .and_then(|actions| actions.iter().find(|action| !action.disabled).map(|action| action.result.clone())) + }) +} + #[derive(Properties, PartialEq)] pub struct ContentDialogProps { pub content: Html, @@ -40,6 +58,7 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html { html! { Html { // Find a cancel action to use for backdrop clicks let on_close = { let on_result = on_result.clone(); - let cancel_action = props - .actions - .right - .iter() - .find(|action| matches!(action.result, DialogResult::Cancel)) - .or_else(|| props.actions.right.first()); - - if let Some(action) = cancel_action { - let result = action.result.clone(); - Some(Callback::from(move |_| { + resolve_close_result(&props.actions).map(|result| { + Callback::from(move |_| { on_result(result.clone()); - })) - } else { - None - } + }) + }) }; html! { @@ -94,3 +103,31 @@ pub fn ContentDialog(props: &ContentDialogProps) -> Html { } } + +#[cfg(test)] +mod tests { + use super::resolve_close_result; + use crate::model::{DialogAction, DialogActions, DialogResult}; + + #[test] + fn resolve_close_result_prefers_cancel_from_left_actions() { + let actions = DialogActions { + left: Some(vec![DialogAction::new("close", "LABEL.CLOSE", DialogResult::Cancel, None, None)]), + right: vec![DialogAction::new("ok", "LABEL.OK", DialogResult::Ok, None, None)], + }; + + assert_eq!(resolve_close_result(&actions), Some(DialogResult::Cancel)); + } + + #[test] + fn resolve_close_result_ignores_disabled_actions() { + let actions = DialogActions { + left: Some(vec![ + DialogAction::new("close", "LABEL.CLOSE", DialogResult::Cancel, None, None).with_disabled(true) + ]), + right: vec![DialogAction::new("ok", "LABEL.OK", DialogResult::Ok, None, None)], + }; + + assert_eq!(resolve_close_result(&actions), Some(DialogResult::Ok)); + } +} diff --git a/frontend/src/app/components/dashboard/streams_table.rs b/frontend/src/app/components/dashboard/streams_table.rs index 472f9e156..4427e276d 100644 --- a/frontend/src/app/components/dashboard/streams_table.rs +++ b/frontend/src/app/components/dashboard/streams_table.rs @@ -18,8 +18,8 @@ use shared::{ concat_string, error::{info_err_res, TuliproxError}, model::{ - PlaylistItemType, ProtocolMessage, SortOrder, StreamChannel, StreamInfo, StreamMeterEntry, StreamTechnicalInfo, - UserCommand, + PlaylistItemType, PlaylistRequest, PlaylistUrlResolveRequest, ProtocolMessage, SortOrder, StreamChannel, + StreamInfo, StreamMeterEntry, StreamTechnicalInfo, UserCommand, }, utils::{current_time_secs, default_hls_session_ttl_secs, default_kick_secs, strip_port}, }; @@ -605,7 +605,16 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { } StreamsTableAction::CopyLinkProviderUrl => { if let Some(dto) = &*selected_dto { - copy_to_clipboard.emit(dto.channel.url.to_string()); + let url = dto.channel.url.to_string(); + let playlist_request = PlaylistRequest::Target(dto.channel.target_id); + let copy_to_clipboard = copy_to_clipboard.clone(); + let services = services.clone(); + spawn_local(async move { + let request = + PlaylistUrlResolveRequest::Provider { playlist_request, url: url.to_string() }; + let resolved = services.playlist.resolve_url(request).await.unwrap_or(url); + copy_to_clipboard.emit(resolved); + }); } } StreamsTableAction::CopyLinkTuliproxWebPlayerUrl => { @@ -617,9 +626,8 @@ pub fn StreamsTable(props: &StreamsTableProps) -> Html { let translate = translate.clone(); let copy_to_clipboard = copy_to_clipboard.clone(); spawn_local(async move { - if let Some(url) = - services.playlist.get_playlist_webplayer_url(target_id, virtual_id, cluster).await - { + let request = PlaylistUrlResolveRequest::Webplayer { target_id, virtual_id, cluster }; + if let Some(url) = services.playlist.resolve_url(request).await { copy_to_clipboard.emit(url); } else { services.toastr.error(translate.t("MESSAGES.FAILED_TO_RETRIEVE_WEBPLAYER_URL")); diff --git a/frontend/src/app/components/filter/filter_editor.rs b/frontend/src/app/components/filter/filter_editor.rs index c36dd86e8..6cfb6f9ca 100644 --- a/frontend/src/app/components/filter/filter_editor.rs +++ b/frontend/src/app/components/filter/filter_editor.rs @@ -5,7 +5,10 @@ use crate::{ }, i18n::use_translation, }; -use shared::{foundation::get_filter, model::PatternTemplate}; +use shared::{ + foundation::{get_filter, Filter}, + model::PatternTemplate, +}; use web_sys::InputEvent; use yew::{classes, component, html, use_context, use_effect_with, use_state, Callback, Html, Properties, TargetCast}; @@ -15,25 +18,38 @@ pub struct FilterEditorProps { pub filter: Option, #[prop_or_default] pub on_filter_change: Callback>, + #[prop_or_default] + pub on_valid_change: Callback, pub on_templates_change: Callback>>, } +pub(crate) fn parse_filter_preview( + filter: Option<&str>, + templates: Option<&[PatternTemplate]>, +) -> (Option, bool) { + match filter { + Some(filter) => match get_filter(filter, templates) { + Ok(parsed) => (Some(parsed), true), + Err(_) => (None, false), + }, + None => (None, true), + } +} + #[component] pub fn FilterEditor(props: &FilterEditorProps) -> Html { let config_ctx = use_context::().expect("Config context not found"); let translate = use_translation(); + let cfg_templates = config_ctx.config.as_ref().and_then(|c| { + c.templates.as_ref().map(|definition| definition.templates.clone()).or_else(|| c.sources.templates.clone()) + }); - let templates_state = use_state(|| None); - let filter_state = use_state(|| None); - let parsed_filter_state = use_state(|| None); - let valid_filter_state = use_state(|| true); + let templates_state = use_state(|| cfg_templates.clone()); + let filter_state = use_state(|| props.filter.clone()); { let templates = templates_state.clone(); let on_templates_change = props.on_templates_change.clone(); - let cfg_templates = config_ctx.config.as_ref().and_then(|c| { - c.templates.as_ref().map(|definition| definition.templates.clone()).or_else(|| c.sources.templates.clone()) - }); use_effect_with(cfg_templates, move |templ| { templates.set(templ.clone()); on_templates_change.emit(templ.clone()); @@ -47,28 +63,13 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { }); } + let (parsed_filter, valid_filter) = parse_filter_preview((*filter_state).as_deref(), (*templates_state).as_deref()); + { - let filter = filter_state.clone(); - let parsed_filter = parsed_filter_state.clone(); - let templates = templates_state.clone(); - let valid_filter = valid_filter_state.clone(); - use_effect_with(filter.clone(), move |flt| { - let parsed = if let Some(new_fltr) = flt.as_ref() { - match get_filter(new_fltr, (*templates).as_deref()) { - Ok(fltr) => { - valid_filter.set(true); - Some(fltr) - } - Err(_) => { - valid_filter.set(false); - None - } - } - } else { - valid_filter.set(true); - None - }; - parsed_filter.set(parsed); + let on_valid_change = props.on_valid_change.clone(); + use_effect_with(valid_filter, move |valid| { + on_valid_change.emit(*valid); + || () }); } @@ -90,7 +91,7 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html { }; html! { -
+
@@ -118,8 +119,34 @@ pub fn FilterEditor(props: &FilterEditorProps) -> Html {