1use crate::map::types::OpCode;
15use regex::Regex;
16use scraper::{Html, Selector};
17use serde::{Deserialize, Serialize};
18use std::sync::OnceLock;
19
20const DRAG_PLATFORMS_JSON: &str = include_str!("drag_platforms.json");
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub enum DragLibrary {
30 ReactBeautifulDnd,
32 SortableJS,
34 AngularCdk,
36 DndKit,
38 JQueryUI,
40 Html5Native,
42 Unknown,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ApiEndpoint {
49 pub url: String,
51 pub method: String,
53 pub body_template: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct DragAction {
60 pub drag_library: DragLibrary,
62 pub draggable_selector: String,
64 pub source_id_attr: String,
66 pub drop_zone_selector: String,
68 pub target_id_attr: String,
70 pub api_endpoint: Option<ApiEndpoint>,
72 pub position_param: String,
74 pub opcode: OpCode,
76 pub confidence: f32,
78}
79
80#[derive(Debug, Clone, Deserialize)]
84struct PlatformDragConfig {
85 drag_type: String,
87 api: PlatformDragApi,
89 source_selector: String,
91 source_id: Option<String>,
93 target_selector: Option<String>,
95 target_id: Option<String>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
101struct PlatformDragApi {
102 method: String,
104 path: String,
106 body: Option<serde_json::Value>,
108}
109
110type DragPlatformRegistry = std::collections::HashMap<String, PlatformDragConfig>;
111
112fn drag_platform_registry() -> &'static DragPlatformRegistry {
114 static REGISTRY: OnceLock<DragPlatformRegistry> = OnceLock::new();
115 REGISTRY.get_or_init(|| serde_json::from_str(DRAG_PLATFORMS_JSON).unwrap_or_default())
116}
117
118pub fn has_known_drag(domain: &str) -> bool {
122 let registry = drag_platform_registry();
123 registry.contains_key(domain)
124 || registry
125 .keys()
126 .any(|k| domain.ends_with(k.as_str()) || k.contains(domain))
127}
128
129pub fn discover_drag_actions(html: &str, js_bundles: &[String]) -> Vec<DragAction> {
148 let mut actions = Vec::new();
149
150 if let Some(domain) = extract_domain_from_html(html) {
152 let platform_actions = discover_drag_from_platform(&domain);
153 if !platform_actions.is_empty() {
154 return platform_actions;
155 }
156 }
157
158 let library = detect_drag_library(html, js_bundles);
160 if library == DragLibrary::Unknown {
161 return actions;
162 }
163
164 let (draggable_selector, source_id_attr, drop_zone_selector, target_id_attr) =
166 find_drag_elements(html, &library);
167
168 let api_endpoint = scan_js_for_drag_api(js_bundles);
169
170 let confidence = compute_confidence(&library, &api_endpoint, &draggable_selector);
171
172 actions.push(DragAction {
173 drag_library: library,
174 draggable_selector,
175 source_id_attr,
176 drop_zone_selector,
177 target_id_attr,
178 api_endpoint,
179 position_param: "position".to_string(),
180 opcode: OpCode::new(0x07, 0x00),
181 confidence,
182 });
183
184 actions
185}
186
187pub fn detect_drag_library(html: &str, js_bundles: &[String]) -> DragLibrary {
215 let document = Html::parse_document(html);
217
218 if let Ok(sel) = Selector::parse("[data-rbd-draggable-id]") {
220 if document.select(&sel).next().is_some() {
221 return DragLibrary::ReactBeautifulDnd;
222 }
223 }
224
225 if let Ok(sel) = Selector::parse("[cdkDrag], [cdkdrag]") {
227 if document.select(&sel).next().is_some() {
228 return DragLibrary::AngularCdk;
229 }
230 }
231
232 if let Ok(sel) = Selector::parse(".ui-sortable") {
234 if document.select(&sel).next().is_some() {
235 return DragLibrary::JQueryUI;
236 }
237 }
238
239 if let Ok(sel) = Selector::parse(".sortable") {
241 if document.select(&sel).next().is_some() {
242 return DragLibrary::SortableJS;
243 }
244 }
245
246 let js_combined: String = js_bundles
248 .iter()
249 .map(|s| s.as_str())
250 .collect::<Vec<_>>()
251 .join("\n");
252
253 if js_combined.contains("DragDropContext") || js_combined.contains("data-rbd-draggable-id") {
254 return DragLibrary::ReactBeautifulDnd;
255 }
256
257 if js_combined.contains("useDraggable") || js_combined.contains("@dnd-kit") {
258 return DragLibrary::DndKit;
259 }
260
261 if js_combined.contains("Sortable.create") || js_combined.contains("new Sortable") {
262 return DragLibrary::SortableJS;
263 }
264
265 if js_combined.contains("cdkDrag") || js_combined.contains("CdkDragDrop") {
266 return DragLibrary::AngularCdk;
267 }
268
269 if let Ok(sel) = Selector::parse("[draggable=\"true\"]") {
271 if document.select(&sel).next().is_some() {
272 return DragLibrary::Html5Native;
273 }
274 }
275
276 DragLibrary::Unknown
277}
278
279pub fn discover_drag_from_platform(domain: &str) -> Vec<DragAction> {
294 let registry = drag_platform_registry();
295
296 let config = registry.get(domain).or_else(|| {
298 registry
299 .iter()
300 .find(|(key, _)| domain.ends_with(key.as_str()))
301 .map(|(_, v)| v)
302 });
303
304 let config = match config {
305 Some(c) => c,
306 None => return Vec::new(),
307 };
308
309 let body_template = config
310 .api
311 .body
312 .as_ref()
313 .map(|b| serde_json::to_string(b).unwrap_or_default());
314
315 let api_endpoint = ApiEndpoint {
316 url: config.api.path.clone(),
317 method: config.api.method.clone(),
318 body_template,
319 };
320
321 vec![DragAction {
322 drag_library: DragLibrary::Unknown,
323 draggable_selector: config.source_selector.clone(),
324 source_id_attr: config.source_id.clone().unwrap_or_default(),
325 drop_zone_selector: config.target_selector.clone().unwrap_or_default(),
326 target_id_attr: config.target_id.clone().unwrap_or_default(),
327 api_endpoint: Some(api_endpoint),
328 position_param: "position".to_string(),
329 opcode: OpCode::new(0x07, 0x00),
330 confidence: 0.95,
331 }]
332}
333
334fn scan_js_for_drag_api(js_bundles: &[String]) -> Option<ApiEndpoint> {
341 let js_combined: String = js_bundles
342 .iter()
343 .map(|s| s.as_str())
344 .collect::<Vec<_>>()
345 .join("\n");
346
347 let handler_name_re = Regex::new(
349 r"(?:onDragEnd|handleDrop|onSortEnd|dropHandler|onDragStop)\s*(?:=\s*(?:async\s*)?(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>|[:=]\s*(?:async\s+)?function\s*\([^)]*\))\s*\{"
350 ).ok()?;
351
352 let fetch_re = Regex::new(
354 r#"fetch\(\s*['"`]([^'"`]+)['"`]\s*(?:,\s*\{[^}]*method\s*:\s*['"`](\w+)['"`])?"#,
355 )
356 .ok()?;
357 let axios_re =
358 Regex::new(r#"axios\.(get|post|put|patch|delete)\(\s*['"`]([^'"`]+)['"`]"#).ok()?;
359 let ajax_re = Regex::new(r#"\$\.ajax\(\s*\{([^}]*)\}"#).ok()?;
360 let url_re = Regex::new(r#"url\s*:\s*['"`]([^'"`]+)['"`]"#).ok()?;
361 let type_re = Regex::new(r#"type\s*:\s*['"`](\w+)['"`]"#).ok()?;
362
363 for m in handler_name_re.find_iter(&js_combined) {
364 let body = extract_brace_body(&js_combined[m.end()..]);
366
367 if let Some(fetch_caps) = fetch_re.captures(&body) {
369 let url = fetch_caps.get(1).map_or("", |m| m.as_str()).to_string();
370 let method = fetch_caps
371 .get(2)
372 .map_or("POST", |m| m.as_str())
373 .to_uppercase();
374
375 let body_template = extract_body_template(&body);
376
377 return Some(ApiEndpoint {
378 url,
379 method,
380 body_template,
381 });
382 }
383
384 if let Some(axios_caps) = axios_re.captures(&body) {
386 let method = axios_caps
387 .get(1)
388 .map_or("POST", |m| m.as_str())
389 .to_uppercase();
390 let url = axios_caps.get(2).map_or("", |m| m.as_str()).to_string();
391 let body_template = extract_body_template(&body);
392
393 return Some(ApiEndpoint {
394 url,
395 method,
396 body_template,
397 });
398 }
399
400 if let Some(ajax_caps) = ajax_re.captures(&body) {
402 let ajax_block = ajax_caps.get(1).map_or("", |m| m.as_str());
403
404 if let Some(url_caps) = url_re.captures(ajax_block) {
405 let url = url_caps.get(1).map_or("", |m| m.as_str()).to_string();
406 let method = type_re
407 .captures(ajax_block)
408 .and_then(|c| c.get(1))
409 .map_or("POST", |m| m.as_str())
410 .to_uppercase();
411
412 return Some(ApiEndpoint {
413 url,
414 method,
415 body_template: None,
416 });
417 }
418 }
419 }
420
421 None
422}
423
424fn extract_brace_body(s: &str) -> String {
429 let mut depth: u32 = 1;
430 let mut in_string = false;
431 let mut string_char: char = '"';
432 let mut prev_char = '\0';
433
434 for (i, ch) in s.char_indices() {
435 if in_string {
436 if ch == string_char && prev_char != '\\' {
437 in_string = false;
438 }
439 prev_char = ch;
440 continue;
441 }
442
443 match ch {
444 '"' | '\'' | '`' => {
445 in_string = true;
446 string_char = ch;
447 }
448 '{' => depth += 1,
449 '}' => {
450 depth -= 1;
451 if depth == 0 {
452 return s[..i].to_string();
453 }
454 }
455 _ => {}
456 }
457 prev_char = ch;
458 }
459
460 s.to_string()
461}
462
463fn extract_body_template(js_body: &str) -> Option<String> {
467 let stringify_re = Regex::new(r"JSON\.stringify\(\s*(\{[^}]+\})").ok()?;
469 if let Some(caps) = stringify_re.captures(js_body) {
470 return Some(caps.get(1).map_or("", |m| m.as_str()).to_string());
471 }
472
473 let body_obj_re = Regex::new(r"body\s*:\s*(\{[^}]+\})").ok()?;
475 if let Some(caps) = body_obj_re.captures(js_body) {
476 return Some(caps.get(1).map_or("", |m| m.as_str()).to_string());
477 }
478
479 None
480}
481
482fn extract_domain_from_html(html: &str) -> Option<String> {
484 let document = Html::parse_document(html);
485
486 if let Ok(sel) = Selector::parse("base[href]") {
488 if let Some(el) = document.select(&sel).next() {
489 if let Some(href) = el.value().attr("href") {
490 if let Some(domain) = domain_from_url(href) {
491 return Some(domain);
492 }
493 }
494 }
495 }
496
497 if let Ok(sel) = Selector::parse("link[rel=\"canonical\"]") {
499 if let Some(el) = document.select(&sel).next() {
500 if let Some(href) = el.value().attr("href") {
501 if let Some(domain) = domain_from_url(href) {
502 return Some(domain);
503 }
504 }
505 }
506 }
507
508 if let Ok(sel) = Selector::parse("meta[property=\"og:url\"]") {
510 if let Some(el) = document.select(&sel).next() {
511 if let Some(content) = el.value().attr("content") {
512 if let Some(domain) = domain_from_url(content) {
513 return Some(domain);
514 }
515 }
516 }
517 }
518
519 None
520}
521
522fn domain_from_url(url_str: &str) -> Option<String> {
524 url::Url::parse(url_str)
525 .ok()
526 .and_then(|u| u.host_str().map(String::from))
527}
528
529fn find_drag_elements(html: &str, library: &DragLibrary) -> (String, String, String, String) {
533 let document = Html::parse_document(html);
534
535 match library {
536 DragLibrary::ReactBeautifulDnd => {
537 let draggable_sel = "[data-rbd-draggable-id]".to_string();
538 let source_id = "data-rbd-draggable-id".to_string();
539 let drop_zone_sel = "[data-rbd-droppable-id]".to_string();
540 let target_id = "data-rbd-droppable-id".to_string();
541 (draggable_sel, source_id, drop_zone_sel, target_id)
542 }
543 DragLibrary::SortableJS => {
544 let draggable_sel = ".sortable > *".to_string();
546 let source_id = find_data_id_attr(&document, ".sortable > *");
547 let drop_zone_sel = ".sortable".to_string();
548 let target_id = find_data_id_attr(&document, ".sortable");
549 (draggable_sel, source_id, drop_zone_sel, target_id)
550 }
551 DragLibrary::AngularCdk => {
552 let draggable_sel = "[cdkDrag], [cdkdrag]".to_string();
553 let source_id = find_data_id_attr(&document, "[cdkDrag], [cdkdrag]");
554 let drop_zone_sel = "[cdkDropList], [cdkdroplist]".to_string();
555 let target_id = find_data_id_attr(&document, "[cdkDropList], [cdkdroplist]");
556 (draggable_sel, source_id, drop_zone_sel, target_id)
557 }
558 DragLibrary::DndKit => {
559 let draggable_sel = "[data-dnd-draggable]".to_string();
560 let source_id = "data-dnd-draggable".to_string();
561 let drop_zone_sel = "[data-dnd-droppable]".to_string();
562 let target_id = "data-dnd-droppable".to_string();
563 (draggable_sel, source_id, drop_zone_sel, target_id)
564 }
565 DragLibrary::JQueryUI => {
566 let draggable_sel = ".ui-sortable > *".to_string();
567 let source_id = find_data_id_attr(&document, ".ui-sortable > *");
568 let drop_zone_sel = ".ui-sortable".to_string();
569 let target_id = find_data_id_attr(&document, ".ui-sortable");
570 (draggable_sel, source_id, drop_zone_sel, target_id)
571 }
572 DragLibrary::Html5Native => {
573 let draggable_sel = "[draggable=\"true\"]".to_string();
574 let source_id = find_data_id_attr(&document, "[draggable=\"true\"]");
575 let drop_zone_sel = "[data-drop-zone], [ondrop]".to_string();
576 let target_id = find_data_id_attr(&document, "[data-drop-zone], [ondrop]");
577 (draggable_sel, source_id, drop_zone_sel, target_id)
578 }
579 DragLibrary::Unknown => (
580 "".to_string(),
581 "".to_string(),
582 "".to_string(),
583 "".to_string(),
584 ),
585 }
586}
587
588fn find_data_id_attr(document: &Html, selector_str: &str) -> String {
590 let sel = match Selector::parse(selector_str) {
591 Ok(s) => s,
592 Err(_) => return "id".to_string(),
593 };
594
595 if let Some(el) = document.select(&sel).next() {
596 for attr in el.value().attrs() {
598 let (name, _) = attr;
599 if name.starts_with("data-") && (name.ends_with("-id") || name == "data-id") {
600 return name.to_string();
601 }
602 }
603 if el.value().attr("id").is_some() {
605 return "id".to_string();
606 }
607 }
608
609 "id".to_string()
610}
611
612fn compute_confidence(
614 library: &DragLibrary,
615 api_endpoint: &Option<ApiEndpoint>,
616 draggable_selector: &str,
617) -> f32 {
618 let mut confidence = 0.0f32;
619
620 confidence += match library {
622 DragLibrary::ReactBeautifulDnd => 0.40,
623 DragLibrary::SortableJS => 0.35,
624 DragLibrary::AngularCdk => 0.35,
625 DragLibrary::DndKit => 0.35,
626 DragLibrary::JQueryUI => 0.30,
627 DragLibrary::Html5Native => 0.20,
628 DragLibrary::Unknown => 0.0,
629 };
630
631 if api_endpoint.is_some() {
633 confidence += 0.40;
634 }
635
636 if !draggable_selector.is_empty() {
638 confidence += 0.15;
639 }
640
641 confidence.min(1.0)
642}
643
644#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
651 fn test_detect_react_beautiful_dnd() {
652 let html = r#"
653 <html>
654 <body>
655 <div data-rbd-droppable-id="list-1">
656 <div data-rbd-draggable-id="item-1" data-rbd-drag-handle-draggable-id="item-1">
657 <span>Task 1</span>
658 </div>
659 <div data-rbd-draggable-id="item-2" data-rbd-drag-handle-draggable-id="item-2">
660 <span>Task 2</span>
661 </div>
662 </div>
663 </body>
664 </html>
665 "#;
666
667 let library = detect_drag_library(html, &[]);
668 assert_eq!(library, DragLibrary::ReactBeautifulDnd);
669
670 let js =
672 vec!["import { DragDropContext, Droppable } from 'react-beautiful-dnd';".to_string()];
673 let library_js = detect_drag_library("<html><body></body></html>", &js);
674 assert_eq!(library_js, DragLibrary::ReactBeautifulDnd);
675 }
676
677 #[test]
678 fn test_detect_sortablejs() {
679 let html = r#"
680 <html>
681 <body>
682 <ul class="sortable" id="task-list">
683 <li data-item-id="1">Item 1</li>
684 <li data-item-id="2">Item 2</li>
685 <li data-item-id="3">Item 3</li>
686 </ul>
687 </body>
688 </html>
689 "#;
690
691 let js = vec!["var sortable = Sortable.create(document.getElementById('task-list'), { animation: 150 });".to_string()];
692
693 let library = detect_drag_library(html, &js);
694 assert_eq!(library, DragLibrary::SortableJS);
695 }
696
697 #[test]
698 fn test_discover_drag_from_platform_trello() {
699 let actions = discover_drag_from_platform("trello.com");
700 assert!(!actions.is_empty());
701
702 let action = &actions[0];
703 assert_eq!(action.opcode, OpCode::new(0x07, 0x00));
704 assert!(action.confidence > 0.0);
705 assert!(action.api_endpoint.is_some());
706
707 let api = action.api_endpoint.as_ref().unwrap();
708 assert_eq!(api.method, "PUT");
709 assert!(api.url.contains("/cards/"));
710 }
711
712 #[test]
713 fn test_scan_js_for_drag_api() {
714 let js_with_handler = r#"
715 const onDragEnd = async (result) => {
716 if (!result.destination) return;
717 const { source, destination } = result;
718 await fetch('/api/reorder', {
719 method: 'PUT',
720 headers: { 'Content-Type': 'application/json' },
721 body: JSON.stringify({ itemId: source.index, newPosition: destination.index })
722 });
723 };
724 "#;
725
726 let bundles = vec![js_with_handler.to_string()];
727 let endpoint = scan_js_for_drag_api(&bundles);
728 assert!(endpoint.is_some());
729
730 let ep = endpoint.unwrap();
731 assert_eq!(ep.url, "/api/reorder");
732 assert_eq!(ep.method, "PUT");
733 assert!(ep.body_template.is_some());
734 }
735
736 #[test]
737 fn test_empty_html() {
738 let actions = discover_drag_actions("", &[]);
739 assert!(actions.is_empty());
740 }
741
742 #[test]
743 fn test_detect_angular_cdk() {
744 let html = r#"
745 <html>
746 <body>
747 <div cdkDropList>
748 <div cdkDrag data-item-id="a1">Item A</div>
749 <div cdkDrag data-item-id="a2">Item B</div>
750 </div>
751 </body>
752 </html>
753 "#;
754
755 let library = detect_drag_library(html, &[]);
756 assert_eq!(library, DragLibrary::AngularCdk);
757 }
758
759 #[test]
760 fn test_detect_jquery_ui() {
761 let html = r#"
762 <html>
763 <body>
764 <ul class="ui-sortable">
765 <li class="ui-sortable-handle" data-task-id="t1">Task 1</li>
766 <li class="ui-sortable-handle" data-task-id="t2">Task 2</li>
767 </ul>
768 </body>
769 </html>
770 "#;
771
772 let library = detect_drag_library(html, &[]);
773 assert_eq!(library, DragLibrary::JQueryUI);
774 }
775
776 #[test]
777 fn test_detect_dnd_kit_from_js() {
778 let js = vec!["import { useDraggable, useDroppable } from '@dnd-kit/core';".to_string()];
779 let library = detect_drag_library("<html><body></body></html>", &js);
780 assert_eq!(library, DragLibrary::DndKit);
781 }
782
783 #[test]
784 fn test_detect_html5_native() {
785 let html = r#"
786 <html>
787 <body>
788 <div draggable="true" data-item-id="x1">Drag me</div>
789 <div draggable="true" data-item-id="x2">Drag me too</div>
790 <div data-drop-zone="zone-1" ondrop="handleDrop(event)">Drop here</div>
791 </body>
792 </html>
793 "#;
794
795 let library = detect_drag_library(html, &[]);
796 assert_eq!(library, DragLibrary::Html5Native);
797 }
798
799 #[test]
800 fn test_discover_drag_from_platform_unknown() {
801 let actions = discover_drag_from_platform("unknown-site.example.org");
802 assert!(actions.is_empty());
803 }
804
805 #[test]
806 fn test_discover_drag_actions_with_rbd_and_api() {
807 let html = r#"
808 <html>
809 <head><link rel="canonical" href="https://myapp.example.com/board" /></head>
810 <body>
811 <div data-rbd-droppable-id="col-1">
812 <div data-rbd-draggable-id="card-1">Card 1</div>
813 <div data-rbd-draggable-id="card-2">Card 2</div>
814 </div>
815 </body>
816 </html>
817 "#;
818
819 let js = vec![r#"
820 const onDragEnd = (result) => {
821 fetch('/api/cards/reorder', {
822 method: 'POST',
823 body: JSON.stringify({ cardId: result.draggableId, column: result.destination.droppableId })
824 });
825 };
826 "#.to_string()];
827
828 let actions = discover_drag_actions(html, &js);
829 assert!(!actions.is_empty());
830
831 let action = &actions[0];
832 assert_eq!(action.drag_library, DragLibrary::ReactBeautifulDnd);
833 assert_eq!(action.draggable_selector, "[data-rbd-draggable-id]");
834 assert_eq!(action.drop_zone_selector, "[data-rbd-droppable-id]");
835 assert!(action.api_endpoint.is_some());
836 assert!(action.confidence > 0.5);
837 }
838
839 #[test]
840 fn test_extract_domain_from_html() {
841 let html = r#"
842 <html>
843 <head>
844 <base href="https://trello.com/b/abc123" />
845 </head>
846 <body></body>
847 </html>
848 "#;
849
850 let domain = extract_domain_from_html(html);
851 assert_eq!(domain, Some("trello.com".to_string()));
852 }
853
854 #[test]
855 fn test_compute_confidence_ranges() {
856 let high = compute_confidence(
858 &DragLibrary::ReactBeautifulDnd,
859 &Some(ApiEndpoint {
860 url: "/api/reorder".to_string(),
861 method: "POST".to_string(),
862 body_template: None,
863 }),
864 "[data-rbd-draggable-id]",
865 );
866 assert!(high >= 0.90, "expected >= 0.90, got {high}");
867
868 let low = compute_confidence(&DragLibrary::Unknown, &None, "");
870 assert!((low - 0.0).abs() < f32::EPSILON, "expected 0.0, got {low}");
871 }
872}