1use uuid::Uuid;
10
11use super::*;
12use crate::preview::PreviewFormat;
13
14pub const MAX_PREVIEW_CANDIDATES: usize = 8;
16const MAX_OTHER_FILES: usize = 20;
18const MAX_LISTED_FILES: usize = 20;
20
21#[derive(Debug, PartialEq, Eq)]
23pub(super) enum Target {
24 Item {
26 number: String,
27 revision: Option<String>,
28 },
29 Search {
31 query: String,
32 revision: Option<String>,
33 },
34}
35
36pub(super) fn target(a: &Args<'_>) -> AppResult<Target> {
39 let revision = a.opt_str("revision")?;
40 if let Some(reference) = a.opt_str("item")? {
41 let (number, code) = parse_item_ref(&reference);
42 if number.is_empty() {
43 return Err(bad("`item` must be an item number, e.g. PRT-000012."));
44 }
45 let revision = match (code, revision) {
46 (Some(c), Some(r)) if !c.eq_ignore_ascii_case(&r) => {
47 return Err(bad(format!(
48 "`item` names revision {c} but `revision` is {r} – give one."
49 )))
50 }
51 (Some(c), _) => Some(c),
52 (None, r) => r,
53 };
54 return Ok(Target::Item { number, revision });
55 }
56 match a.opt_str("q")? {
57 Some(query) => Ok(Target::Search { query, revision }),
58 None => Err(bad(
59 "Give `item` (an item number) or `q` (search text, e.g. a name like `bracket`).",
60 )),
61 }
62}
63
64pub(super) fn preview_format(format_id: &str, filename: &str) -> Option<PreviewFormat> {
68 PreviewFormat::of_format(format_id).or_else(|| PreviewFormat::of(filename))
69}
70
71pub(super) fn preview_rank(format: PreviewFormat) -> u8 {
74 if format.is_point_cloud() {
75 3
76 } else if format.is_result() {
77 2
78 } else if format.is_brep() {
79 0
80 } else {
81 1
82 }
83}
84
85fn ranked(files: &[FileRecord]) -> Vec<(&FileRecord, PreviewFormat)> {
88 let mut list: Vec<(&FileRecord, PreviewFormat)> = files
89 .iter()
90 .filter_map(|f| preview_format(&f.format, &f.filename).map(|p| (f, p)))
91 .collect();
92 list.sort_by(|(fa, pa), (fb, pb)| {
93 preview_rank(*pa)
94 .cmp(&preview_rank(*pb))
95 .then_with(|| fa.filename.to_lowercase().cmp(&fb.filename.to_lowercase()))
96 });
97 list
98}
99
100pub fn viewer_path(file_id: Uuid) -> String {
102 format!("/files/{file_id}/preview")
103}
104
105pub fn preview_action(url: &str) -> Value {
107 json!({ "action": "open", "kind": "preview3d", "url": url })
108}
109
110pub fn checked_viewer_path(url: &str, public_url: &str) -> Option<String> {
116 let url = url.trim();
117 let public_url = public_url.trim().trim_end_matches('/');
118 let path = if url.starts_with('/') {
119 url
120 } else if !public_url.is_empty() {
121 url.strip_prefix(public_url)
122 .filter(|rest| rest.starts_with('/'))?
123 } else {
124 return None;
125 };
126 let id = path.strip_prefix("/files/")?.strip_suffix("/preview")?;
127 if id.len() != 36 || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-') {
129 return None;
130 }
131 let id = Uuid::parse_str(id).ok()?;
132 Some(viewer_path(id))
133}
134
135pub(super) async fn open_3d_preview(st: &AppState, me: &Actor, a: &Args<'_>) -> AppResult<Value> {
137 let file_name = a.opt_str("file")?;
138 let rev = match target(a)? {
139 Target::Item { number, revision } => {
140 revision_at(st, me, &number, revision.as_deref()).await?
141 }
142 Target::Search { query, revision } => match search_target(st, me, a, &query).await? {
143 Found::One(number) => revision_at(st, me, &number, revision.as_deref()).await?,
144 Found::Choose(answer) => return Ok(answer),
145 },
146 };
147 let file_list = files::list(&st.db, me, rev.id).await?;
148 let usable = ranked(&file_list);
149 let chosen = match &file_name {
150 Some(name) => {
151 let file = file_list
152 .iter()
153 .find(|f| f.filename.eq_ignore_ascii_case(name))
154 .ok_or_else(|| {
155 AppError::not_found(format!(
156 "{}/{} has no file '{name}'.",
157 rev.item_number, rev.code
158 ))
159 })?;
160 let format = preview_format(&file.format, &file.filename).ok_or_else(|| {
161 bad(format!(
162 "'{}' has no 3D preview. Viewable are STEP, STL, OBJ, PLY, 3MF, glTF, JT, NX and \
163 SOLIDWORKS parts, Rhino, point clouds and VTK/FRD results.",
164 file.filename
165 ))
166 })?;
167 Some((file, format))
168 }
169 None => usable.first().copied(),
170 };
171 let Some((file, format)) = chosen else {
172 let (listed, cut) = capped(file_list.iter().collect::<Vec<_>>(), MAX_LISTED_FILES);
173 return Ok(json!({
174 "status": "no_3d_file",
175 "item": rev.item_number,
176 "revision": rev.code,
177 "name": rev.name,
178 "message": format!(
179 "{}/{} has no file the 3D viewer can show. Native CAD files without a stored \
180 lightweight model need a neutral export (STEP, STL, 3MF …) in the revision. Tell \
181 the user; do not open anything.",
182 rev.item_number, rev.code
183 ),
184 "files": listed.iter().map(|f| json!({
185 "filename": f.filename,
186 "format": f.format_info.as_ref().map(|i| i.label),
187 })).collect::<Vec<_>>(),
188 "truncated": cut,
189 "revision_url": web_url(st, &format!("/revisions/{}", rev.id)),
190 }));
191 };
192 let url = web_url(st, &viewer_path(file.id));
193 let (others, others_cut) = capped(
194 usable
195 .iter()
196 .filter(|(f, _)| f.id != file.id)
197 .map(|(f, p)| json!({ "filename": f.filename, "format": p.as_str() }))
198 .collect::<Vec<_>>(),
199 MAX_OTHER_FILES,
200 );
201 Ok(json!({
202 "status": "ok",
203 "item": rev.item_number,
204 "revision": rev.code,
205 "name": rev.name,
206 "state": rev.state,
207 "file": {
208 "filename": file.filename,
209 "format": format.as_str(),
210 "version": file.version,
211 },
212 "url": url,
213 "action": preview_action(&url),
214 "other_3d_files": others,
215 "truncated": others_cut,
216 "revision_url": web_url(st, &format!("/revisions/{}", rev.id)),
217 "message": "Give the user this link to the 3D viewer (TeamCraft's own assistant shows it as a \
218 button). Another file: call again with `file`.",
219 }))
220}
221
222enum Found {
223 One(String),
224 Choose(Value),
225}
226
227async fn search_target(st: &AppState, me: &Actor, a: &Args<'_>, query: &str) -> AppResult<Found> {
230 let project_id = match a.opt_str("project")? {
231 Some(code) => Some(projects::get_by_code(&st.db, me, &code).await?.id),
232 None => None,
233 };
234 let filter = items::ItemFilter {
235 query: Some(query.to_string()),
236 project_id,
237 ..Default::default()
238 };
239 let (rows, total) = items::search(
240 &st.db,
241 me,
242 &filter,
243 Page::new(Some(MAX_PREVIEW_CANDIDATES as i64), None),
244 )
245 .await?;
246 if let Some(exact) = rows
247 .iter()
248 .find(|r| r.number.eq_ignore_ascii_case(query.trim()))
249 {
250 return Ok(Found::One(exact.number.clone()));
251 }
252 match rows.as_slice() {
253 [] => Err(AppError::not_found(format!(
254 "No item matches '{query}'. Try a shorter search text (a key word of the name, e.g. \
255 `bracket`) or search_items."
256 ))),
257 [only] if total == 1 => Ok(Found::One(only.number.clone())),
258 _ => {
259 let mut candidates = Vec::with_capacity(rows.len());
260 for r in &rows {
261 let file_list = files::list(&st.db, me, r.latest_revision_id).await?;
262 let first = ranked(&file_list).first().map(|(f, _)| f.filename.clone());
263 candidates.push(json!({
264 "item": r.number,
265 "name": r.name,
266 "type": r.item_type,
267 "project": r.project_code,
268 "latest_revision": r.latest_code,
269 "latest_state": r.latest_state,
270 "has_3d": first.is_some(),
271 "3d_file": first,
272 }));
273 }
274 Ok(Found::Choose(json!({
275 "status": "choose",
276 "query": query,
277 "message": format!(
278 "{total} items match '{query}'. Do not guess: ask the user which one they mean \
279 (or narrow the search), then call again with `item`."
280 ),
281 "total": total,
282 "truncated": total > rows.len() as i64,
283 "candidates": candidates,
284 })))
285 }
286 }
287}