@@ -35,6 +35,137 @@ pub struct FlowCreateRequest {
3535 pub overwrite : bool ,
3636}
3737
38+ const FLOW_SOURCE_METADATA_FILE : & str = ".biovault-flow-source.json" ;
39+
40+ #[ derive( Debug , Clone , Serialize , Deserialize , Default ) ]
41+ #[ serde( rename_all = "camelCase" ) ]
42+ pub struct FlowSourceMetadata {
43+ pub source_url : String ,
44+ }
45+
46+ #[ derive( Debug , Clone , Serialize ) ]
47+ #[ serde( rename_all = "camelCase" ) ]
48+ pub struct FlowListItem {
49+ #[ serde( flatten) ]
50+ pub flow : Flow ,
51+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
52+ pub version : Option < String > ,
53+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
54+ pub source_url : Option < String > ,
55+ }
56+
57+ #[ derive( Debug , Clone , Serialize ) ]
58+ #[ serde( rename_all = "camelCase" ) ]
59+ pub struct FlowUpdateInfo {
60+ pub flow_id : i64 ,
61+ pub name : String ,
62+ pub source_url : String ,
63+ pub current_version : Option < String > ,
64+ pub remote_version : Option < String > ,
65+ pub update_available : bool ,
66+ pub error : Option < String > ,
67+ }
68+
69+ fn normalize_flow_url ( url : & str ) -> String {
70+ let mut raw_url = url
71+ . replace ( "github.com" , "raw.githubusercontent.com" )
72+ . replace ( "/blob/" , "/" )
73+ . replace ( "/tree/" , "/" ) ;
74+ if !raw_url. ends_with ( ".yaml" ) && !raw_url. ends_with ( ".yml" ) && !raw_url. ends_with ( ".json" ) {
75+ raw_url = format ! ( "{}/flow.yaml" , raw_url. trim_end_matches( '/' ) ) ;
76+ }
77+ raw_url
78+ }
79+
80+ pub fn write_flow_source_metadata ( flow_path : & str , source_url : & str ) -> Result < ( ) , String > {
81+ let path = PathBuf :: from ( flow_path) . join ( FLOW_SOURCE_METADATA_FILE ) ;
82+ let metadata = FlowSourceMetadata {
83+ source_url : normalize_flow_url ( source_url) ,
84+ } ;
85+ let json = serde_json:: to_string_pretty ( & metadata)
86+ . map_err ( |e| format ! ( "Failed to serialize flow source metadata: {}" , e) ) ?;
87+ fs:: write ( & path, json) . map_err ( |e| format ! ( "Failed to write flow source metadata: {}" , e) )
88+ }
89+
90+ fn read_flow_source_metadata ( flow_path : & str ) -> Option < FlowSourceMetadata > {
91+ let path = PathBuf :: from ( flow_path) . join ( FLOW_SOURCE_METADATA_FILE ) ;
92+ fs:: read_to_string ( path)
93+ . ok ( )
94+ . and_then ( |raw| serde_json:: from_str :: < FlowSourceMetadata > ( & raw ) . ok ( ) )
95+ }
96+
97+ fn known_managed_flow_source_url ( name : & str ) -> Option < & ' static str > {
98+ match name {
99+ "01_bv_paper_pca_qc_fast" => Some (
100+ "https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/01_bv_paper_pca_qc_fast/flow.yaml" ,
101+ ) ,
102+ "02_bv_paper_gnomad_projection_fast" => Some (
103+ "https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/02_bv_paper_gnomad_projection_fast/flow.yaml" ,
104+ ) ,
105+ "03_bv_paper_sex_biased_admixture_fast" => Some (
106+ "https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/03_bv_paper_sex_biased_admixture_fast/flow.yaml" ,
107+ ) ,
108+ "04_bv_paper_population_level" => Some (
109+ "https://raw.githubusercontent.com/madhavajay/BioVault_popgen/main/flows/04_bv_paper_population_level/flow.yaml" ,
110+ ) ,
111+ _ => None ,
112+ }
113+ }
114+
115+ fn is_managed_flow_path ( flow_path : & str ) -> bool {
116+ let Ok ( home) = biovault:: config:: get_biovault_home ( ) else {
117+ return false ;
118+ } ;
119+ PathBuf :: from ( flow_path) . starts_with ( home. join ( "flows" ) )
120+ }
121+
122+ fn flow_source_metadata_for ( flow : & Flow ) -> Option < FlowSourceMetadata > {
123+ read_flow_source_metadata ( & flow. flow_path ) . or_else ( || {
124+ if is_managed_flow_path ( & flow. flow_path ) {
125+ known_managed_flow_source_url ( & flow. name ) . map ( |source_url| FlowSourceMetadata {
126+ source_url : source_url. to_string ( ) ,
127+ } )
128+ } else {
129+ None
130+ }
131+ } )
132+ }
133+
134+ fn read_flow_file ( flow_path : & str ) -> Option < FlowFile > {
135+ let path = PathBuf :: from ( flow_path) . join ( FLOW_YAML_FILE ) ;
136+ fs:: read_to_string ( path)
137+ . ok ( )
138+ . and_then ( |raw| FlowFile :: parse_yaml ( & raw ) . ok ( ) )
139+ }
140+
141+ fn flow_list_item ( flow : Flow ) -> FlowListItem {
142+ let version = read_flow_file ( & flow. flow_path ) . map ( |file| file. metadata . version ) ;
143+ let source_url = flow_source_metadata_for ( & flow) . map ( |metadata| metadata. source_url ) ;
144+ FlowListItem {
145+ flow,
146+ version,
147+ source_url,
148+ }
149+ }
150+
151+ fn parse_flow_version ( value : & str ) -> Result < semver:: Version , String > {
152+ let normalized = value. trim ( ) . trim_start_matches ( 'v' ) ;
153+ semver:: Version :: parse ( normalized)
154+ . map_err ( |e| format ! ( "Invalid semantic version '{}': {}" , value, e) )
155+ }
156+
157+ fn remote_version_is_newer (
158+ current_version : Option < & str > ,
159+ remote_version : & str ,
160+ ) -> Result < bool , String > {
161+ let remote = parse_flow_version ( remote_version) ?;
162+ let Some ( current_version) = current_version else {
163+ return Ok ( true ) ;
164+ } ;
165+ let current = parse_flow_version ( current_version) ?;
166+ Ok ( remote > current)
167+ }
168+
38169#[ derive( Debug , Serialize , Deserialize ) ]
39170#[ serde( rename_all = "camelCase" ) ]
40171pub struct FlowRunSelection {
@@ -1622,11 +1753,102 @@ fn append_flow_log(window: Option<&tauri::WebviewWindow>, log_path: &Path, messa
16221753}
16231754
16241755#[ tauri:: command]
1625- pub async fn get_flows ( state : tauri:: State < ' _ , AppState > ) -> Result < Vec < Flow > , String > {
1756+ pub async fn get_flows ( state : tauri:: State < ' _ , AppState > ) -> Result < Vec < FlowListItem > , String > {
16261757 let biovault_db = state. biovault_db . lock ( ) . map_err ( |e| e. to_string ( ) ) ?;
16271758 let flows = biovault_db. list_flows ( ) . map_err ( |e| e. to_string ( ) ) ?;
16281759
1629- Ok ( flows)
1760+ Ok ( flows. into_iter ( ) . map ( flow_list_item) . collect ( ) )
1761+ }
1762+
1763+ #[ tauri:: command]
1764+ pub async fn check_flow_updates (
1765+ state : tauri:: State < ' _ , AppState > ,
1766+ ) -> Result < Vec < FlowUpdateInfo > , String > {
1767+ let flows = {
1768+ let biovault_db = state. biovault_db . lock ( ) . map_err ( |e| e. to_string ( ) ) ?;
1769+ biovault_db. list_flows ( ) . map_err ( |e| e. to_string ( ) ) ?
1770+ } ;
1771+
1772+ tauri:: async_runtime:: spawn_blocking ( move || {
1773+ let mut updates = Vec :: new ( ) ;
1774+ for flow in flows {
1775+ let Some ( source) = flow_source_metadata_for ( & flow) else {
1776+ continue ;
1777+ } ;
1778+
1779+ let current_version = read_flow_file ( & flow. flow_path ) . map ( |file| file. metadata . version ) ;
1780+ let mut info = FlowUpdateInfo {
1781+ flow_id : flow. id ,
1782+ name : flow. name ,
1783+ source_url : source. source_url . clone ( ) ,
1784+ current_version,
1785+ remote_version : None ,
1786+ update_available : false ,
1787+ error : None ,
1788+ } ;
1789+
1790+ match reqwest:: blocking:: get ( & source. source_url ) {
1791+ Ok ( response) if response. status ( ) . is_success ( ) => match response. text ( ) {
1792+ Ok ( raw) => match FlowFile :: parse_yaml ( & raw ) {
1793+ Ok ( remote) => {
1794+ let remote_version = remote. metadata . version ;
1795+ match remote_version_is_newer (
1796+ info. current_version . as_deref ( ) ,
1797+ & remote_version,
1798+ ) {
1799+ Ok ( is_newer) => {
1800+ info. update_available = is_newer;
1801+ }
1802+ Err ( e) => {
1803+ info. error = Some ( e) ;
1804+ }
1805+ }
1806+ info. remote_version = Some ( remote_version) ;
1807+ }
1808+ Err ( e) => {
1809+ info. error = Some ( format ! ( "Failed to parse remote flow.yaml: {}" , e) ) ;
1810+ }
1811+ } ,
1812+ Err ( e) => {
1813+ info. error = Some ( format ! ( "Failed to read remote flow.yaml: {}" , e) ) ;
1814+ }
1815+ } ,
1816+ Ok ( response) => {
1817+ info. error = Some ( format ! ( "HTTP {}" , response. status( ) ) ) ;
1818+ }
1819+ Err ( e) => {
1820+ info. error = Some ( format ! ( "Failed to fetch remote flow.yaml: {}" , e) ) ;
1821+ }
1822+ }
1823+
1824+ updates. push ( info) ;
1825+ }
1826+ Ok :: < _ , String > ( updates)
1827+ } )
1828+ . await
1829+ . map_err ( |e| e. to_string ( ) ) ?
1830+ }
1831+
1832+ #[ tauri:: command]
1833+ pub async fn redownload_flow (
1834+ state : tauri:: State < ' _ , AppState > ,
1835+ flow_id : i64 ,
1836+ ) -> Result < String , String > {
1837+ let flow = {
1838+ let biovault_db = state. biovault_db . lock ( ) . map_err ( |e| e. to_string ( ) ) ?;
1839+ biovault_db
1840+ . get_flow ( flow_id)
1841+ . map_err ( |e| e. to_string ( ) ) ?
1842+ . ok_or_else ( || format ! ( "Flow {} not found" , flow_id) ) ?
1843+ } ;
1844+ let source = flow_source_metadata_for ( & flow)
1845+ . ok_or_else ( || "This flow was not imported from a URL" . to_string ( ) ) ?;
1846+ let source_url = source. source_url . clone ( ) ;
1847+
1848+ let flow_path =
1849+ crate :: commands:: modules:: import_flow_with_deps ( source_url. clone ( ) , None , true ) . await ?;
1850+ write_flow_source_metadata ( & flow_path, & source_url) ?;
1851+ Ok ( flow_path)
16301852}
16311853
16321854#[ tauri:: command]
0 commit comments