11use crate :: database:: { repositories:: StreamStatsRepository , DatabaseManager } ;
22use crate :: error:: ResultExt ;
3+ use chrono:: { DateTime , FixedOffset , NaiveDateTime } ;
34use serde:: { Deserialize , Serialize } ;
45use tauri:: { AppHandle , State } ;
56
67#[ derive( Debug , Serialize , Deserialize ) ]
78pub struct ExportQuery {
8- pub channel_id : Option < i64 > ,
9+ pub channel_id : i64 ,
910 pub start_time : Option < String > ,
1011 pub end_time : Option < String > ,
1112 pub aggregation : Option < String > , // "raw", "1min", "5min", "1hour"
1213 pub delimiter : Option < String > , // Custom delimiter (default: comma)
1314}
1415
16+ fn normalize_timestamp ( value : & str ) -> String {
17+ // 1) RFC3339 (元の文字列形式を想定)
18+ if let Ok ( dt) = DateTime :: parse_from_rfc3339 ( value) {
19+ return dt. to_rfc3339 ( ) ;
20+ }
21+ // 2) DuckDB の TIMESTAMP 表示形式を想定(秒以下あり/なし両対応)
22+ if let Ok ( naive) = NaiveDateTime :: parse_from_str ( value, "%Y-%m-%d %H:%M:%S%.f" )
23+ . or_else ( |_| NaiveDateTime :: parse_from_str ( value, "%Y-%m-%d %H:%M:%S" ) )
24+ {
25+ if let Some ( offset) = FixedOffset :: east_opt ( 0 ) {
26+ return DateTime :: < FixedOffset > :: from_naive_utc_and_offset ( naive, offset) . to_rfc3339 ( ) ;
27+ }
28+ }
29+ // 3) どれにも当てはまらない場合は元の文字列を返す
30+ value. to_string ( )
31+ }
32+
1533/// Helper function to escape field values for delimited output
1634fn escape_field ( value : & str , delimiter : & str ) -> String {
1735 // Check if field needs escaping (contains delimiter, quotes, or newlines)
@@ -35,25 +53,47 @@ pub async fn export_to_delimited(
3553 file_path : String ,
3654 include_bom : Option < bool > ,
3755) -> Result < String , String > {
56+ let ExportQuery {
57+ channel_id,
58+ start_time,
59+ end_time,
60+ aggregation,
61+ delimiter,
62+ } = query;
63+
3864 let stats = db_manager
3965 . with_connection ( |conn| {
40- StreamStatsRepository :: get_stream_stats_filtered (
41- conn,
42- None ,
43- query. channel_id ,
44- query. start_time . as_deref ( ) ,
45- query. end_time . as_deref ( ) ,
46- true , // ORDER BY collected_at ASC for export
47- )
48- . db_context ( "query stats" )
49- . map_err ( |e| e. to_string ( ) )
66+ let start_opt = start_time. as_deref ( ) ;
67+ let end_opt = end_time. as_deref ( ) ;
68+
69+ let interval_minutes = match aggregation. as_deref ( ) {
70+ Some ( "1min" ) => Some ( 1 ) ,
71+ Some ( "5min" ) => Some ( 5 ) ,
72+ Some ( "1hour" ) => Some ( 60 ) ,
73+ _ => None ,
74+ } ;
75+
76+ if let ( Some ( st) , Some ( et) , Some ( interval) ) = ( start_opt, end_opt, interval_minutes) {
77+ StreamStatsRepository :: get_interpolated_stream_stats_for_export (
78+ conn, None , Some ( channel_id) , st, et, interval,
79+ )
80+ . db_context ( "query interpolated stats for export" )
81+ . map_err ( |e| e. to_string ( ) )
82+ } else {
83+ StreamStatsRepository :: get_stream_stats_filtered (
84+ conn, None , Some ( channel_id) , start_opt, end_opt,
85+ true , // ORDER BY collected_at ASC for export
86+ )
87+ . db_context ( "query stats" )
88+ . map_err ( |e| e. to_string ( ) )
89+ }
5090 } )
5191 . await ?;
5292
5393 let stats_len = stats. len ( ) ;
5494
5595 // Determine delimiter (default to comma)
56- let delimiter = query . delimiter . as_deref ( ) . unwrap_or ( "," ) ;
96+ let delimiter = delimiter. as_deref ( ) . unwrap_or ( "," ) ;
5797
5898 // Build delimited file content
5999 let mut output = String :: new ( ) ;
@@ -71,7 +111,7 @@ pub async fn export_to_delimited(
71111
72112 // Data rows
73113 for stat in & stats {
74- let collected_at = & stat. collected_at ;
114+ let collected_at = normalize_timestamp ( & stat. collected_at ) ;
75115 let channel_name = stat. channel_name . as_deref ( ) . unwrap_or ( "" ) ;
76116 let viewer_count = stat. viewer_count . unwrap_or ( 0 ) . to_string ( ) ;
77117 let category = stat. category . as_deref ( ) . unwrap_or ( "" ) ;
@@ -83,7 +123,7 @@ pub async fn export_to_delimited(
83123
84124 output. push_str ( & format ! (
85125 "{}{}{}{}{}{}{}{}{}{}{}\n " ,
86- escape_field( collected_at, delimiter) ,
126+ escape_field( & collected_at, delimiter) ,
87127 delimiter,
88128 escape_field( channel_name, delimiter) ,
89129 delimiter,
@@ -115,18 +155,40 @@ pub async fn preview_export_data(
115155 query : ExportQuery ,
116156 max_rows : Option < usize > ,
117157) -> Result < String , String > {
158+ let ExportQuery {
159+ channel_id,
160+ start_time,
161+ end_time,
162+ aggregation,
163+ delimiter,
164+ } = query;
165+
118166 let stats = db_manager
119167 . with_connection ( |conn| {
120- StreamStatsRepository :: get_stream_stats_filtered (
121- conn,
122- None ,
123- query. channel_id ,
124- query. start_time . as_deref ( ) ,
125- query. end_time . as_deref ( ) ,
126- true , // ORDER BY collected_at ASC
127- )
128- . db_context ( "query stats" )
129- . map_err ( |e| e. to_string ( ) )
168+ let start_opt = start_time. as_deref ( ) ;
169+ let end_opt = end_time. as_deref ( ) ;
170+
171+ let interval_minutes = match aggregation. as_deref ( ) {
172+ Some ( "1min" ) => Some ( 1 ) ,
173+ Some ( "5min" ) => Some ( 5 ) ,
174+ Some ( "1hour" ) => Some ( 60 ) ,
175+ _ => None ,
176+ } ;
177+
178+ if let ( Some ( st) , Some ( et) , Some ( interval) ) = ( start_opt, end_opt, interval_minutes) {
179+ StreamStatsRepository :: get_interpolated_stream_stats_for_export (
180+ conn, None , Some ( channel_id) , st, et, interval,
181+ )
182+ . db_context ( "query interpolated stats for preview" )
183+ . map_err ( |e| e. to_string ( ) )
184+ } else {
185+ StreamStatsRepository :: get_stream_stats_filtered (
186+ conn, None , Some ( channel_id) , start_opt, end_opt,
187+ true , // ORDER BY collected_at ASC
188+ )
189+ . db_context ( "query stats" )
190+ . map_err ( |e| e. to_string ( ) )
191+ }
130192 } )
131193 . await ?;
132194
@@ -135,7 +197,7 @@ pub async fn preview_export_data(
135197 let preview_stats = stats. iter ( ) . take ( max_rows) ;
136198
137199 // Determine delimiter (default to comma)
138- let delimiter = query . delimiter . as_deref ( ) . unwrap_or ( "," ) ;
200+ let delimiter = delimiter. as_deref ( ) . unwrap_or ( "," ) ;
139201
140202 // Build preview content
141203 let mut output = String :: new ( ) ;
@@ -148,7 +210,7 @@ pub async fn preview_export_data(
148210
149211 // Data rows (limited to max_rows)
150212 for stat in preview_stats {
151- let collected_at = & stat. collected_at ;
213+ let collected_at = normalize_timestamp ( & stat. collected_at ) ;
152214 let channel_name = stat. channel_name . as_deref ( ) . unwrap_or ( "" ) ;
153215 let viewer_count = stat. viewer_count . unwrap_or ( 0 ) . to_string ( ) ;
154216 let category = stat. category . as_deref ( ) . unwrap_or ( "" ) ;
@@ -160,7 +222,7 @@ pub async fn preview_export_data(
160222
161223 output. push_str ( & format ! (
162224 "{}{}{}{}{}{}{}{}{}{}{}\n " ,
163- escape_field( collected_at, delimiter) ,
225+ escape_field( & collected_at, delimiter) ,
164226 delimiter,
165227 escape_field( channel_name, delimiter) ,
166228 delimiter,
0 commit comments