Skip to content

Commit c8b59c1

Browse files
committed
Merge branch 'collaborator'
2 parents 9af38e9 + ada9d9e commit c8b59c1

4 files changed

Lines changed: 192 additions & 3 deletions

File tree

phira/locales/en-US/song.ftl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,16 @@ info-composer = Composer
7878
info-charter = Charter
7979
info-difficulty = Difficulty
8080
info-desc = Description
81+
info-collaborators = Collaborators
8182
info-rating = Rating
8283
info-type = Type
8384
info-tags = Tags
8485
86+
collab-autocomplete-title = Complete Collaborators
87+
collab-autocomplete-content = Detected collaborator(s) missing user IDs: { $mentions }. Auto-complete?
88+
collab-autocomplete-failed = Failed to resolve "@{ $name }": user not found or ambiguous.
89+
collab-autocomplete-done = Collaborators completed.
90+
8591
reviewed = Reviewed
8692
unreviewed = Unreviewed
8793

phira/locales/zh-CN/song.ftl

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,16 @@ info-composer = 曲师
7676
info-charter = 谱师
7777
info-difficulty = 难度
7878
info-desc = 简介
79+
info-collaborators = 协作者
7980
info-rating = 评分
8081
info-type = 种类
8182
info-tags = 标签
8283
84+
collab-autocomplete-title = 协作者补全
85+
collab-autocomplete-content = 检测到缺少用户 ID 的协作者:{ $mentions },是否自动补全?
86+
collab-autocomplete-failed = 无法解析 "@{ $name }":用户不存在或有多个匹配。
87+
collab-autocomplete-done = 协作者已补全
88+
8389
reviewed = 已审核
8490
unreviewed = 未审核
8591

phira/locales/zh-TW/song.ftl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,16 @@ info-composer = 曲師
6565
info-charter = 譜師
6666
info-difficulty = 難度
6767
info-desc = 簡介
68+
info-collaborators = 協作者
6869
info-rating = 評分
6970
info-type = 類型
7071
info-tags = 標籤
72+
73+
collab-autocomplete-title = 協作者補全
74+
collab-autocomplete-content = 偵測到缺少使用者 ID 的協作者:{ $mentions },是否自動補全?
75+
collab-autocomplete-failed = 無法解析「@{ $name }」:使用者不存在或有多個匹配。
76+
collab-autocomplete-done = 協作者已補全
77+
7178
reviewed = 已審核
7279
unreviewed = 未審核
7380
review-approve = 通過

phira/src/scene/song.rs

