11use aqbot_core:: storage_inventory:: { self , StorageInventory } ;
2+ use aqbot_core:: storage_paths;
3+ use crate :: AppState ;
4+ use serde:: Serialize ;
5+ use std:: path:: PathBuf ;
6+ use tauri:: State ;
27
38#[ tauri:: command]
49pub async fn get_storage_inventory ( ) -> Result < StorageInventory , String > {
@@ -7,9 +12,182 @@ pub async fn get_storage_inventory() -> Result<StorageInventory, String> {
712
813#[ tauri:: command]
914pub async fn open_storage_directory ( app : tauri:: AppHandle ) -> Result < ( ) , String > {
10- let root = aqbot_core :: storage_paths:: documents_root ( ) ;
15+ let root = storage_paths:: documents_root ( ) ;
1116 use tauri_plugin_opener:: OpenerExt ;
1217 app. opener ( )
1318 . reveal_item_in_dir ( & root)
1419 . map_err ( |e| e. to_string ( ) )
1520}
21+
22+ // -- Change documents root --
23+
24+ #[ derive( Debug , Serialize ) ]
25+ pub struct ChangeDocumentsRootResult {
26+ pub files_moved : usize ,
27+ pub files_failed : usize ,
28+ }
29+
30+ /// Validate a candidate documents root directory.
31+ /// Returns (is_empty, exists, writable).
32+ #[ tauri:: command]
33+ pub async fn validate_documents_root ( path : String ) -> Result < ValidateResult , String > {
34+ let target = PathBuf :: from ( & path) ;
35+
36+ if !target. is_absolute ( ) {
37+ return Err ( "路径必须是绝对路径" . into ( ) ) ;
38+ }
39+
40+ let exists = target. exists ( ) ;
41+
42+ // Create if missing (to test writability), then remove if we created it.
43+ let created_now = if !exists {
44+ std:: fs:: create_dir_all ( & target) . map_err ( |e| format ! ( "无法创建目录: {e}" ) ) ?;
45+ true
46+ } else {
47+ false
48+ } ;
49+
50+ // Test writability
51+ let probe = target. join ( ".aqbot_write_probe" ) ;
52+ let writable = std:: fs:: write ( & probe, b"ok" ) . is_ok ( ) ;
53+ let _ = std:: fs:: remove_file ( & probe) ;
54+
55+ // Check emptiness
56+ let is_empty = match std:: fs:: read_dir ( & target) {
57+ Ok ( mut entries) => entries. next ( ) . is_none ( ) ,
58+ Err ( _) => true ,
59+ } ;
60+
61+ // Clean up if we created the dir
62+ if created_now && is_empty {
63+ let _ = std:: fs:: remove_dir ( & target) ;
64+ }
65+
66+ Ok ( ValidateResult {
67+ exists,
68+ is_empty,
69+ writable,
70+ } )
71+ }
72+
73+ #[ derive( Debug , Serialize ) ]
74+ pub struct ValidateResult {
75+ pub exists : bool ,
76+ pub is_empty : bool ,
77+ pub writable : bool ,
78+ }
79+
80+ /// Change the documents root to `new_path`.
81+ /// If `migrate` is true, copies all files from the current root.
82+ #[ tauri:: command]
83+ pub async fn change_documents_root (
84+ state : State < ' _ , AppState > ,
85+ new_path : String ,
86+ migrate : bool ,
87+ ) -> Result < ChangeDocumentsRootResult , String > {
88+ let new_root = PathBuf :: from ( & new_path) ;
89+ let old_root = storage_paths:: documents_root ( ) ;
90+
91+ if new_root == old_root {
92+ return Err ( "新目录与当前目录相同" . into ( ) ) ;
93+ }
94+
95+ if !new_root. is_absolute ( ) {
96+ return Err ( "路径必须是绝对路径" . into ( ) ) ;
97+ }
98+
99+ // Ensure the target directory and subdirs exist
100+ for sub in & [ "images" , "files" , "backups" ] {
101+ std:: fs:: create_dir_all ( new_root. join ( sub) )
102+ . map_err ( |e| format ! ( "无法创建目录 {sub}: {e}" ) ) ?;
103+ }
104+
105+ let mut result = ChangeDocumentsRootResult {
106+ files_moved : 0 ,
107+ files_failed : 0 ,
108+ } ;
109+
110+ // Migrate files if requested — move (rename or copy+delete)
111+ if migrate {
112+ for sub in & [ "images" , "files" , "backups" ] {
113+ let src_dir = old_root. join ( sub) ;
114+ let dst_dir = new_root. join ( sub) ;
115+ if !src_dir. exists ( ) {
116+ continue ;
117+ }
118+ if let Ok ( entries) = std:: fs:: read_dir ( & src_dir) {
119+ for entry in entries. flatten ( ) {
120+ let meta = match entry. metadata ( ) {
121+ Ok ( m) => m,
122+ Err ( _) => { result. files_failed += 1 ; continue ; }
123+ } ;
124+ if !meta. is_file ( ) {
125+ continue ;
126+ }
127+ let src = entry. path ( ) ;
128+ let dst = dst_dir. join ( entry. file_name ( ) ) ;
129+ if dst. exists ( ) {
130+ // Already at destination — delete source and count as moved
131+ let _ = std:: fs:: remove_file ( & src) ;
132+ result. files_moved += 1 ;
133+ continue ;
134+ }
135+ // Try rename first (atomic, same filesystem)
136+ if std:: fs:: rename ( & src, & dst) . is_ok ( ) {
137+ result. files_moved += 1 ;
138+ continue ;
139+ }
140+ // Cross-filesystem: copy then delete source
141+ match std:: fs:: copy ( & src, & dst) {
142+ Ok ( _) => {
143+ let _ = std:: fs:: remove_file ( & src) ;
144+ result. files_moved += 1 ;
145+ }
146+ Err ( e) => {
147+ tracing:: warn!(
148+ src = %src. display( ) ,
149+ dst = %dst. display( ) ,
150+ error = %e,
151+ "failed to move file during documents root change"
152+ ) ;
153+ result. files_failed += 1 ;
154+ }
155+ }
156+ }
157+ }
158+ }
159+ }
160+
161+ // Persist the setting
162+ let db = & state. sea_db ;
163+ let mut settings = aqbot_core:: repo:: settings:: get_settings ( db)
164+ . await
165+ . map_err ( |e| e. to_string ( ) ) ?;
166+ settings. documents_root_override = Some ( new_path) ;
167+ aqbot_core:: repo:: settings:: save_settings ( db, & settings)
168+ . await
169+ . map_err ( |e| e. to_string ( ) ) ?;
170+
171+ // Update the in-process global so subsequent calls see the new root
172+ storage_paths:: set_documents_root ( new_root) ;
173+
174+ Ok ( result)
175+ }
176+
177+ /// Reset documents root back to the platform default.
178+ #[ tauri:: command]
179+ pub async fn reset_documents_root (
180+ state : State < ' _ , AppState > ,
181+ ) -> Result < ( ) , String > {
182+ let db = & state. sea_db ;
183+ let mut settings = aqbot_core:: repo:: settings:: get_settings ( db)
184+ . await
185+ . map_err ( |e| e. to_string ( ) ) ?;
186+ settings. documents_root_override = None ;
187+ aqbot_core:: repo:: settings:: save_settings ( db, & settings)
188+ . await
189+ . map_err ( |e| e. to_string ( ) ) ?;
190+
191+ storage_paths:: clear_documents_root_override ( ) ;
192+ Ok ( ( ) )
193+ }
0 commit comments