@@ -157,23 +157,29 @@ def delete_folder(notes_dir: str, folder_path: str) -> bool:
157157
158158
159159def get_all_notes (notes_dir : str ) -> List [Dict ]:
160- """Recursively get all markdown notes"""
161- notes = []
160+ """Recursively get all markdown notes and images """
161+ items = []
162162 notes_path = Path (notes_dir )
163163
164+ # Get all markdown notes
164165 for md_file in notes_path .rglob ("*.md" ):
165166 relative_path = md_file .relative_to (notes_path )
166167 stat = md_file .stat ()
167168
168- notes .append ({
169+ items .append ({
169170 "name" : md_file .stem ,
170171 "path" : str (relative_path .as_posix ()),
171172 "folder" : str (relative_path .parent .as_posix ()) if str (relative_path .parent ) != "." else "" ,
172173 "modified" : datetime .fromtimestamp (stat .st_mtime ).isoformat (),
173- "size" : stat .st_size
174+ "size" : stat .st_size ,
175+ "type" : "note"
174176 })
175177
176- return sorted (notes , key = lambda x : x ['modified' ], reverse = True )
178+ # Get all images
179+ images = get_all_images (notes_dir )
180+ items .extend (images )
181+
182+ return sorted (items , key = lambda x : x ['modified' ], reverse = True )
177183
178184
179185def get_note_content (notes_dir : str , note_path : str ) -> Optional [str ]:
@@ -296,3 +302,127 @@ def create_note_metadata(notes_dir: str, note_path: str) -> Dict:
296302 "lines" : line_count
297303 }
298304
305+
306+ def sanitize_filename (filename : str ) -> str :
307+ """
308+ Sanitize a filename by removing/replacing invalid characters.
309+ Keeps only alphanumeric chars, dots, dashes, and underscores.
310+ """
311+ # Get the extension first
312+ parts = filename .rsplit ('.' , 1 )
313+ name = parts [0 ]
314+ ext = parts [1 ] if len (parts ) > 1 else ''
315+
316+ # Remove/replace invalid characters
317+ name = re .sub (r'[^a-zA-Z0-9_-]' , '_' , name )
318+
319+ # Rejoin with extension
320+ return f"{ name } .{ ext } " if ext else name
321+
322+
323+ def get_attachment_dir (notes_dir : str , note_path : str ) -> Path :
324+ """
325+ Get the attachments directory for a given note.
326+ If note is in root, returns /data/_attachments/
327+ If note is in folder, returns /data/folder/_attachments/
328+ """
329+ if not note_path :
330+ # Root level
331+ return Path (notes_dir ) / "_attachments"
332+
333+ note_path_obj = Path (note_path )
334+ folder = note_path_obj .parent
335+
336+ if str (folder ) == '.' :
337+ # Note is in root
338+ return Path (notes_dir ) / "_attachments"
339+ else :
340+ # Note is in a folder
341+ return Path (notes_dir ) / folder / "_attachments"
342+
343+
344+ def save_uploaded_image (notes_dir : str , note_path : str , filename : str , file_data : bytes ) -> Optional [str ]:
345+ """
346+ Save an uploaded image to the appropriate attachments directory.
347+ Returns the relative path to the image if successful, None otherwise.
348+
349+ Args:
350+ notes_dir: Base notes directory
351+ note_path: Path of the note the image is being uploaded to
352+ filename: Original filename
353+ file_data: Binary file data
354+
355+ Returns:
356+ Relative path to the saved image, or None if failed
357+ """
358+ # Sanitize filename
359+ sanitized_name = sanitize_filename (filename )
360+
361+ # Get extension
362+ ext = Path (sanitized_name ).suffix
363+ name_without_ext = Path (sanitized_name ).stem
364+
365+ # Add timestamp to prevent collisions
366+ timestamp = datetime .now ().strftime ('%Y%m%d%H%M%S' )
367+ final_filename = f"{ name_without_ext } -{ timestamp } { ext } "
368+
369+ # Get attachments directory
370+ attachments_dir = get_attachment_dir (notes_dir , note_path )
371+
372+ # Create directory if it doesn't exist
373+ attachments_dir .mkdir (parents = True , exist_ok = True )
374+
375+ # Full path to save the image
376+ full_path = attachments_dir / final_filename
377+
378+ # Security check
379+ if not validate_path_security (notes_dir , full_path ):
380+ print (f"Security: Attempted to save image outside notes directory: { full_path } " )
381+ return None
382+
383+ try :
384+ # Write the file
385+ with open (full_path , 'wb' ) as f :
386+ f .write (file_data )
387+
388+ # Return relative path from notes_dir
389+ relative_path = full_path .relative_to (Path (notes_dir ))
390+ return str (relative_path .as_posix ())
391+ except Exception as e :
392+ print (f"Error saving image: { e } " )
393+ return None
394+
395+
396+ def get_all_images (notes_dir : str ) -> List [Dict ]:
397+ """
398+ Get all images from attachments directories.
399+ Returns list of image dictionaries with metadata.
400+ """
401+ images = []
402+ notes_path = Path (notes_dir )
403+
404+ # Common image extensions
405+ image_extensions = {'.jpg' , '.jpeg' , '.png' , '.gif' , '.webp' }
406+
407+ # Find all attachments directories
408+ for attachments_dir in notes_path .rglob ("_attachments" ):
409+ if not attachments_dir .is_dir ():
410+ continue
411+
412+ # Find all images in this attachments directory
413+ for image_file in attachments_dir .iterdir ():
414+ if image_file .is_file () and image_file .suffix .lower () in image_extensions :
415+ relative_path = image_file .relative_to (notes_path )
416+ stat = image_file .stat ()
417+
418+ images .append ({
419+ "name" : image_file .name ,
420+ "path" : str (relative_path .as_posix ()),
421+ "folder" : str (relative_path .parent .as_posix ()),
422+ "modified" : datetime .fromtimestamp (stat .st_mtime ).isoformat (),
423+ "size" : stat .st_size ,
424+ "type" : "image"
425+ })
426+
427+ return images
428+
0 commit comments