diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e937f70c..cac6b447a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ - More robust connection-state and provider-handle management - Streamlined event notifications and provider-count reporting - Added configurable `reverse_proxy.resource_retry` (UI + server) to tune max attempts, base delay, and exponential backoff multiplier for proxied resources. +- Multi Strm outputs with same type is now allowed. +- Added new mapper function `pad(text | number, number, char, optional position: "<" | ">" | "^")` +- Added new mapper function `format` for simple in-text replacement like `format("Hello {}! Hello {}!", "Bob", "World")` # 3.1.8 (2025-11-06) - Fixed HLS streaming issues caused by session eviction and incorrect headers. diff --git a/README.md b/README.md index 0b75a9867..edf88677b 100644 --- a/README.md +++ b/README.md @@ -1268,8 +1268,12 @@ It is whitespace-tolerant and uses familiar programming concepts with a custom s - first(a) - template(a) - replace(text, match, replacement) + - pad(text | number, number, char, optional position: "<" | ">" | "^") + - format(fmt_text, ...args) Field names are: `name`, `title"`, `caption"`, `group"`, `id"`, `chno"`, `logo"`, `logo_small"`, `parent_code"`, `audio_track"`, `time_shift" | "url"`, `epg_channel_id"`, `epg_id`. -When you use Regular expressions it could be that your match contains multiple results. The builtin function `first` returns the first match. +Format is very simple and only supports in text replacement like `format("Hello {}! Hello {}!", "Bob", "World")` +When you use Regular expressions it could be that your match contains multiple results. +The builtin function `first` returns the first match. Example `print(uppercase("hello"))`. output is only visible in `trace` log level you can enable it like `log_level: debug,tuliprox::foundation::mapper=trace` in config - Assignment assigns an expression result. variable or field. ```dsl diff --git a/frontend/public/assets/icons.json b/frontend/public/assets/icons.json index 6846ac8d8..eef6df83d 100644 --- a/frontend/public/assets/icons.json +++ b/frontend/public/assets/icons.json @@ -520,5 +520,4 @@ "keys": ["Accept"], "path": "m18 7-1.41-1.41-6.34 6.34 1.41 1.41zm4.24-1.41L11.66 16.17 7.48 12l-1.41 1.41L11.66 19l12-12zM.41 13.41 6 19l1.41-1.41L1.83 12z" } - ] \ No newline at end of file diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index 13fbbce26..0e594cc73 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -363,8 +363,10 @@ --source-editor-line-color: #ffffff; --source-editor-pending-line-color: yellow; -} + --selection-area-border-color: rgba(0, 120, 215, 0.9); + --selection-area-background-color: rgba(0, 120, 215, 0.12); +} body[data-theme="bright"] { --primary-color: rgba(10, 120, 255, 0.95); diff --git a/frontend/scss/app/components/source_editor/_source_editor.scss b/frontend/scss/app/components/source_editor/_source_editor.scss index 997a7ed35..6e6e7b5d2 100644 --- a/frontend/scss/app/components/source_editor/_source_editor.scss +++ b/frontend/scss/app/components/source_editor/_source_editor.scss @@ -79,6 +79,13 @@ &__connections { } + &__selection-rect { + border: 1px dashed var(--selection-area-border-color); + background: var(--selection-area-background-color); + z-index: 9999; + pointer-events: none; + } + } .graph-paper-advanced { @@ -94,6 +101,11 @@ cursor: grabbing; } +.selection_mode * { + cursor: crosshair !important; +} + + .clickable { cursor: pointer; } diff --git a/frontend/scss/app/components/source_editor/_source_editor_block.scss b/frontend/scss/app/components/source_editor/_source_editor_block.scss index 8513414c0..a3664d3cc 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_block.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_block.scss @@ -6,6 +6,8 @@ $port-size: 10px; .tp__source-editor__block { position: absolute; display: flex; + left: 0; + top: 0; flex-flow: column; align-items: center; background-color: transparent; @@ -13,6 +15,13 @@ $port-size: 10px; min-width: $block-width; max-width: $block-width; width: $block-width; + box-sizing: border-box; + + &-editing { + .tp__source-editor__block-content { + border-color: red !important; + } + } &-selected { filter: sepia(1); @@ -194,7 +203,7 @@ $port-size: 10px; justify-content: flex-end; width: 100%; &-label { - color: var(--text-color); + color: var(--modest-text-color); font-size: 0.7rem; font-weight: bold; width: 50%; diff --git a/frontend/src/app/components/source_editor/block_view.rs b/frontend/src/app/components/source_editor/block_view.rs index 78aa08a3c..583bdcc72 100644 --- a/frontend/src/app/components/source_editor/block_view.rs +++ b/frontend/src/app/components/source_editor/block_view.rs @@ -6,6 +6,7 @@ use crate::app::components::{Block, BlockId, BlockInstance, BlockType, PortStatu #[derive(Properties, PartialEq)] pub struct BlockProps { pub(crate) block: Block, + pub(crate) edited:bool, pub(crate) selected:bool, pub(crate) delete_mode: bool, pub(crate) delete_block: Callback, @@ -28,7 +29,7 @@ pub fn BlockView(props: &BlockProps) -> Html { let block_id = block.id; let block_type = block.block_type; - let style = format!("position:absolute; left:{}px; top:{}px;", block.position.0, block.position.1); + let style = format!("transform: translate({}px, {}px)", block.position.0, block.position.1); let from_id = block_id; let to_id = block_id; @@ -81,11 +82,11 @@ pub fn BlockView(props: &BlockProps) -> Html { }; html! { -
// Block handle (drag) -
+
// Delete button for block { html_if!(delete_mode, { @@ -97,7 +98,7 @@ pub fn BlockView(props: &BlockProps) -> Html { }) }
-
+
{ title } diff --git a/frontend/src/app/components/source_editor/editor_model.rs b/frontend/src/app/components/source_editor/editor_model.rs index 3c4b847f6..719795540 100644 --- a/frontend/src/app/components/source_editor/editor_model.rs +++ b/frontend/src/app/components/source_editor/editor_model.rs @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize}; use yew::{Callback, UseStateHandle}; use shared::model::{ConfigInputDto, ConfigTargetDto, InputType, TargetOutputDto}; +pub const BLOCK_WIDTH: f32 = 100.0; +pub const BLOCK_HEIGHT: f32 = 50.0; +pub const BLOCK_HEADER_HEIGHT: f32 = 12.0; +pub const BLOCK_PORT_HEIGHT: f32 = 10.0; + // ----------------- Data Models ----------------- #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] pub enum BlockType { @@ -98,13 +103,50 @@ pub struct Block { pub instance: BlockInstance, } +impl Block { + /// Liefert die Bounding-Box des Blocks unter Berücksichtigung der Canvas-Verschiebung. + pub fn bounds(&self, canvas_offset: (f32, f32)) -> (f32, f32, f32, f32) { + let (ox, oy) = canvas_offset; + let bx = self.position.0 + ox; + let by = self.position.1 + oy; + let b_left = bx; + let b_top = by; + let b_right = bx + BLOCK_WIDTH; + let b_bottom = by + (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT); + (b_left, b_top, b_right, b_bottom) + } + + /// Prüft, ob der Block von einem Auswahlrechteck getroffen wird. + pub fn intersects_rect( + &self, + rect_start: (f32, f32), + rect_end: (f32, f32), + canvas_offset: (f32, f32), + ) -> bool { + let (b_left, b_top, b_right, b_bottom) = self.bounds(canvas_offset); + + // Rechteck normalisieren + let (x1, y1) = rect_start; + let (x2, y2) = rect_end; + let r_left = x1.min(x2); + let r_top = y1.min(y2); + let r_right = x1.max(x2); + let r_bottom = y1.max(y2); + + b_right >= r_left && + b_left <= r_right && + b_bottom >= r_top && + b_top <= r_bottom + } +} + + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct Connection { pub from: BlockId, pub to: BlockId, } - #[derive(Clone, Copy, PartialEq)] pub enum PortStatus { Valid, @@ -112,8 +154,6 @@ pub enum PortStatus { Inactive } - - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum BlockInstance { Input(Rc), diff --git a/frontend/src/app/components/source_editor/editor_view.rs b/frontend/src/app/components/source_editor/editor_view.rs index bdf45c966..cbd236356 100644 --- a/frontend/src/app/components/source_editor/editor_view.rs +++ b/frontend/src/app/components/source_editor/editor_view.rs @@ -1,16 +1,159 @@ -use std::rc::Rc; -use web_sys::{HtmlElement, MouseEvent}; -use yew::prelude::*; -use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; -use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar}; -use crate::app::{PlaylistContext}; use crate::app::components::source_editor::layout::layout; +use crate::app::components::{can_connect, Block, BlockId, BlockInstance, BlockType, BlockView, Connection, EditMode, InputRow, PortStatus, SourceEditorContext, SourceEditorForm, SourceEditorSidebar, BLOCK_HEADER_HEIGHT, BLOCK_HEIGHT, BLOCK_PORT_HEIGHT, BLOCK_WIDTH}; +use crate::app::PlaylistContext; +use shared::model::{ConfigInputDto, ConfigTargetDto, HdHomeRunTargetOutputDto, M3uTargetOutputDto, StrmTargetOutputDto, TargetOutputDto, XtreamTargetOutputDto}; +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; +use wasm_bindgen::JsCast; +use web_sys::{Element, HtmlElement, MouseEvent}; +use yew::prelude::*; -const BLOCK_WIDTH: f32 = 100.0; -const BLOCK_HEIGHT: f32 = 50.0; -const BLOCK_HEADER_HEIGHT: f32 = 12.0; -const BLOCK_PORT_HEIGHT: f32 = 10.0; -const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0; +const BLOCK_MIDDLE_Y: f32 = (BLOCK_HEIGHT + BLOCK_HEADER_HEIGHT + BLOCK_PORT_HEIGHT) / 2.0; + +#[derive(Clone, PartialEq)] +struct DragState { + pub block_id: Option, + pub drag_offset: (f32, f32), + pub sidebar_drag_offset: (f32, f32), + pub dragging_group: Vec::, +} + +impl Default for DragState { + fn default() -> Self { + Self { + block_id: None, + drag_offset: (0.0f32, 0.0f32), + dragging_group: Vec::::new(), + sidebar_drag_offset: (0.0f32, 0.0f32), + } + } +} + +impl DragState { + pub fn with_sidebar_drag_offset(&self, x: f32, y: f32) -> Self { + Self { sidebar_drag_offset: (x, y), ..self.clone() } + } + + pub fn with_drag_block_offset_and_group(&self, block_id: BlockId, drag_offset: (f32, f32), dragging_group: Vec) -> Self { + Self { block_id: Some(block_id), drag_offset, dragging_group, sidebar_drag_offset: self.sidebar_drag_offset } + } + + pub(crate) fn reset_dragging(&self) -> DragState { + Self::default() + } +} + +#[derive(Clone, PartialEq)] +struct SelectionState { + pub is_selecting: bool, + pub selection_rect: Option<(f32, f32, f32, f32)>, // x,y,w,h relative to canva; + pub selection_start: (f32, f32), + pub selected_blocks: Vec::, + pub group_initial_positions: Vec::<(BlockId, (f32, f32))>, + pub group_anchor_mouse: (f32, f32), +} + +impl Default for SelectionState { + fn default() -> Self { + Self { + is_selecting: false, + selection_rect: None, + selection_start: (0.0f32, 0.0f32), + selected_blocks: Vec::::new(), + group_initial_positions: Vec::<(BlockId, (f32, f32))>::new(), + group_anchor_mouse: (0.0f32, 0.0f32), + } + } +} + +impl SelectionState { + pub fn reset_selection(&self) -> Self { + Self::default() + } + pub fn with_blocks_group_initial_pos_and_group_anchor_mouse(&self, selected_blocks: Vec, + group_initial_positions: Vec<(BlockId, (f32, f32))>, + group_anchor_mouse: (f32, f32)) -> Self { + Self { + is_selecting: self.is_selecting, + selection_rect: self.selection_rect, + selection_start: self.selection_start, + selected_blocks, + group_initial_positions, + group_anchor_mouse, + } + } + + pub fn with_selecting_start_rect_and_blocks(&self, is_selecting: bool, + selection_start: (f32, f32), + selection_rect: Option<(f32, f32, f32, f32)>, + selected_blocks: Vec::) -> Self { + Self { + is_selecting, + selection_rect, + selection_start, + selected_blocks, + group_initial_positions: self.group_initial_positions.clone(), + group_anchor_mouse: self.group_anchor_mouse, + } + } + + pub fn with_selecting_start_and_rect(&self, is_selecting: bool, + selection_start: (f32, f32), + selection_rect: Option<(f32, f32, f32, f32)>) -> Self { + Self { + is_selecting, + selection_rect, + selection_start, + selected_blocks: self.selected_blocks.clone(), + group_initial_positions: self.group_initial_positions.clone(), + group_anchor_mouse: self.group_anchor_mouse, + } + } + + + pub fn with_rect_and_blocks(&self, selection_rect: Option<(f32, f32, f32, f32)>, + selected_blocks: Vec::) -> Self { + Self { + is_selecting: self.is_selecting, + selection_rect, + selection_start: self.selection_start, + selected_blocks, + group_initial_positions: self.group_initial_positions.clone(), + group_anchor_mouse: self.group_anchor_mouse, + } + } + + pub fn with_selecting_rect_and_group_initial_positions(&self, is_selecting: bool, + selection_rect: Option<(f32, f32, f32, f32)>, + group_initial_positions: Vec<(BlockId, (f32, f32))>) -> Self { + Self { + is_selecting, + selection_rect, + selection_start: self.selection_start, + selected_blocks: self.selected_blocks.clone(), + group_initial_positions, + group_anchor_mouse: self.group_anchor_mouse, + } + } + + pub(crate) fn with_cleared_blocks(&self, block_id: BlockId) -> Self { + let updated_selections: Vec = self.selected_blocks + .iter() + .filter(|&&id| id != block_id) + .map(|&id| if id > block_id { id - 1 } else { id }) + .collect(); + + Self { + is_selecting: self.is_selecting, + selection_rect: self.selection_rect, + selection_start: self.selection_start, + selected_blocks: updated_selections, + group_initial_positions: self.group_initial_positions.clone(), + group_anchor_mouse: self.group_anchor_mouse, + } + } +} fn create_instance(block_type: BlockType) -> BlockInstance { match block_type { @@ -19,7 +162,7 @@ fn create_instance(block_type: BlockType) -> BlockInstance { BlockType::Target => { let dto = ConfigTargetDto { name: String::new(), ..Default::default() }; BlockInstance::Target(Rc::new(dto)) - }, + } BlockType::OutputM3u => BlockInstance::Output(Rc::new(TargetOutputDto::M3u(M3uTargetOutputDto::default()))), BlockType::OutputXtream => BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(XtreamTargetOutputDto::default()))), BlockType::OutputHdHomeRun => BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::default()))), @@ -32,38 +175,36 @@ fn create_instance(block_type: BlockType) -> BlockInstance { pub fn SourceEditor() -> Html { let canvas_ref = use_node_ref(); let playlist_ctx = use_context::().expect("Playlist context not found"); - let blocks = use_state(Vec::::new); - let connections = use_state(Vec::::new); + let force_update = use_state(|| 0); + let blocks = use_mut_ref(Vec::::new); + let connections = use_mut_ref(Vec::::new); let next_id = use_state(|| 1 as BlockId); - + let block_elements = use_mut_ref(HashMap::::new); + let connection_elements = use_mut_ref(HashMap::<(BlockId, BlockId), Element>::new); // ----------------- virtual canvas offset ----------------- let canvas_offset = use_state(|| (0.0f32, 0.0f32)); let is_panning = use_state(|| false); let pan_start = use_state(|| (0.0f32, 0.0f32)); - // Dragging state - let dragging_block = use_state(|| None); - let drag_offset = use_state(|| (0.0f32, 0.0f32)); - let sidebar_drag_offset = use_state(|| (0.0f32, 0.0f32)); - + let drag_state = use_state(DragState::default); + // Selection / marquee states + let selection_state = use_state(SelectionState::default); // Pending line for live connection let pending_line = use_state(|| None); - let pending_connection = use_state(|| None ); - + let pending_connection = use_state(|| None); // Delete mode toggle let delete_mode = use_state(|| false); - let cursor_grabbing = use_state(|| false); { let playlists = playlist_ctx.clone(); let get_next_id = next_id.clone(); - let blocks_set = blocks.clone(); - let connections_set = connections.clone(); + let blocks_ref = blocks.clone(); + let connections_ref = connections.clone(); use_effect_with(playlists.sources.clone(), move |sources| { if let Some(entries) = sources.as_ref() { - let mut gen_blocks = vec![]; - let mut gen_connections = vec![]; + let mut gen_blocks = blocks_ref.borrow_mut(); + let mut gen_connections = connections_ref.borrow_mut(); let mut current_id = *get_next_id; for (inputs, targets) in entries.as_ref() { let mut input_ids = vec![]; @@ -85,7 +226,7 @@ pub fn SourceEditor() -> Html { } } for target_config in targets { - let target_id = current_id; + let target_id = current_id; current_id += 1; let block = Block { id: target_id, @@ -95,20 +236,15 @@ pub fn SourceEditor() -> Html { }; gen_blocks.push(block); input_ids.iter().for_each(|input_id| gen_connections.push(Connection { from: *input_id, to: target_id })); - // Test connections - // gen_connections.push(Connection {from: 1, to: 14}); - // gen_connections.push(Connection {from: 17, to: 2}); - for output in &target_config.output { - - let (block_instance, block_type) = match output { + let (block_instance, block_type) = match output { TargetOutputDto::Xtream(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Xtream(dto.clone()))), BlockType::OutputXtream), TargetOutputDto::M3u(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::M3u(dto.clone()))), BlockType::OutputM3u), TargetOutputDto::Strm(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::Strm(dto.clone()))), BlockType::OutputStrm), TargetOutputDto::HdHomeRun(dto) => (BlockInstance::Output(Rc::new(TargetOutputDto::HdHomeRun(dto.clone()))), BlockType::OutputHdHomeRun), }; - let output_id = current_id; + let output_id = current_id; current_id += 1; let block = Block { @@ -121,23 +257,36 @@ pub fn SourceEditor() -> Html { gen_connections.push(Connection { from: target_id, to: output_id }); } } - } layout(&mut gen_blocks, &gen_connections); get_next_id.set(current_id); - blocks_set.set(gen_blocks); - connections_set.set(gen_connections); } - ||{} + || {} }); } + + let handle_layout = { + let blocks_clone = blocks.clone(); + let connections_clone = connections.clone(); + let force_update = force_update.clone(); + Callback::from(move |_| { + let mut new_blocks = blocks_clone.borrow_mut(); + let new_connections = connections_clone.borrow_mut(); + layout(&mut new_blocks, &new_connections); + + force_update.set(*force_update + 1); + }) + }; + // ----------------- Drag Start from Sidebar ----------------- let handle_drag_start = { - let sidebar_drag_offset = sidebar_drag_offset.clone(); + let drag_state = drag_state.clone(); let cursor_grabbing = cursor_grabbing.clone(); + let selection_state = selection_state.clone(); Callback::from(move |e: DragEvent| { + selection_state.set((*selection_state).reset_selection()); if let Some(target) = e.target_dyn_into::() { let block_type = target.get_attribute("data-block-type").unwrap_or_default(); e.data_transfer().unwrap().set_data("text/plain", &block_type).unwrap(); @@ -145,18 +294,19 @@ pub fn SourceEditor() -> Html { let rect = target.get_bounding_client_rect(); let offset_x = e.client_x() as f32 - rect.left() as f32; let offset_y = e.client_y() as f32 - rect.top() as f32; - sidebar_drag_offset.set((offset_x, offset_y)); + drag_state.set((*drag_state).with_sidebar_drag_offset(offset_x, offset_y)); cursor_grabbing.set(true); } } - )}; + ) + }; // ----------------- Drop on Canvas ----------------- let handle_drop = { let blocks = blocks.clone(); let next_id = next_id.clone(); let canvas_ref = canvas_ref.clone(); - let sidebar_drag_offset = sidebar_drag_offset.clone(); + let drag_state = drag_state.clone(); let canvas_offset = canvas_offset.clone(); // <-- add canvas_offset let cursor_grabbing = cursor_grabbing.clone(); @@ -168,12 +318,12 @@ pub fn SourceEditor() -> Html { let rect = canvas.get_bounding_client_rect(); let mouse_x = e.client_x() as f32 - rect.left() as f32; let mouse_y = e.client_y() as f32 - rect.top() as f32; - let (offset_x, offset_y) = *sidebar_drag_offset; + let (offset_x, offset_y) = drag_state.sidebar_drag_offset; let (ox, oy) = *canvas_offset; // <-- include canvas offset let block_type = BlockType::from(data.as_str()); - let mut current_blocks = (*blocks).clone(); + let mut current_blocks = blocks.borrow_mut(); current_blocks.push(Block { id: *next_id, block_type, @@ -183,7 +333,6 @@ pub fn SourceEditor() -> Html { ), instance: create_instance(block_type), }); - blocks.set(current_blocks); next_id.set(*next_id + 1); } } @@ -207,7 +356,7 @@ pub fn SourceEditor() -> Html { let canvas_offset = canvas_offset.clone(); Callback::from(move |from_id: BlockId| { pending_connection.set(Some(from_id)); - if let Some(block) = (*blocks).get(from_id as usize -1) { + if let Some(block) = blocks.borrow().get(from_id as usize - 1) { let (ox, oy) = *canvas_offset; let x = block.position.0 + BLOCK_WIDTH + ox; let y = block.position.1 + BLOCK_MIDDLE_Y + oy; @@ -224,82 +373,116 @@ pub fn SourceEditor() -> Html { Callback::from(move |to_id: BlockId| { if let Some(from_id) = *pending_connection { if from_id != to_id { - let current_blocks = (*blocks).clone(); + let current_blocks = blocks.borrow_mut(); if let (Some(from_block), Some(to_block)) = ( - current_blocks.get(from_id as usize -1), - current_blocks.get(to_id as usize -1), + current_blocks.get(from_id as usize - 1), + current_blocks.get(to_id as usize - 1), ) { - // ✅ Check connection rules before adding - if can_connect(from_block, to_block, &connections, &blocks) { - let mut current_connections = (*connections).clone(); + // Check connection rules before adding + if can_connect(from_block, to_block, &connections.borrow(), ¤t_blocks) { + let mut current_connections = connections.borrow_mut(); current_connections.push(Connection { from: from_id, to: to_id }); - connections.set(current_connections); - //} else { - // web_sys::console::log_1( - // &format!( - // "Connection from {:?} to {:?} not allowed", - // from_block.block_type, to_block.block_type - // ) - // .into(), - // ); } } } } - // // Snap pending line end to target port - // if let Some(to_block) = (*blocks).iter().find(|b| b.id == to_id) { - // let x = to_block.position.0; - // let y = to_block.position.1 + BLOCK_MIDDLE_Y; - // if let Some(((from_x, from_y), _)) = *pending_line { - // pending_line.set(Some(((from_x, from_y), (x, y)))); - // } - // } - pending_connection.set(None); pending_line.set(None); }) }; - // ----------------- Drag block logic ----------------- + // ----------------- Drag block logic ----------------- let handle_block_mouse_down = { - let dragging_block = dragging_block.clone(); - let drag_offset = drag_offset.clone(); + let drag_state = drag_state.clone(); let canvas_ref = canvas_ref.clone(); let blocks = blocks.clone(); let cursor_grabbing = cursor_grabbing.clone(); + let selection_state = selection_state.clone(); Callback::from(move |(block_id, e): (BlockId, MouseEvent)| { e.prevent_default(); + let ctrl_key = e.ctrl_key(); if let Some(canvas) = canvas_ref.cast::() { cursor_grabbing.set(true); let rect = canvas.get_bounding_client_rect(); let mouse_x = e.client_x() as f32 - rect.left() as f32; let mouse_y = e.client_y() as f32 - rect.top() as f32; - if let Some(block) = (*blocks).get(block_id as usize -1) { - drag_offset.set((mouse_x - block.position.0, mouse_y - block.position.1)); - dragging_block.set(Some(block_id)); + if let Some(block) = blocks.borrow().get(block_id as usize - 1) { + // For Group-Drag: save all blocks + let mut initials = vec![]; + let mut group_ids = vec![]; + + // if block is not in selection list, than only select this block + let mut new_selection = None; + if !selection_state.selected_blocks.contains(&block_id) { + new_selection = Some(block_id); + } else { + let current_blocks = blocks.borrow(); + for id in &selection_state.selected_blocks { + if let Some(b) = current_blocks.get(*id as usize - 1) { + initials.push((*id, b.position)); + group_ids.push(*id); + } + } + } + + drag_state.set((*drag_state).with_drag_block_offset_and_group(block_id, (mouse_x - block.position.0, mouse_y - block.position.1), group_ids)); + + let new_blocks = match new_selection { + Some(block) => if ctrl_key { + let mut new_list = selection_state.selected_blocks.clone(); + new_list.push(block); + new_list + } else { vec![block] }, + None => selection_state.selected_blocks.clone(), + }; + selection_state.set((*selection_state).with_blocks_group_initial_pos_and_group_anchor_mouse(new_blocks, initials, (mouse_x, mouse_y))); } } }) }; - // ----------------- Canvas mouse down (start panning if clicking the canvas itself) ----------------- + // ----------------- Canvas mouse down (start panning or marquee selection) ----------------- let handle_canvas_mouse_down = { let is_panning = is_panning.clone(); + let selection_state = selection_state.clone(); let pan_start = pan_start.clone(); let canvas_ref = canvas_ref.clone(); let cursor_grabbing = cursor_grabbing.clone(); Callback::from(move |e: MouseEvent| { - // Start panning with right mouse button if the click target is exactly the canvas element. - if e.button() == 2 { - if let Some(target) = e.target_dyn_into::() { - if let Some(canvas) = canvas_ref.cast::() { - let tag = target.tag_name().to_lowercase(); - // accept both the canvas
itself and its background as valid pan targets - if target.is_same_node(Some(&canvas)) || tag == "svg" { - e.prevent_default(); + let mouse_button = e.button(); + if mouse_button != 0 && mouse_button != 2 { + return; + } + if let Some(target) = e.target_dyn_into::() { + if let Some(canvas) = canvas_ref.cast::() { + let tag = target.tag_name().to_lowercase(); + if target.is_same_node(Some(&canvas)) || tag == "svg" { + e.prevent_default(); + + if e.button() == 0 { // left button + if selection_state.is_selecting { + selection_state.set((*selection_state).reset_selection()); + } else { + // selection area mode + if let Some(rect_el) = canvas_ref.cast::() { + let rect = rect_el.get_bounding_client_rect(); + let mouse_x = e.client_x() as f32 - rect.left() as f32; + let mouse_y = e.client_y() as f32 - rect.top() as f32; + if e.ctrl_key() { + selection_state.set((*selection_state) + .with_selecting_start_and_rect(true, (mouse_x, mouse_y), Some((mouse_x, mouse_y, 0.0, 0.0)))); + } else { + selection_state.set((*selection_state) + .with_selecting_start_rect_and_blocks(true, (mouse_x, mouse_y), Some((mouse_x, mouse_y, 0.0, 0.0)), vec![])); + } + } + } + } else if e.button() == 2 { // right button + selection_state.set((*selection_state).reset_selection()); + // Right button panning cursor_grabbing.set(true); is_panning.set(true); pan_start.set((e.client_x() as f32, e.client_y() as f32)); @@ -310,42 +493,52 @@ pub fn SourceEditor() -> Html { }) }; - // ----------------- Mouse move for both pending line, block drag, and canvas panning ----------------- + // ----------------- Mouse move for pending line, block drag, canvas panning, marquee update ----------------- let handle_canvas_mouse_move = { let pending_line = pending_line.clone(); - let dragging_block = dragging_block.clone(); - let drag_offset = drag_offset.clone(); + let drag_state = drag_state.clone(); let blocks = blocks.clone(); + let connections = connections.clone(); let canvas_ref = canvas_ref.clone(); let is_panning = is_panning.clone(); + let selection_state = selection_state.clone(); let pan_start = pan_start.clone(); let canvas_offset = canvas_offset.clone(); + let last_frame = RefCell::new(0.0); + let block_elements_ref = block_elements.clone(); + let conn_elements_ref = connection_elements.clone(); Callback::from(move |e: MouseEvent| { - // If currently panning the canvas, update the canvas offset and exit early. + let now = web_sys::js_sys::Date::now(); + if now - *last_frame.borrow() < 16.0 { return; } + *last_frame.borrow_mut() = now; + + let client_x = e.client_x(); + let client_y = e.client_y(); + + // Panning (right mouse) if *is_panning { let (start_x, start_y) = *pan_start; - let dx = e.client_x() as f32 - start_x; - let dy = e.client_y() as f32 - start_y; + let dx = client_x as f32 - start_x; + let dy = client_y as f32 - start_y; let (ox, oy) = *canvas_offset; canvas_offset.set((ox + dx, oy + dy)); - // Update pan start for smooth continuous panning - pan_start.set((e.client_x() as f32, e.client_y() as f32)); + pan_start.set((client_x as f32, client_y as f32)); return; } if let Some(canvas) = canvas_ref.cast::() { let rect = canvas.get_bounding_client_rect(); - let mouse_x = e.client_x() as f32 - rect.left() as f32; - let mouse_y = e.client_y() as f32 - rect.top() as f32; + let mouse_x = client_x as f32 - rect.left() as f32; + let mouse_y = client_y as f32 - rect.top() as f32; - // Update pending line (snap to nearest port if close) + // Pending line snap if let Some(((from_x, from_y), _)) = *pending_line { let mut snapped = (mouse_x, mouse_y); let (ox, oy) = *canvas_offset; - for block in (*blocks).iter() { + for block in blocks.borrow().iter() { let port_x = block.position.0 + ox; - let port_y = block.position.1 + BLOCK_MIDDLE_Y + oy; + let port_y = block.position.1 + BLOCK_MIDDLE_Y + oy; let dx = mouse_x - port_x; let dy = mouse_y - port_y; let dist = (dx * dx + dy * dy).sqrt(); @@ -356,29 +549,148 @@ pub fn SourceEditor() -> Html { pending_line.set(Some(((from_x, from_y), snapped))); } - // Update dragging block - if let Some(block_id) = *dragging_block { - let (offset_x, offset_y) = *drag_offset; - let mut current_blocks = (*blocks).clone(); - if let Some(block) = current_blocks.get_mut(block_id as usize -1) { - block.position = (mouse_x - offset_x, mouse_y - offset_y); + if selection_state.is_selecting { + // compute normalized rect + let (start_x, start_y) = selection_state.selection_start; + let x = start_x.min(mouse_x); + let y = start_y.min(mouse_y); + let w = (mouse_x - start_x).abs(); + let h = (mouse_y - start_y).abs(); + let ctrl_key = e.ctrl_key(); + + // Update selected_blocks: block intersects rect? + let mut sel = Vec::new(); + if ctrl_key { + selection_state.selected_blocks.iter().for_each(|b| sel.push(*b)); + } + blocks.borrow().iter().filter(|b| b.intersects_rect((x, y), (x + w, y + h), *canvas_offset)) + .for_each(|b| sel.push(b.id)); + + selection_state.set((*selection_state).with_rect_and_blocks(Some((x, y, w, h)), sel)); + } + + // Update dragging block (Single or Group) + if let Some(block_id) = drag_state.block_id { + let mut block_elements = block_elements_ref.borrow_mut(); + if block_elements.is_empty() { + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + for block_id in &drag_state.dragging_group { + if let Some(el) = document.get_element_by_id(&format!("block-{block_id}")) { + let div = el.dyn_into::().unwrap(); + block_elements.insert(*block_id, div); + } + } + + if let std::collections::hash_map::Entry::Vacant(e) = block_elements.entry(block_id) { + if let Some(el) = document.get_element_by_id(&format!("block-{block_id}")) { + let div = el.dyn_into::().unwrap(); + e.insert(div); + } + } + + let mut conn_elements = conn_elements_ref.borrow_mut(); + // find connections + for conn in connections.borrow().iter() { + if block_id == conn.from || block_id == conn.to + || drag_state.dragging_group.contains(&conn.from) || drag_state.dragging_group.contains(&conn.to) { + if let Some(el) = document.get_element_by_id(&format!("conn-{}-{}", conn.from, conn.to)) { + let path_el = el.dyn_into::().unwrap(); + conn_elements.insert((conn.from, conn.to), path_el); + } + } + } + } + } + } + + let mut moved_blocks = HashSet::new(); + + // If the dragged block is member of a selection -> move group + if (*drag_state.dragging_group).contains(&block_id) && !selection_state.group_initial_positions.is_empty() { + let (anchor_x, anchor_y) = selection_state.group_anchor_mouse; + let dx = mouse_x - anchor_x; + let dy = mouse_y - anchor_y; + + let (ox, oy) = *canvas_offset; + let mut current_blocks = blocks.borrow_mut(); + for (id, (ix, iy)) in &selection_state.group_initial_positions { + if let Some(b) = current_blocks.get_mut(*id as usize - 1) { + b.position = (ix + dx, iy + dy); + moved_blocks.insert(b.id); + if let Some(div) = block_elements.get(&b.id) { + div.style().set_property("transform", &format!("translate({}px,{}px)", ix + dx + ox, iy + dy + oy)).unwrap(); + } + } + } + } else { + // Single drag block + let (ox, oy) = *canvas_offset; + let (offset_x, offset_y) = drag_state.drag_offset; + let mut current_blocks = blocks.borrow_mut(); + if let Some(block) = current_blocks.get_mut(block_id as usize - 1) { + block.position = (mouse_x - offset_x, mouse_y - offset_y); + moved_blocks.insert(block.id); + if let Some(div) = block_elements.get(&block_id) { + div.style().set_property("transform", &format!("translate({}px,{}px)", mouse_x - offset_x + ox, mouse_y - offset_y + oy)).unwrap(); + } + } + } + + if !moved_blocks.is_empty() { + let mut move_connections = HashMap::::new(); + for conn in connections.borrow().iter() { + if moved_blocks.contains(&conn.from) || moved_blocks.contains(&conn.to) { + move_connections.insert(format!("conn-{}-{}", conn.from, conn.to), (conn.from, conn.to)); + } + } + let current_blocks = blocks.borrow(); + let conn_elements = conn_elements_ref.borrow(); + let (ox, oy) = *canvas_offset; + for (from, to) in move_connections.values() { + if let Some(path_el) = conn_elements.get(&(*from, *to)) { + if let (Some(from_block), Some(to_block)) = + (¤t_blocks.get(*from as usize - 1), ¤t_blocks.get(*to as usize - 1)) { + let from_x = from_block.position.0 + BLOCK_WIDTH + ox; + let from_y = from_block.position.1 + BLOCK_MIDDLE_Y + oy; + let to_x = to_block.position.0 + ox; + let to_y = to_block.position.1 + BLOCK_MIDDLE_Y + oy; + let dx = to_x - from_x; + let ctrl = dx * 0.5; + let new_d = format!( + "M {} {} C {} {}, {} {}, {} {}", + from_x, from_y, + from_x + ctrl, from_y, + to_x - ctrl, to_y, + to_x, to_y + ); + path_el.set_attribute("d", &new_d).unwrap(); + } + } + } } - blocks.set(current_blocks); } } }) }; - let handle_canvas_mouse_up = { - let dragging_block = dragging_block.clone(); + let drag_state = drag_state.clone(); let is_panning = is_panning.clone(); + let selection_state = selection_state.clone(); let cursor_grabbing = cursor_grabbing.clone(); + let block_elements = block_elements.clone(); + let connection_elements = connection_elements.clone(); Callback::from(move |_e: MouseEvent| { + block_elements.borrow_mut().clear(); + connection_elements.borrow_mut().clear(); // Stop any block dragging and stop panning - dragging_block.set(None); + if drag_state.block_id.is_some() { + drag_state.set((*drag_state).reset_dragging()); + } is_panning.set(false); cursor_grabbing.set(false); + selection_state.set((*selection_state).with_selecting_rect_and_group_initial_positions(false, None, vec![])); }) }; @@ -398,25 +710,28 @@ pub fn SourceEditor() -> Html { Callback::from(move |_| delete_mode.set(!*delete_mode)) }; + // Deleting a Block means updating the following block ids, + // because a BlockId is the index in the blocks list. let handle_delete_block = { let blocks = blocks.clone(); let connections = connections.clone(); let next_id = next_id.clone(); + let selection_state = selection_state.clone(); Callback::from(move |block_id: BlockId| { - let mut current_blocks = (*blocks).clone(); + let mut current_blocks = blocks.borrow_mut(); current_blocks.retain(|b| b.id != block_id); - let mut current_connections = (*connections).clone(); + let mut current_connections = connections.borrow_mut(); current_connections.retain(|c| c.from != block_id && c.to != block_id); - for block in &mut current_blocks { + for block in current_blocks.iter_mut() { if block.id >= block_id { block.id -= 1; } } - // Schritt 2: Connections anpassen - for conn in &mut current_connections { + // udpate connection ids + for conn in current_connections.iter_mut() { if conn.from >= block_id { conn.from -= 1; } @@ -426,30 +741,28 @@ pub fn SourceEditor() -> Html { } let max_id = current_blocks.iter().map(|b| b.id).max().unwrap_or(0); - next_id.set(max_id+1); - blocks.set(current_blocks); - connections.set(current_connections); + next_id.set(max_id + 1); + + selection_state.set((*selection_state).with_cleared_blocks(block_id)); }) }; let handle_delete_connection = { let connections = connections.clone(); Callback::from(move |(from, to): (BlockId, BlockId)| { - let mut current_connections = (*connections).clone(); - current_connections.retain(|c| !(c.from == from && c.to == to)); - connections.set(current_connections); + connections.borrow_mut().retain(|c| !(c.from == from && c.to == to)); }) }; let get_port_status = { |block: &Block| { if let Some(from_id) = *pending_connection { - if let Some(from_block) = (*blocks).get(from_id as usize -1) { - return if can_connect(from_block, block, &connections, &blocks) { + if let Some(from_block) = blocks.borrow().get(from_id as usize - 1) { + return if can_connect(from_block, block, &connections.borrow(), &blocks.borrow()) { PortStatus::Valid } else { PortStatus::Invalid - } + }; } } PortStatus::Inactive @@ -459,11 +772,10 @@ pub fn SourceEditor() -> Html { let form_changed = { let blocks = blocks.clone(); Callback::<(BlockId, BlockInstance)>::from(move |(block_id, instance): (BlockId, BlockInstance)| { - let mut current_blocks = (*blocks).clone(); - if let Some(block) = current_blocks.get_mut(block_id as usize -1) { - block.instance = instance; - } - blocks.set(current_blocks); + let mut current_blocks = blocks.borrow_mut(); + if let Some(block) = current_blocks.get_mut(block_id as usize - 1) { + block.instance = instance; + } }) }; @@ -472,9 +784,11 @@ pub fn SourceEditor() -> Html { let handle_block_edit = { let edit_mode_set = edit_mode.clone(); let blocks = blocks.clone(); + let selection_state = selection_state.clone(); Callback::from(move |block_id: BlockId| { - if let Some(block) = (*blocks).get(block_id as usize -1 ) { + if let Some(block) = blocks.borrow().get(block_id as usize - 1) { edit_mode_set.set(EditMode::Active(block.clone())); + selection_state.set((*selection_state).reset_selection()); } }) }; @@ -484,11 +798,12 @@ pub fn SourceEditor() -> Html { edit_mode: edit_mode.clone(), }; - let selected_block_id = match *edit_mode { + let edited_block_id = match *edit_mode { EditMode::Inactive => 0, EditMode::Active(ref b) => b.id, }; let grabbed = *cursor_grabbing; + let selection_mode = selection_state.is_selecting; // ----------------- Render ----------------- html! { context={editor_context}> @@ -498,12 +813,13 @@ pub fn SourceEditor() -> Html { delete_mode={*delete_mode} on_drag_start={handle_drag_start.clone()} on_toggle_delete={handle_toggle_delete_mode.clone()} + on_layout={handle_layout.clone()} /> // Canvas
Html { oncontextmenu={handle_canvas_right_click.clone()}> // SVG for connections - - { for (*connections).iter().filter_map(|c| { - let from_block = (*blocks).iter().find(|b| b.id == c.from)?; - let to_block = (*blocks).iter().find(|b| b.id == c.to)?; + + { for connections.borrow().iter().filter_map(|c| { + let current_blocks = blocks.borrow(); + let from_block = current_blocks.get(c.from as usize -1)?; + let to_block = current_blocks.get(c.to as usize -1)?; let (ox, oy) = *canvas_offset; // Apply virtual canvas offset let from_x = from_block.position.0 + BLOCK_WIDTH + ox; let from_y = from_block.position.1 + BLOCK_MIDDLE_Y + oy; @@ -534,7 +851,7 @@ pub fn SourceEditor() -> Html { Some(html! { - + { if *delete_mode { let mid_x = (from_x + to_x) / 2.0; let mid_y = (from_y + to_y) / 2.0; @@ -555,7 +872,7 @@ pub fn SourceEditor() -> Html { }) }) } - // Pending line (straight, yellow) + // Pending line straight { if let Some(((x1, y1), (x2, y2))) = *pending_line { html! { Html { } else { html!{} } } - // Render blocks - { for (*blocks).iter().map(|b|{ + // Selection rectangle overlay + { + if let Some((x, y, w, h)) = selection_state.selection_rect { + let style = format!("position:absolute; left:{}px; top:{}px; width:{}px; height:{}px; pointer-events:none;", x, y, w, h); + html! { +
+ } + } else { + html! {} + } + } + + // Render blocks with canvas offset + { for blocks.borrow().iter().map(|b|{ let port_status = get_port_status(b); let (ox, oy) = *canvas_offset; // Apply virtual offset to each block let mut shifted_block = b.clone(); let block_id = shifted_block.id; shifted_block.position = (b.position.0 + ox, b.position.1 + oy); + let is_block_selected = selection_state.selected_blocks.contains(&block_id); html! { Html { > } } + diff --git a/frontend/src/app/components/source_editor/layout.rs b/frontend/src/app/components/source_editor/layout.rs index ceb0428cc..108494bfe 100644 --- a/frontend/src/app/components/source_editor/layout.rs +++ b/frontend/src/app/components/source_editor/layout.rs @@ -246,6 +246,6 @@ pub fn layout(blocks: &mut [Block], connections: &[Connection]) { let block = &mut blocks[*block_id as usize -1]; block.position = (CANVAS_OFFSET, final_y); - last_input_y = final_y + BLOCK_HEIGHT + Y_GAP; + last_input_y = final_y + BLOCK_HEIGHT + Y_GAP*2.0; } } \ No newline at end of file diff --git a/frontend/src/app/components/source_editor/sidebar.rs b/frontend/src/app/components/source_editor/sidebar.rs index 7ea19bd79..39907e663 100644 --- a/frontend/src/app/components/source_editor/sidebar.rs +++ b/frontend/src/app/components/source_editor/sidebar.rs @@ -39,6 +39,8 @@ pub struct SourceEditorSidebarProps { pub on_toggle_delete: Callback<(String, MouseEvent)>, #[prop_or_default] pub on_drag_start: Callback, + #[prop_or_default] + pub on_layout: Callback<(String, MouseEvent)>, } #[function_component] @@ -49,6 +51,7 @@ pub fn SourceEditorSidebar(props: &SourceEditorSidebarProps) -> Html { // Sidebar
+ // Delete mode toggle button
diff --git a/shared/src/foundation/mapper.pest b/shared/src/foundation/mapper.pest index f963ec4e5..87f317535 100644 --- a/shared/src/foundation/mapper.pest +++ b/shared/src/foundation/mapper.pest @@ -18,7 +18,7 @@ block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format"} function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } diff --git a/shared/src/foundation/mapper.rs b/shared/src/foundation/mapper.rs index 82332db66..c5f34421b 100644 --- a/shared/src/foundation/mapper.rs +++ b/shared/src/foundation/mapper.rs @@ -1,21 +1,22 @@ #![allow(clippy::empty_docs)] -use crate::foundation::filter::{ValueAccessor}; +use crate::error::{create_tuliprox_error_result, info_err, TuliproxError, TuliproxErrorKind}; +use crate::foundation::filter::ValueAccessor; +use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; +use crate::model::{PatternTemplate, TemplateValue}; +use crate::utils::Capitalize; use log::{debug, trace}; use pest::iterators::{Pair, Pairs}; +use pest::Parser; use pest_derive::Parser; use regex::Regex; use std::borrow::Cow; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::fmt::Display; +use std::fmt::Write; use std::ops::Deref; use std::str::FromStr; -use pest::Parser; -use crate::error::{TuliproxError, TuliproxErrorKind, create_tuliprox_error_result, info_err}; -use crate::foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value}; -use crate::model::{PatternTemplate, TemplateValue}; -use crate::utils::Capitalize; #[derive(Parser)] #[grammar_inline = r##" @@ -39,7 +40,7 @@ block_expr = { "{" ~ statements ~ "}" } condition = { function_call | var_access | field_access } assignment = { (field_access | identifier) ~ "=" ~ expression } expression = { assignment | map_block | match_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr } -function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" } +function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" } function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" } any_match = { "_" } match_case_key = { any_match | identifier } @@ -118,6 +119,8 @@ pub enum BuiltInFunction { First, Template, Replace, + Pad, + Format, } impl FromStr for BuiltInFunction { @@ -135,6 +138,8 @@ impl FromStr for BuiltInFunction { "first" => Ok(Self::First), "template" => Ok(Self::Template), "replace" => Ok(Self::Replace), + "pad" => Ok(Self::Pad), + "format" => Ok(Self::Format), _ => create_tuliprox_error_result!(TuliproxErrorKind::Info, "Unknown function {s}"), } } @@ -153,6 +158,8 @@ impl Display for BuiltInFunction { Self::First => "first", Self::Template => "template", Self::Replace => "replace", + Self::Pad => "pad", + Self::Format => "format", }.to_owned(); write!(f, "{str}") } @@ -620,7 +627,7 @@ impl<'a> MapperContext<'a> { } Some(hash_map) } - }) + }), } } @@ -696,13 +703,18 @@ impl<'a> MapperContext<'a> { if args.len() > 1 { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts only one argument {:?}, {} given", name, args.len()); } - }, + } BuiltInFunction::Replace => { - if args.len() != 3 { + if args.len() != 3 { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three arguments {:?}, {} given", name, args.len()); } } - _ => {} + BuiltInFunction::Pad => { + if !(args.len() == 3 || args.len() == 4) { + return create_tuliprox_error_result!(TuliproxErrorKind::Info, "Function accepts three or four arguments {:?}, {} given", name, args.len()); + } + } + _ => {} } for expr_id in args { self.validate_expr(*expr_id, identifiers)?; @@ -931,8 +943,22 @@ fn concat_args(args: &Vec) -> Vec> { macro_rules! extract_evaluated_arg_value { ($evaluated_args:expr, $index:expr) => {{ - let evaluated_arg = &$evaluated_args[$index]; - match evaluated_arg { + if $index >= $evaluated_args.len() { + None + } else { + let evaluated_arg = &$evaluated_args[$index]; + match evaluated_arg { + Value(value) => Some(value), + Named(values) => values.first().map(|(_key, val)| val), + _ => None, + } + } + }}; +} + +macro_rules! extract_arg_value { + ($evaluated_arg:expr) => {{ + match $evaluated_arg { Value(value) => Some(value), Named(values) => values.first().map(|(_key, val)| val), _ => None, @@ -1122,6 +1148,98 @@ impl Expression { evaluated_args[0].clone() } } + BuiltInFunction::Pad => { + let value = match &evaluated_args[0] { + Number(value) => Some(value.to_string()), + Value(value) => Some(value.clone()), + Named(values) => values.first().map(|(_key, val)| val.clone()), + _ => None, + }; + let width = match &evaluated_args[1] { + Number(value) => { + if value.is_nan() || value.is_infinite() { + 0 + } else { + value.abs().min(usize::MAX as f64) as usize + } + } + Value(value) => value.parse::().ok().unwrap_or(0), + Named(values) => values.first().and_then(|(_key, val)| val.parse::().ok()).unwrap_or(0), + _ => 0, + }; + + let fill = match &evaluated_args[2] { + Number(value) => Some(value.to_string()), + Value(value) => Some(value.clone()), + Named(values) => values.first().map(|(_key, val)| val.clone()), + _ => None, + }; + + let align = extract_evaluated_arg_value!(evaluated_args, 3); // "<", ">", "^" + + if let Some(text) = value { + let fill_char = fill.and_then(|s| s.chars().next()).unwrap_or(' '); + + let padded = if width <= text.len() { + text.clone() + } else { + let pad = width - text.len(); + if let Some(al) = align { + match al.as_str() { + "^" => { + let left = pad / 2; + let right = pad - left; + format!( + "{}{}{}", + fill_char.to_string().repeat(left), + text, + fill_char.to_string().repeat(right) + ) + } + "<" => format!("{}{}", text, fill_char.to_string().repeat(pad)), + _ => format!("{}{}", fill_char.to_string().repeat(pad), text), + } + } else { + format!("{}{}", fill_char.to_string().repeat(pad), text) + } + }; + + Value(padded) + } else { + Undefined + } + } + BuiltInFunction::Format => { + let fmt_pattern = extract_evaluated_arg_value!(evaluated_args, 0); + + if let Some(fmt_str) = fmt_pattern { + let args: Vec> = evaluated_args.iter() + .skip(1) + .map(|v| extract_arg_value!(v)) + .collect(); + + let mut formatted = String::new(); + let mut arg_iter = args.iter(); + let mut chars = fmt_str.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '{' && chars.peek() == Some(&'}') { + chars.next(); + if let Some(Some(arg)) = arg_iter.next() { + write!(formatted, "{}", arg).unwrap(); + } else { + formatted.push_str("{}"); + } + } else { + formatted.push(ch); + } + } + + Value(formatted) + } else { + Undefined + } + } } } } @@ -1381,6 +1499,27 @@ mod tests { } "#; let mapper = MapperScript::parse(script, None).expect("Parsing failed"); - println!("Program: {mapper:?}"); + println!("Program: {mapper:?}") + } + + #[test] + fn test_mapper_format() { + let dsl = r#" + @Name = pad(1000, 10, 0); + @Title = format("Hello {} how {}", "a", "b"); + "#; + + let mapper = MapperScript::parse(dsl, None).expect("Parsing failed"); + let mut channels: Vec = vec![ + ("D", "HD"), + ].into_iter().map(|(name, quality)| PlaylistItem { header: PlaylistItemHeader { title: format!("Chanel {name} [{quality}]"), ..Default::default() } }).collect::>(); + + for pli in &mut channels { + let mut accessor = ValueAccessor { + pli, + }; + mapper.eval(&mut accessor, None); + println!("Result: {pli:?}"); + } } } diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 60bdd783b..491653cc3 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -246,7 +246,7 @@ impl ConfigTargetDto { let mut hdhomerun_needs_m3u = false; let mut hdhomerun_needs_xtream = false; - let mut strm_export_styles = vec![]; + //let mut strm_export_styles = vec![]; let mut strm_directories: Vec<&str> = vec![]; for target_output in &mut self.output { @@ -276,10 +276,10 @@ impl ConfigTargetDto { if has_username { strm_needs_xtream = true; } - if strm_export_styles.contains(&strm_output.style) { - return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); - } - strm_export_styles.push(strm_output.style); + // if strm_export_styles.contains(&strm_output.style) { + // return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export style are not allowed: {}", self.name); + // } + // strm_export_styles.push(strm_output.style); if strm_directories.contains(&strm_output.directory.as_str()) { return create_tuliprox_error_result!(TuliproxErrorKind::Info, "strm outputs with same export directory are not allowed: {}", self.name); }