Lines changed: 173 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use super::{
88
use crate::{
99
charts_view::NEED_UPDATE,
1010
client::{
11-
basic_client_builder, recv_raw, Chart, ChartRef, ChartRefChartInfo, Client, Collection, CollectionUpdate, Permissions, Ptr, Record,
11+
basic_client_builder, recv_raw, Chart, ChartRef, ChartRefChartInfo, Client, Collection, CollectionUpdate, Permissions, Ptr, Record, User,
1212
UserManager, CLIENT_TOKEN,
1313
},
1414
data::{BriefChartInfo, LocalChart},
@@ -31,6 +31,7 @@ use core::f32;
3131
use futures_util::StreamExt;
3232
use inputbox::{InputBox, InputMode};
3333
use macroquad::prelude::*;
34+
use once_cell::sync::Lazy;
3435
use phira_mp_common::{ClientCommand, CompactPos, JudgeEvent, TouchFrame};
3536
use prpr::{
3637
config::Mods,
@@ -50,6 +51,7 @@ use prpr::{
5051
time::TimeManager,
5152
ui::{button_hit, render_chart_info, ChartInfoEdit, DRectButton, Dialog, LoadingParams, LongTouchState, RectButton, Scroll, Ui, UI_AUDIO},
5253
};
54+
use regex::Regex;
5355
use reqwest::Method;
5456
use sanitize_filename::sanitize;
5557
use sasa::{AudioClip, Frame, Music, MusicParams};
@@ -59,7 +61,7 @@ use sha2::{Digest, Sha256};
5961
use std::{
6062
any::Any,
6163
borrow::Cow,
62-
collections::{hash_map, HashMap, VecDeque},
64+
collections::{hash_map, BTreeMap, HashMap, VecDeque},
6365
fs::File,
6466
io::{BufWriter, Cursor, Seek, Write},
6567
path::Path,
@@ -83,8 +85,57 @@ static CONFIRM_CKSUM: AtomicBool = AtomicBool::new(false);
8385
static UPLOAD_NOT_SAVED: AtomicBool = AtomicBool::new(false);
8486
static CONFIRM_OVERWRITE: AtomicBool = AtomicBool::new(false);
8587
static CONFIRM_UPLOAD: AtomicBool = AtomicBool::new(false);
88+
static CONFIRM_AUTOCOMPLETE: AtomicBool = AtomicBool::new(false);
89+
static SKIP_AUTOCOMPLETE: AtomicBool = AtomicBool::new(false);
8690
pub static RECORD_ID: AtomicI32 = AtomicI32::new(-1);
8791

92+
/// Matches any `@name#id (role)` or `@name#id` or `@name (role)` or `@name`.
93+
/// Parentheses may be ASCII `()` or fullwidth `()`; whitespace before `(` is optional.
94+
/// Groups: 1=name, 2=id (optional), 3=role (optional)
95+
static MENTION_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"@([^\s#@((]+)(?:#(\d+))?(?:\s*[((]([^))]+)[))])?").unwrap());
96+
97+
/// Parse all `@name#id` resolved collaborator mentions and return `(id, role)` pairs.
98+
fn parse_collaborators(intro: &str) -> BTreeMap<i32, Option<String>> {
99+
use std::collections::btree_map::Entry;
100+
101+
let mut result = BTreeMap::new();
102+
for (id, role) in MENTION_RE.captures_iter(intro).filter_map(|cap| {
103+
let id: i32 = cap.get(2)?.as_str().parse().ok()?;
104+
let role = cap.get(3).map(|m| m.as_str().to_owned());
105+
Some((id, role))
106+
}) {
107+
match result.entry(id) {
108+
Entry::Vacant(e) => {
109+
e.insert(role);
110+
}
111+
Entry::Occupied(mut e) => {
112+
if e.get().is_none() && role.is_some() {
113+
e.insert(role);
114+
}
115+
}
116+
}
117+
}
118+
result
119+
}
120+
121+
/// Find all unresolved `@name` or `@name (role)` mentions (those missing `#id`).
122+
/// Returns `(start, end, name)` byte-offset pairs into `intro`.
123+
/// Processing right-to-left preserves earlier offsets during replacement.
124+
fn find_unresolved_mentions(intro: &str) -> Vec<(usize, usize, String)> {
125+
MENTION_RE
126+
.captures_iter(intro)
127+
.filter_map(|cap| {
128+
if cap.get(2).is_some() {
129+
// Already has #id — resolved
130+
return None;
131+
}
132+
let m = cap.get(0)?;
133+
let name = cap.get(1)?.as_str().to_owned();
134+
Some((m.start(), m.end(), name))
135+
})
136+
.collect()
137+
}
138+
88139
fn fade_in_time() -> Option<f32> {
89140
if get_data().prefer_reduced_motion {
90141
None
@@ -357,6 +408,9 @@ pub struct SongScene {
357408

358409
confirm_cancel_edit: Arc<AtomicBool>,
359410

411+
collaborators: BTreeMap<i32, Option<String>>,
412+
autocomplete_task: Option<Task<Result<String>>>,
413+
360414
export_task: Option<mpsc::Receiver<Result<()>>>,
361415
}
362416

@@ -544,6 +598,9 @@ impl SongScene {
544598

545599
confirm_cancel_edit: Arc::default(),
546600

601+
collaborators: BTreeMap::new(),
602+
autocomplete_task: None,
603+
547604
export_task: None,
548605
}
549606
}
@@ -1099,7 +1156,7 @@ impl SongScene {
10991156
}
11001157
r.x += dx;
11011158
if ui.button("save", r, tl!("edit-save")) {
1102-
self.save_edit();
1159+
self.try_save_with_autocomplete();
11031160
}
11041161

11051162
ui.ensure_touches()
@@ -1204,6 +1261,34 @@ impl SongScene {
12041261
}
12051262
dy!(0.14);
12061263
}
1264+
if !self.collaborators.is_empty() {
1265+
dy!(ui.text(tl!("info-collaborators")).size(0.4).color(semi_white(0.7)).draw().h + 0.02);
1266+
for (collab_id, role) in &self.collaborators {
1267+
let c = 0.06;
1268+
let s = 0.05;
1269+
let r = ui.avatar(c, c, s, rt, UserManager::opt_avatar(*collab_id, &self.icons.user));
1270+
if let Some((name, color)) = UserManager::name_and_color(*collab_id) {
1271+
let name_r = ui
1272+
.text(name)
1273+
.pos(r.right() + 0.02, r.center().y - if role.is_some() { 0.01 } else { 0. })
1274+
.anchor(0., 0.5)
1275+
.no_baseline()
1276+
.max_width(width - 0.15)
1277+
.size(0.5)
1278+
.color(color)
1279+
.draw();
1280+
if let Some(role_text) = role {
1281+
ui.text(role_text.as_str())
1282+
.pos(r.right() + 0.02, name_r.bottom() + 0.005)
1283+
.size(0.35)
1284+
.color(semi_white(0.6))
1285+
.draw();
1286+
}
1287+
}
1288+
dy!(0.14);
1289+
}
1290+
}
1291+
12071292
let mut item = |title: Cow<'_, str>, content: Cow<'_, str>| {
12081293
dy!(ui.text(title).size(0.4).color(semi_white(0.7)).draw().h + 0.02);
12091294
dy!(ui.text(content).pos(pad, 0.).size(0.6).multiline().max_width(mw).draw().h + 0.03);
@@ -1347,6 +1432,61 @@ impl SongScene {
13471432
}));
13481433
}
13491434

