@@ -7,6 +7,8 @@ use std::fs;
7
7
use std:: path:: { Path , PathBuf } ;
8
8
use walkdir:: WalkDir ;
9
9
10
+ /// Example:
11
+ /// notion-to-blog --input <input_folder> --output <output_folder> --output-name release-070
10
12
#[ derive( Parser ) ]
11
13
#[ command( author, version, about, long_about = None ) ]
12
14
struct Args {
@@ -17,6 +19,10 @@ struct Args {
17
19
/// Output folder for transformed markdown
18
20
#[ arg( short, long) ]
19
21
output : PathBuf ,
22
+
23
+ /// The name of the markdown file to output
24
+ #[ arg( short, long, default_value = "blog" ) ]
25
+ output_name : String ,
20
26
}
21
27
22
28
#[ tokio:: main]
@@ -27,6 +33,13 @@ async fn main() -> Result<()> {
27
33
anyhow:: bail!( "Input folder does not exist: {}" , args. input. display( ) ) ;
28
34
}
29
35
36
+ if args. input . is_file ( ) {
37
+ anyhow:: bail!(
38
+ "Input path must be a directory, not a file: {}" ,
39
+ args. input. display( )
40
+ ) ;
41
+ }
42
+
30
43
// Create output directory if it doesn't exist
31
44
fs:: create_dir_all ( & args. output ) . with_context ( || {
32
45
format ! (
@@ -42,7 +55,7 @@ async fn main() -> Result<()> {
42
55
43
56
if path. extension ( ) . and_then ( |s| s. to_str ( ) ) == Some ( "md" ) {
44
57
println ! ( "Processing: {}" , path. display( ) ) ;
45
- process_markdown_file ( & args. input , & args. output , path) . await ?;
58
+ process_markdown_file ( & args. input , & args. output , & args . output_name , path) . await ?;
46
59
}
47
60
}
48
61
@@ -53,6 +66,7 @@ async fn main() -> Result<()> {
53
66
async fn process_markdown_file (
54
67
input_base : & Path ,
55
68
output_base : & Path ,
69
+ output_name : & str ,
56
70
md_path : & Path ,
57
71
) -> Result < ( ) > {
58
72
// Read the markdown file
@@ -61,7 +75,7 @@ async fn process_markdown_file(
61
75
62
76
// Get the relative path from input base to maintain directory structure
63
77
let relative_path = md_path. strip_prefix ( input_base) ?;
64
- let output_md_path = output_base. join ( relative_path ) ;
78
+ let output_md_path = output_base. join ( output_name ) . with_extension ( "md" ) ;
65
79
66
80
// Create parent directories if needed
67
81
if let Some ( parent) = output_md_path. parent ( ) {
@@ -74,17 +88,18 @@ async fn process_markdown_file(
74
88
. and_then ( |s| s. to_str ( ) )
75
89
. unwrap_or ( "assets" ) ;
76
90
let input_assets_folder = md_path. parent ( ) . unwrap ( ) . join ( assets_folder_name) ;
77
- let output_assets_folder = output_md_path. parent ( ) . unwrap ( ) . join ( "assets" ) ;
91
+ let output_assets_folder = output_md_path. parent ( ) . unwrap ( ) . join ( "assets" ) . join ( output_name ) ;
78
92
79
93
// Process images if assets folder exists
80
94
let mut image_mapping = HashMap :: new ( ) ;
81
95
if input_assets_folder. exists ( ) {
82
96
fs:: create_dir_all ( & output_assets_folder) ?;
83
97
image_mapping = process_images ( & input_assets_folder, & output_assets_folder) . await ?;
84
98
}
99
+ println ! ( "{:#?}" , image_mapping) ;
85
100
86
101
// Transform the markdown content
87
- let transformed_content = transform_markdown ( & content, & image_mapping) ?;
102
+ let transformed_content = transform_markdown ( & content, output_name , & image_mapping) ?;
88
103
89
104
// Write the transformed markdown
90
105
fs:: write ( & output_md_path, transformed_content)
@@ -100,6 +115,7 @@ async fn process_images(
100
115
) -> Result < HashMap < String , String > > {
101
116
let mut image_mapping = HashMap :: new ( ) ;
102
117
let mut screenshot_counter = 1 ;
118
+ let mut screen_recording_counter = 1 ;
103
119
104
120
for entry in WalkDir :: new ( input_folder) {
105
121
let entry = entry?;
@@ -111,14 +127,19 @@ async fn process_images(
111
127
}
112
128
113
129
if let Some ( extension) = path. extension ( ) . and_then ( |s| s. to_str ( ) ) {
114
- let file_name = path. file_name ( ) . unwrap ( ) . to_str ( ) . unwrap ( ) ;
130
+ let file_name = path. file_name ( ) . and_then ( |s| s. to_str ( ) ) . unwrap ( ) ;
131
+ let file_stem = path. file_stem ( ) . unwrap ( ) . to_str ( ) . unwrap ( ) ;
115
132
116
133
if matches ! (
117
134
extension. to_lowercase( ) . as_str( ) ,
118
135
"png" | "jpg" | "jpeg" | "gif" | "webp"
119
136
) {
120
137
// Convert images to AVIF
121
- let new_name = generate_new_image_name ( file_name, & mut screenshot_counter) ;
138
+ let new_name = generate_new_image_name (
139
+ file_stem,
140
+ & mut screenshot_counter,
141
+ & mut screen_recording_counter,
142
+ ) ;
122
143
let new_name_avif = format ! (
123
144
"{}.avif" ,
124
145
new_name. trim_end_matches( & format!( ".{}" , extension) )
@@ -134,11 +155,16 @@ async fn process_images(
134
155
135
156
// Store mapping from original to new name (without ./assets/ prefix)
136
157
image_mapping. insert ( file_name. to_string ( ) , new_name_avif. clone ( ) ) ;
137
- println ! ( " ✓ Converted: {} -> {}" , file_name , new_name_avif ) ;
158
+ println ! ( " ✓ Converted: {file_name } -> {new_name_avif}" ) ;
138
159
} else {
139
160
// Copy all other assets (videos, documents, etc.) as-is
140
- let cleaned_name = clean_asset_name ( file_name) ;
141
- let output_path = output_folder. join ( & cleaned_name) ;
161
+ let cleaned_name = clean_asset_name (
162
+ file_stem,
163
+ & mut screenshot_counter,
164
+ & mut screen_recording_counter,
165
+ ) ;
166
+ let cleaned_file = format ! ( "{}.{}" , cleaned_name, extension) ;
167
+ let output_path = output_folder. join ( & cleaned_file) ;
142
168
143
169
fs:: copy ( path, & output_path) . with_context ( || {
144
170
format ! (
@@ -149,24 +175,20 @@ async fn process_images(
149
175
} ) ?;
150
176
151
177
// Store mapping from original to cleaned name (without ./assets/ prefix)
152
- image_mapping. insert ( file_name. to_string ( ) , cleaned_name . clone ( ) ) ;
153
- println ! ( " ✓ Copied: {} -> {}" , file_name , cleaned_name ) ;
178
+ image_mapping. insert ( file_name. to_string ( ) , cleaned_file . clone ( ) ) ;
179
+ println ! ( " ✓ Copied: {file_name } -> {cleaned_file}" ) ;
154
180
}
155
181
}
156
182
}
157
183
158
184
Ok ( image_mapping)
159
185
}
160
186
161
- fn clean_asset_name ( original_name : & str ) -> String {
162
- // Clean up asset names by removing spaces and URL encoding
163
- original_name
164
- . replace ( " " , "-" )
165
- . replace ( "%20" , "-" )
166
- . to_lowercase ( )
167
- }
168
-
169
- fn generate_new_image_name ( original_name : & str , screenshot_counter : & mut i32 ) -> String {
187
+ fn clean_asset_name (
188
+ original_name : & str ,
189
+ screenshot_counter : & mut i32 ,
190
+ screen_recording_counter : & mut i32 ,
191
+ ) -> String {
170
192
let lower_name = original_name. to_lowercase ( ) ;
171
193
172
194
// Check if it's a screenshot
@@ -177,11 +199,29 @@ fn generate_new_image_name(original_name: &str, screenshot_counter: &mut i32) ->
177
199
return name;
178
200
}
179
201
180
- // For other images, just clean up the name
181
- let cleaned = original_name
202
+ // Check if it's a screen recording
203
+ let screen_recording_regex =
204
+ Regex :: new ( r"screen[_\s]*recording[_\s]*\d{4}[-_]\d{2}[-_]\d{2}" ) . unwrap ( ) ;
205
+ if screen_recording_regex. is_match ( & lower_name) {
206
+ let name = format ! ( "screen-recording-{}" , screen_recording_counter) ;
207
+ * screen_recording_counter += 1 ;
208
+ return name;
209
+ }
210
+
211
+ // Clean up asset names by removing spaces and URL encoding
212
+ original_name
182
213
. replace ( " " , "-" )
183
214
. replace ( "%20" , "-" )
184
- . to_lowercase ( ) ;
215
+ . to_lowercase ( )
216
+ }
217
+
218
+ fn generate_new_image_name (
219
+ original_name : & str ,
220
+ screenshot_counter : & mut i32 ,
221
+ screen_recording_counter : & mut i32 ,
222
+ ) -> String {
223
+ // For other images, just clean up the name
224
+ let cleaned = clean_asset_name ( original_name, screenshot_counter, screen_recording_counter) ;
185
225
186
226
// Remove file extension to add it back later
187
227
if let Some ( dot_pos) = cleaned. rfind ( '.' ) {
@@ -191,7 +231,11 @@ fn generate_new_image_name(original_name: &str, screenshot_counter: &mut i32) ->
191
231
}
192
232
}
193
233
194
- fn transform_markdown ( content : & str , image_mapping : & HashMap < String , String > ) -> Result < String > {
234
+ fn transform_markdown (
235
+ content : & str ,
236
+ asset_sub_directory : & str ,
237
+ image_mapping : & HashMap < String , String > ,
238
+ ) -> Result < String > {
195
239
let parser = pulldown_cmark:: Parser :: new ( content) ;
196
240
let mut events = Vec :: new ( ) ;
197
241
let mut skip_until_after_heading = false ;
@@ -217,7 +261,8 @@ fn transform_markdown(content: &str, image_mapping: &HashMap<String, String>) ->
217
261
title,
218
262
id,
219
263
} ) => {
220
- let processed_url = process_image_url ( & dest_url, image_mapping) ;
264
+ let processed_url =
265
+ process_image_url ( & dest_url, asset_sub_directory, image_mapping) ;
221
266
222
267
// Check if this is a video file - if so, convert to image syntax
223
268
let url_decoded = dest_url. replace ( "%20" , " " ) ;
@@ -239,7 +284,7 @@ fn transform_markdown(content: &str, image_mapping: &HashMap<String, String>) ->
239
284
in_link = true ;
240
285
events. push ( Event :: Start ( Tag :: Link {
241
286
link_type,
242
- dest_url : processed_url . into ( ) ,
287
+ dest_url,
243
288
title,
244
289
id,
245
290
} ) ) ;
@@ -260,7 +305,8 @@ fn transform_markdown(content: &str, image_mapping: &HashMap<String, String>) ->
260
305
events. push ( Event :: Text ( text) ) ;
261
306
} else {
262
307
// Process image references in text only if not inside a link
263
- let processed_text = process_image_references ( & text, image_mapping) ;
308
+ let processed_text =
309
+ process_image_references ( & text, asset_sub_directory, image_mapping) ;
264
310
events. push ( Event :: Text ( processed_text. into ( ) ) ) ;
265
311
}
266
312
}
@@ -270,7 +316,8 @@ fn transform_markdown(content: &str, image_mapping: &HashMap<String, String>) ->
270
316
title,
271
317
id,
272
318
} ) => {
273
- let processed_url = process_image_url ( & dest_url, image_mapping) ;
319
+ let processed_url =
320
+ process_image_url ( & dest_url, asset_sub_directory, image_mapping) ;
274
321
events. push ( Event :: Start ( Tag :: Image {
275
322
link_type,
276
323
dest_url : processed_url. into ( ) ,
@@ -414,7 +461,11 @@ fn transform_markdown(content: &str, image_mapping: &HashMap<String, String>) ->
414
461
Ok ( output)
415
462
}
416
463
417
- fn process_image_references ( text : & str , image_mapping : & HashMap < String , String > ) -> String {
464
+ fn process_image_references (
465
+ text : & str ,
466
+ asset_sub_directory : & str ,
467
+ image_mapping : & HashMap < String , String > ,
468
+ ) -> String {
418
469
let mut result = text. to_string ( ) ;
419
470
420
471
// Only process if this text doesn't look like it's already part of a processed link
@@ -426,7 +477,7 @@ fn process_image_references(text: &str, image_mapping: &HashMap<String, String>)
426
477
for ( original, new) in image_mapping {
427
478
// Handle URL-encoded spaces and direct references
428
479
let encoded_original = original. replace ( " " , "%20" ) ;
429
- let new_with_prefix = format ! ( "./assets/{}" , new) ;
480
+ let new_with_prefix = format ! ( "./assets/{asset_sub_directory}/{ new}" ) ;
430
481
431
482
// Check if this is a video file that should be treated as an image
432
483
let is_video = is_media_file ( original) ;
@@ -452,18 +503,25 @@ fn process_image_references(text: &str, image_mapping: &HashMap<String, String>)
452
503
result
453
504
}
454
505
455
- fn process_image_url ( url : & str , image_mapping : & HashMap < String , String > ) -> String {
506
+ fn process_image_url (
507
+ url : & str ,
508
+ asset_sub_directory : & str ,
509
+ image_mapping : & HashMap < String , String > ,
510
+ ) -> String {
456
511
// Extract filename from URL
457
512
let url_decoded = url. replace ( "%20" , " " ) ;
458
513
459
514
if let Some ( filename) = Path :: new ( & url_decoded) . file_name ( ) . and_then ( |s| s. to_str ( ) ) {
460
515
if let Some ( new_name) = image_mapping. get ( filename) {
461
- return format ! ( "./assets/{}" , new_name) ;
516
+ return format ! ( "./assets/{asset_sub_directory}/{ new_name}" ) ;
462
517
}
463
518
}
464
519
465
520
// Fallback: clean up the URL by removing URL encoding
466
- format ! ( "./assets/{}" , url. replace( "%20" , "-" ) . to_lowercase( ) )
521
+ format ! (
522
+ "./assets/{asset_sub_directory}/{}" ,
523
+ url. replace( "%20" , "-" ) . to_lowercase( )
524
+ )
467
525
}
468
526
469
527
fn is_media_file ( filename : & str ) -> bool {
0 commit comments