use crate::puzzle::{PuzzleWorkProfileRow, puzzle_work_profile}; use crate::*; use module_custom_world::CustomWorldPublicationStatus; use module_puzzle::PuzzlePublicationStatus; const SOURCE_TYPE_PUZZLE: &str = "puzzle"; const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world"; const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop"; const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish"; const SOURCE_TYPE_MATCH3D: &str = "match3d"; const SOURCE_TYPE_SQUARE_HOLE: &str = "square-hole"; const SOURCE_TYPE_VISUAL_NOVEL: &str = "visual-novel"; const SOURCE_TYPE_BIG_FISH: &str = "big-fish"; const SOURCE_TYPE_BARK_BATTLE: &str = "bark-battle"; /// 后台作品可见性列表。 /// /// 中文注释:后台必须能看到 hidden 作品,不能复用 public_work_* view,否则隐藏后无法恢复。 #[spacetimedb::procedure] pub fn admin_list_work_visibility( ctx: &mut ProcedureContext, input: AdminWorkVisibilityListInput, ) -> AdminWorkVisibilityListProcedureResult { match ctx.try_with_tx(|tx| list_work_visibility_tx(tx, input.clone())) { Ok(entries) => AdminWorkVisibilityListProcedureResult { ok: true, entries, error_message: None, }, Err(message) => AdminWorkVisibilityListProcedureResult { ok: false, entries: Vec::new(), error_message: Some(message), }, } } /// 后台修改单个作品可见性。 #[spacetimedb::procedure] pub fn admin_update_work_visibility( ctx: &mut ProcedureContext, input: AdminWorkVisibilityUpdateInput, ) -> AdminWorkVisibilityProcedureResult { match ctx.try_with_tx(|tx| update_work_visibility_tx(tx, input.clone())) { Ok(record) => AdminWorkVisibilityProcedureResult { ok: true, record: Some(record), error_message: None, }, Err(message) => AdminWorkVisibilityProcedureResult { ok: false, record: None, error_message: Some(message), }, } } fn list_work_visibility_tx( ctx: &ReducerContext, input: AdminWorkVisibilityListInput, ) -> Result, String> { require_admin_user_id(&input.admin_user_id)?; let mut entries = Vec::new(); entries.extend(list_puzzle_work_visibility(ctx)); entries.extend(list_custom_world_work_visibility(ctx)); entries.extend(list_jump_hop_work_visibility(ctx)); entries.extend(list_wooden_fish_work_visibility(ctx)); entries.extend(list_match3d_work_visibility(ctx)); entries.extend(list_square_hole_work_visibility(ctx)); entries.extend(list_visual_novel_work_visibility(ctx)); entries.extend(list_big_fish_work_visibility(ctx)); entries.extend(list_bark_battle_work_visibility(ctx)); sort_work_visibility_entries(&mut entries); Ok(entries) } fn update_work_visibility_tx( ctx: &ReducerContext, input: AdminWorkVisibilityUpdateInput, ) -> Result { require_admin_user_id(&input.admin_user_id)?; let source_type = normalize_source_type(&input.source_type)?; let profile_id = normalize_required_text(&input.profile_id, "profileId")?; match source_type.as_str() { SOURCE_TYPE_PUZZLE => update_puzzle_work_visibility(ctx, &profile_id, input.visible), SOURCE_TYPE_CUSTOM_WORLD => { update_custom_world_work_visibility(ctx, &profile_id, input.visible) } SOURCE_TYPE_JUMP_HOP => update_jump_hop_work_visibility(ctx, &profile_id, input.visible), SOURCE_TYPE_WOODEN_FISH => { update_wooden_fish_work_visibility(ctx, &profile_id, input.visible) } SOURCE_TYPE_MATCH3D => update_match3d_work_visibility(ctx, &profile_id, input.visible), SOURCE_TYPE_SQUARE_HOLE => { update_square_hole_work_visibility(ctx, &profile_id, input.visible) } SOURCE_TYPE_VISUAL_NOVEL => { update_visual_novel_work_visibility(ctx, &profile_id, input.visible) } SOURCE_TYPE_BIG_FISH => update_big_fish_work_visibility(ctx, &profile_id, input.visible), SOURCE_TYPE_BARK_BATTLE => { update_bark_battle_work_visibility(ctx, &profile_id, input.visible) } _ => Err(format!("不支持的作品类型:{source_type}")), } } fn list_puzzle_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .puzzle_work_profile() .iter() // 中文注释:后台页签是低频管理入口,列表优先保证稳定性,避免二级索引 filter 初始化异常打爆 wasm 实例。 .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) .map(|row| puzzle_work_visibility_snapshot(&row)) .collect() } fn update_puzzle_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "拼图作品不存在".to_string())?; if row.publication_status != PuzzlePublicationStatus::Published { return Err("只能修改已发布拼图作品可见性".to_string()); } let next = PuzzleWorkProfileRow { visible, ..row }; ctx.db .puzzle_work_profile() .profile_id() .delete(&next.profile_id); ctx.db.puzzle_work_profile().insert(next); let updated = ctx .db .puzzle_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "拼图作品可见性更新失败".to_string())?; Ok(puzzle_work_visibility_snapshot(&updated)) } fn puzzle_work_visibility_snapshot(row: &PuzzleWorkProfileRow) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_PUZZLE.to_string(), work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), source_session_id: row.source_session_id.clone(), public_work_code: build_prefixed_public_work_code("PZ", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: choose_non_empty(&[row.work_title.as_str(), row.level_name.as_str()]), subtitle: "拼图关卡".to_string(), cover_image_src: row.cover_image_src.clone(), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_custom_world_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .custom_world_profile() .iter() // 中文注释:后台必须能读到所有已发布源表记录,包括已隐藏作品,因此不复用公开 view。 .filter(|row| row.publication_status == CustomWorldPublicationStatus::Published) .filter(|row| row.deleted_at.is_none()) .map(|row| custom_world_work_visibility_snapshot(&row)) .collect() } fn update_custom_world_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .custom_world_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "自定义世界作品不存在".to_string())?; if row.publication_status != CustomWorldPublicationStatus::Published || row.deleted_at.is_some() { return Err("只能修改已发布自定义世界作品可见性".to_string()); } let next = CustomWorldProfile { visible, ..row }; let snapshot = custom_world_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .custom_world_profile() .profile_id() .delete(&profile_id); ctx.db.custom_world_profile().insert(next); if let Some(gallery) = ctx .db .custom_world_gallery_entry() .profile_id() .find(&profile_id) { let next_gallery = CustomWorldGalleryEntry { visible, ..gallery }; let gallery_profile_id = next_gallery.profile_id.clone(); ctx.db .custom_world_gallery_entry() .profile_id() .delete(&gallery_profile_id); ctx.db.custom_world_gallery_entry().insert(next_gallery); } Ok(snapshot) } fn custom_world_work_visibility_snapshot(row: &CustomWorldProfile) -> AdminWorkVisibilitySnapshot { let public_work_code = row .public_work_code .clone() .unwrap_or_else(|| build_custom_world_public_work_code(&row.profile_id)); let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_CUSTOM_WORLD.to_string(), work_id: format!("custom-world:{}", row.profile_id), profile_id: row.profile_id.clone(), source_session_id: row.source_agent_session_id.clone(), public_work_code, owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.world_name.clone(), subtitle: row.subtitle.clone(), cover_image_src: row.cover_image_src.clone(), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_jump_hop_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .jump_hop_work_profile() .iter() .filter(|row| row.publication_status == JUMP_HOP_PUBLICATION_PUBLISHED) .map(|row| jump_hop_work_visibility_snapshot(&row)) .collect() } fn update_jump_hop_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .jump_hop_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "跳一跳作品不存在".to_string())?; if row.publication_status != JUMP_HOP_PUBLICATION_PUBLISHED { return Err("只能修改已发布跳一跳作品可见性".to_string()); } let next = JumpHopWorkProfileRow { visible, ..row }; let snapshot = jump_hop_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .jump_hop_work_profile() .profile_id() .delete(&profile_id); ctx.db.jump_hop_work_profile().insert(next); Ok(snapshot) } fn jump_hop_work_visibility_snapshot(row: &JumpHopWorkProfileRow) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_JUMP_HOP.to_string(), work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), public_work_code: build_prefixed_public_work_code("JH", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.work_title.clone(), subtitle: "跳一跳".to_string(), cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_wooden_fish_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .wooden_fish_work_profile() .iter() .filter(|row| row.publication_status == WOODEN_FISH_PUBLICATION_PUBLISHED) .map(|row| wooden_fish_work_visibility_snapshot(&row)) .collect() } fn update_wooden_fish_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .wooden_fish_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "敲木鱼作品不存在".to_string())?; if row.publication_status != WOODEN_FISH_PUBLICATION_PUBLISHED { return Err("只能修改已发布敲木鱼作品可见性".to_string()); } let next = WoodenFishWorkProfileRow { visible, ..row }; let snapshot = wooden_fish_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .wooden_fish_work_profile() .profile_id() .delete(&profile_id); ctx.db.wooden_fish_work_profile().insert(next); Ok(snapshot) } fn wooden_fish_work_visibility_snapshot( row: &WoodenFishWorkProfileRow, ) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_WOODEN_FISH.to_string(), work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), public_work_code: build_prefixed_public_work_code("WF", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.work_title.clone(), subtitle: "敲木鱼".to_string(), cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_match3d_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .match_3_d_work_profile() .by_match3d_work_publication_status() .filter(MATCH3D_PUBLICATION_PUBLISHED) .map(|row| match3d_work_visibility_snapshot(&row)) .collect() } fn update_match3d_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .match_3_d_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "抓大鹅作品不存在".to_string())?; if row.publication_status != MATCH3D_PUBLICATION_PUBLISHED { return Err("只能修改已发布抓大鹅作品可见性".to_string()); } let next = Match3DWorkProfileRow { visible, ..row }; let snapshot = match3d_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .match_3_d_work_profile() .profile_id() .delete(&profile_id); ctx.db.match_3_d_work_profile().insert(next); Ok(snapshot) } fn match3d_work_visibility_snapshot(row: &Match3DWorkProfileRow) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_MATCH3D.to_string(), work_id: row.profile_id.clone(), profile_id: row.profile_id.clone(), source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), public_work_code: build_prefixed_public_work_code("M3", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.game_name.clone(), subtitle: "抓大鹅".to_string(), cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_square_hole_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .square_hole_work_profile() .iter() .filter(|row| row.publication_status == SQUARE_HOLE_PUBLICATION_PUBLISHED) .map(|row| square_hole_work_visibility_snapshot(&row)) .collect() } fn update_square_hole_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .square_hole_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "方洞挑战作品不存在".to_string())?; if row.publication_status != SQUARE_HOLE_PUBLICATION_PUBLISHED { return Err("只能修改已发布方洞挑战作品可见性".to_string()); } let next = SquareHoleWorkProfileRow { visible, ..row }; let snapshot = square_hole_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .square_hole_work_profile() .profile_id() .delete(&profile_id); ctx.db.square_hole_work_profile().insert(next); Ok(snapshot) } fn square_hole_work_visibility_snapshot( row: &SquareHoleWorkProfileRow, ) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_SQUARE_HOLE.to_string(), work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), public_work_code: build_prefixed_public_work_code("SH", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.game_name.clone(), subtitle: "方洞挑战".to_string(), cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_visual_novel_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .visual_novel_work_profile() .iter() .filter(|row| row.publication_status == VISUAL_NOVEL_PUBLICATION_PUBLISHED) .map(|row| visual_novel_work_visibility_snapshot(&row)) .collect() } fn update_visual_novel_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .visual_novel_work_profile() .profile_id() .find(&profile_id.to_string()) .ok_or_else(|| "视觉小说作品不存在".to_string())?; if row.publication_status != VISUAL_NOVEL_PUBLICATION_PUBLISHED { return Err("只能修改已发布视觉小说作品可见性".to_string()); } let next = VisualNovelWorkProfileRow { visible, ..row }; let snapshot = visual_novel_work_visibility_snapshot(&next); let profile_id = next.profile_id.clone(); ctx.db .visual_novel_work_profile() .profile_id() .delete(&profile_id); ctx.db.visual_novel_work_profile().insert(next); Ok(snapshot) } fn visual_novel_work_visibility_snapshot( row: &VisualNovelWorkProfileRow, ) -> AdminWorkVisibilitySnapshot { let sort_time = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_VISUAL_NOVEL.to_string(), work_id: row.work_id.clone(), profile_id: row.profile_id.clone(), source_session_id: Some(row.source_session_id.clone()).filter(|value| !value.is_empty()), public_work_code: build_prefixed_public_work_code("VN", &row.profile_id), owner_user_id: row.owner_user_id.clone(), author_display_name: row.author_display_name.clone(), title: row.work_title.clone(), subtitle: "视觉小说".to_string(), cover_image_src: Some(row.cover_image_src.clone()).filter(|value| !value.is_empty()), visible: row.visible, published_at_micros: row .published_at .map(|value| value.to_micros_since_unix_epoch()), updated_at_micros: sort_time, } } fn list_big_fish_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .big_fish_creation_session() .iter() .filter(|row| row.stage == BigFishCreationStage::Published) .map(|row| big_fish_work_visibility_snapshot(&row)) .collect() } fn update_big_fish_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .big_fish_creation_session() .session_id() .find(&profile_id.to_string()) .ok_or_else(|| "大鱼吃小鱼作品不存在".to_string())?; if row.stage != BigFishCreationStage::Published { return Err("只能修改已发布大鱼吃小鱼作品可见性".to_string()); } let next = BigFishCreationSession { visible, ..row }; let snapshot = big_fish_work_visibility_snapshot(&next); let session_id = next.session_id.clone(); ctx.db .big_fish_creation_session() .session_id() .delete(&session_id); ctx.db.big_fish_creation_session().insert(next); Ok(snapshot) } fn big_fish_work_visibility_snapshot(row: &BigFishCreationSession) -> AdminWorkVisibilitySnapshot { let published_at = row .published_at .map(|value| value.to_micros_since_unix_epoch()); let updated_at = timestamp_sort_micros(row.published_at, row.updated_at); AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_BIG_FISH.to_string(), work_id: row.session_id.clone(), profile_id: row.session_id.clone(), source_session_id: Some(row.session_id.clone()), public_work_code: build_prefixed_public_work_code("BF", &row.session_id), owner_user_id: row.owner_user_id.clone(), author_display_name: "玩家".to_string(), title: "大鱼吃小鱼".to_string(), subtitle: "成长挑战".to_string(), cover_image_src: None, visible: row.visible, published_at_micros: published_at, updated_at_micros: updated_at, } } fn list_bark_battle_work_visibility(ctx: &ReducerContext) -> Vec { ctx.db .bark_battle_published_config() .iter() .map(|row| bark_battle_work_visibility_snapshot(&row)) .collect() } fn update_bark_battle_work_visibility( ctx: &ReducerContext, profile_id: &str, visible: bool, ) -> Result { let row = ctx .db .bark_battle_published_config() .work_id() .find(&profile_id.to_string()) .ok_or_else(|| "汪汪声浪作品不存在".to_string())?; let next = BarkBattlePublishedConfigRow { visible, ..row }; ctx.db.bark_battle_published_config().delete(next.clone()); ctx.db.bark_battle_published_config().insert(next.clone()); Ok(bark_battle_work_visibility_snapshot(&next)) } fn bark_battle_work_visibility_snapshot( row: &BarkBattlePublishedConfigRow, ) -> AdminWorkVisibilitySnapshot { AdminWorkVisibilitySnapshot { source_type: SOURCE_TYPE_BARK_BATTLE.to_string(), work_id: row.work_id.clone(), profile_id: row.work_id.clone(), source_session_id: row.source_draft_id.clone(), public_work_code: build_bark_battle_public_work_code(&row.work_id), owner_user_id: row.owner_user_id.clone(), author_display_name: "玩家".to_string(), title: "汪汪声浪".to_string(), subtitle: row.difficulty_preset.clone(), cover_image_src: None, visible: row.visible, published_at_micros: Some(row.published_at.to_micros_since_unix_epoch()), updated_at_micros: timestamp_sort_micros(Some(row.published_at), row.updated_at), } } fn require_admin_user_id(value: &str) -> Result<(), String> { normalize_required_text(value, "adminUserId").map(|_| ()) } fn normalize_source_type(value: &str) -> Result { let normalized = normalize_required_text(value, "sourceType")? .to_ascii_lowercase() .replace('_', "-"); let source_type = match normalized.as_str() { "match-3-d" | "match-3d" | "match3-d" => SOURCE_TYPE_MATCH3D, other => other, }; Ok(source_type.to_string()) } fn normalize_required_text(value: &str, field_name: &str) -> Result { let normalized = value.trim(); if normalized.is_empty() { return Err(format!("{field_name} 不能为空")); } Ok(normalized.to_string()) } fn sort_work_visibility_entries(entries: &mut [AdminWorkVisibilitySnapshot]) { entries.sort_by(|left, right| { right .updated_at_micros .cmp(&left.updated_at_micros) .then_with(|| left.source_type.cmp(&right.source_type)) .then_with(|| left.profile_id.cmp(&right.profile_id)) }); } fn timestamp_sort_micros(published_at: Option, updated_at: Timestamp) -> i64 { published_at .unwrap_or(updated_at) .to_micros_since_unix_epoch() } fn build_prefixed_public_work_code(prefix: &str, value: &str) -> String { let normalized = normalize_public_code_text(value); let fallback = if normalized.is_empty() { "00000000".to_string() } else { normalized }; let suffix = last_eight_padded(&fallback); format!("{prefix}-{suffix}") } fn build_bark_battle_public_work_code(work_id: &str) -> String { let normalized = normalize_public_code_text(work_id); let without_prefix = normalized .strip_prefix("BB") .map(ToString::to_string) .unwrap_or_else(|| normalized.clone()); let fallback = if without_prefix.is_empty() { if normalized.is_empty() { "00000000".to_string() } else { normalized } } else { without_prefix }; format!("BB-{}", last_eight_padded(&fallback)) } fn normalize_public_code_text(value: &str) -> String { value .trim() .chars() .filter(|character| character.is_ascii_alphanumeric()) .flat_map(char::to_uppercase) .collect() } fn last_eight_padded(value: &str) -> String { let suffix = value .chars() .rev() .take(8) .collect::>() .into_iter() .rev() .collect::(); format!("{suffix:0>8}") } fn choose_non_empty(values: &[&str]) -> String { values .iter() .map(|value| value.trim()) .find(|value| !value.is_empty()) .unwrap_or_default() .to_string() }