1435+
fn try_save_with_autocomplete(&mut self) {
1436+
let intro = &self.info_edit.as_ref().unwrap().info.intro;
1437+
let unresolved = find_unresolved_mentions(intro);
1438+
if unresolved.is_empty() {
1439+
self.save_edit();
1440+
} else {
1441+
let mentions_list = unresolved
1442+
.iter()
1443+
.map(|(start, end, _)| &intro[*start..*end])
1444+
.collect::<Vec<_>>()
1445+
.join(", ");
1446+
let content = tl!("collab-autocomplete-content", "mentions" => mentions_list);
1447+
Dialog::plain(tl!("collab-autocomplete-title"), content)
1448+
.buttons(vec![ttl!("cancel").into_owned(), ttl!("confirm").into_owned()])
1449+
.listener(|_dialog, pos| {
1450+
if pos == 1 {
1451+
CONFIRM_AUTOCOMPLETE.store(true, Ordering::SeqCst);
1452+
} else if pos == 0 {
1453+
SKIP_AUTOCOMPLETE.store(true, Ordering::SeqCst);
1454+
}
1455+
false
1456+
})
1457+
.show();
1458+
}
1459+
}
1460+
1461+
fn start_autocomplete(&mut self) {
1462+
let intro = self.info_edit.as_ref().unwrap().info.intro.clone();
1463+
self.autocomplete_task = Some(Task::new(async move {
1464+
let unresolved = find_unresolved_mentions(&intro);
1465+
// Resolve each mention (bail on first failure), then apply right-to-left
1466+
// so earlier byte offsets stay valid.
1467+
let mut resolved: Vec<(usize, usize, String)> = Vec::new();
1468+
for (start, end, name) in unresolved {
1469+
let name_owned = name.clone();
1470+
let (users, _) = Client::query::<User>().search(name_owned).send().await?;
1471+
let matched = users.into_iter().find(|u| u.name == name);
1472+
let Some(user) = matched else {
1473+
bail!(tl!("collab-autocomplete-failed", "name" => name));
1474+
};
1475+
// Replacement: insert `#id` right after `@name`, keep the rest of
1476+
// the original match (bracket/role if any).
1477+
let suffix = &intro[start + 1 + name.len()..end];
1478+
let new_text = format!("@{}#{}{}", name, user.id, suffix);
1479+
resolved.push((start, end, new_text));
1480+
}
1481+
// Apply right-to-left so replacement lengths don't shift pending offsets.
1482+
let mut result = intro.into_bytes();
1483+
for (start, end, new_text) in resolved.into_iter().rev() {
1484+
result.splice(start..end, new_text.into_bytes());
1485+
}
1486+
Ok(String::from_utf8(result).unwrap())
1487+
}));
1488+
}
1489+
13501490
fn update_chart_info(&self) -> Result<()> {
13511491
Self::global_update_chart_info(self.local_path.as_ref().unwrap(), self.info.clone())
13521492
}
@@ -1524,6 +1664,7 @@ impl Scene for SongScene {
15241664
|| self.update_cksum_task.is_some()
15251665
|| self.toggle_fav_task.is_some()
15261666
|| self.export_task.is_some()
1667+
|| self.autocomplete_task.is_some()
15271668
{
15281669
return Ok(true);
15291670
}
@@ -1692,6 +1833,10 @@ impl Scene for SongScene {
16921833
if let Some(uploader) = &self.info.uploader {
16931834
UserManager::request(uploader.id);
16941835
}
1836+
self.collaborators = parse_collaborators(&self.info.intro);
1837+
for id in self.collaborators.keys() {
1838+
UserManager::request(*id);
1839+
}
16951840
self.side_content = SideContent::Info;
16961841
self.side_enter_time = tm.real_time() as _;
16971842
return Ok(true);
@@ -1748,6 +1893,30 @@ impl Scene for SongScene {
17481893
if self.side_enter_time < 0. && -tm.real_time() as f32 + edit_transit().unwrap_or_default() < self.side_enter_time {
17491894
self.side_enter_time = f32::INFINITY;
17501895
}
1896+
if CONFIRM_AUTOCOMPLETE.fetch_and(false, Ordering::SeqCst) {
1897+
self.start_autocomplete();
1898+
}
1899+
if SKIP_AUTOCOMPLETE.fetch_and(false, Ordering::SeqCst) {
1900+
self.save_edit();
1901+
}
1902+
if let Some(task) = &mut self.autocomplete_task {
1903+
if let Some(res) = task.take() {
1904+
self.autocomplete_task = None;
1905+
match res {
1906+
Err(err) => {
1907+
show_error(err);
1908+
}
1909+
Ok(new_intro) => {
1910+
if let Some(edit) = self.info_edit.as_mut() {
1911+
edit.info.intro = new_intro;
1912+
edit.updated = true;
1913+
}
1914+
show_message(tl!("collab-autocomplete-done")).duration(1.).ok();
1915+
self.save_edit();
1916+
}
1917+
}
1918+
}
1919+
}
17511920
if let Some(task) = &mut self.load_task {
17521921
if let Some(res) = task.take() {
17531922
match res {
@@ -2644,6 +2813,7 @@ impl Scene for SongScene {
26442813
|| self.overwrite_task.is_some()
26452814
|| self.update_cksum_task.is_some()
26462815
|| self.toggle_fav_task.is_some()
2816+
|| self.autocomplete_task.is_some()
26472817
{
26482818
ui.full_loading("", t);
26492819
}

0 commit comments

Comments
 (0)