Files
Flobidian/md_viewer/support_functions.py
T
nahakubuilde 3e4355d20d Fix issue with picture upload and rendering based on 4 modes how Obsidian can store images.
Add there option to view other files and uplod/download files from main view
2025-06-28 12:41:34 +01:00

443 lines
18 KiB
Python

from flask import url_for, jsonify, current_app
import os
import mistune
from pathlib import Path
import re
from app_settings_loader import ROOT_DIR, get_setting
from datetime import datetime
import magic # For file type detection
def resolve_path(path: str, base_dir: str) -> str:
"""
Resolves the given path:
- If it's an absolute path, returns it as-is.
- If it's a relative path or just a folder name, returns the path joined with base_dir.
"""
if os.path.isabs(path):
return path
return os.path.abspath(os.path.join(base_dir, path))
def as_path(path: str | Path) -> Path:
return path if isinstance(path, Path) else Path(path)
NOTES_FOLDER = get_setting('MD_NOTES_APP', 'NOTES_DIR', fallback='notes')
NOTES_FOLDER = resolve_path(NOTES_FOLDER, ROOT_DIR)
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def allowed_image_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
# Custom markdown renderer to handle Obsidian image syntax
class ObsidianRenderer(mistune.HTMLRenderer):
def image(self, src, alt="", title=None):
# Handle Obsidian-style ![[...]] or relative paths from any storage location
# Strip any ![[...]] wrapper
stripped_src = re.sub(r'^!\[\[(.*)\]\]$', r'\1', src)
image_match = re.match(r'(?:.*/)?([\w\- .]+\.(?:png|jpg|jpeg|gif|webp))', stripped_src, re.IGNORECASE)
if image_match:
# Get storage info based on current note path
note_path = getattr(current_app, 'current_note_path', None)
storage_dir, storage_base = get_image_storage_info(note_path)
storage_mode = get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_MODE')
# Clean up the path and normalize slashes
clean_path = os.path.normpath(stripped_src).replace('\\', '/').strip('/')
# Use appropriate endpoint based on storage mode
if storage_mode == '2':
# Mode 2: Specific storage folder - just the filename
image_url = url_for('md_viewer.serve_stored_image', filename=os.path.basename(clean_path))
else:
# For modes 1, 3, and 4 - use path as provided in markdown
# The paths in markdown should already be correct based on the mode:
# Mode 1: just filename.png
# Mode 3: filename.png (same directory as note)
# Mode 4: subfolder/filename.png
image_url = url_for('md_viewer.serve_attatched_image', image_path=clean_path)
return f'<img src="{image_url}" alt="{alt}" title="{title or alt}">'
return super().image(src, alt, title)
# Helper functions
def get_image_storage_info(note_path=None):
"""
Determine the image storage directory and relative path based on the selected mode and note path.
Mode 1: Store directly in NOTES_DIR
Mode 2: Store in specific folder (IMAGE_STORAGE_PATH)
Mode 3: Store in same directory as the note
Mode 4: Store in subfolder (IMAGE_SUBFOLDER_NAME) in the note's directory
Args:
note_path: Path to the .md file, relative to NOTES_DIR
Returns: (storage_dir, markdown_path) where
storage_dir is the absolute Path where the image will be stored
markdown_path is the path to use in the markdown link (None means use actual location)
Raises:
ValueError: If the requested location is in a skipped directory
"""
# Check if the path is in a skipped directory
if note_path:
skip_dirs = get_setting('MD_NOTES_APP', 'NOTES_DIR_SKIP', '').strip().split(',')
skip_dirs = [d.strip() for d in skip_dirs if d.strip()]
# Check if any part of the path is in skip_dirs
path_parts = Path(note_path).parts
for part in path_parts:
if part in skip_dirs:
raise ValueError(f"Cannot access content in skipped directory: {part}")
mode = get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_MODE')
notes_dir = Path(NOTES_FOLDER).resolve()
if mode == '1': # Store directly in NOTES_DIR
return notes_dir, None
elif mode == '2': # Store in specific folder
storage_dir = Path(get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_PATH')).resolve()
# Will use special url_for for mode 2, so no markdown path needed
return storage_dir, None
elif mode in ['3', '4'] and note_path: # Store relative to note
# Ensure we're using the directory of the actual .md file
if note_path.endswith('.md'):
note_dir = notes_dir / os.path.dirname(note_path)
else:
# If it's not a .md file, assume it's a directory and use that
note_dir = notes_dir / note_path
if mode == '3':
# No special path needed, image will be in same dir as note
return note_dir, None
else: # mode == '4'
subfolder_name = get_setting('MD_NOTES_APP', 'IMAGE_SUBFOLDER_NAME')
storage_dir = note_dir / subfolder_name
# Return just subfolder name for markdown
return storage_dir, subfolder_name
# Default to mode 1 if invalid mode or missing note_path
return notes_dir, None
def build_tree_structure(directory, base_path=None, level=0):
"""Build a tree structure from the directory, excluding hidden folders and those in NOTES_DIR_SKIP/HIDE_SIDEPANE."""
if base_path is None:
base_path = directory
# Get folders to skip or hide from settings
skip_dirs = get_setting('MD_NOTES_APP', 'NOTES_DIR_SKIP', '').strip().split(',')
skip_dirs = [d.strip() for d in skip_dirs if d.strip()]
hide_dirs = get_setting('MD_NOTES_APP', 'NOTES_DIR_HIDE_SIDEPANE', '').strip().split(',')
hide_dirs = [d.strip() for d in hide_dirs if d.strip()]
# Add the attatched folder name to hidden list
subfolder_name = get_setting('MD_NOTES_APP', 'IMAGE_SUBFOLDER_NAME', fallback='attatched')
if subfolder_name and subfolder_name not in hide_dirs:
hide_dirs.append(subfolder_name)
tree = []
try:
for item in sorted(as_path(directory).iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
# Skip hidden files, completely skip directories, and side panel hidden directories
if (item.name.startswith('.') or
(item.is_dir() and item.name in skip_dirs) or
(item.is_dir() and item.name in hide_dirs)):
continue
rel_path = str(item.relative_to(base_path))
node = {
'name': item.name,
'type': 'dir' if item.is_dir() else 'file',
'path': rel_path,
'level': level,
'expanded': False # Default to collapsed
}
if item.is_dir():
node['children'] = build_tree_structure(item, base_path, level + 1)
elif item.suffix == '.md': # Only include .md files
node['display_name'] = item.stem # Just use filename without extension
else:
continue # Skip non-markdown files
tree.append(node)
except Exception as e:
print(f"Error reading directory {directory}: {str(e)}")
return tree
def get_path_components(path):
"""Convert a file path into a list of directory names."""
if not path:
return []
parts = path.split('/')
return ['/'.join(parts[:i+1]) for i in range(len(parts)-1)]
def generate_breadcrumbs(note_path, title=None):
"""Generate breadcrumb data for a given note path."""
crumbs = []
parts = note_path.split('/')
current_path = ''
# Add root
crumbs.append({'name': '/', 'url': url_for('md_viewer.index')})
# Add directories
for i, part in enumerate(parts[:-1]): # Skip the file name
current_path = current_path + '/' + part if current_path else part
crumbs.append({
'name': part,
'url': url_for('md_viewer.folder', folder_path=current_path)
})
# Add the file name (always use file name without .md)
if len(parts) > 0:
name = parts[-1][:-3] if parts[-1].endswith('.md') else parts[-1]
url = None if parts[-1].endswith('.md') else url_for('md_viewer.folder', folder_path=current_path + '/' + parts[-1] if current_path else parts[-1])
crumbs.append({'name': name, 'url': url})
return crumbs
# Update app config from settings.ini
def update_app_config():
"""Update app config from settings.ini"""
try:
current_app.config['NOTES_DIR'] = Path(get_setting('MD_NOTES_APP', 'NOTES_DIR')).resolve()
current_app.config['IMAGE_STORAGE_MODE'] = get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_MODE')
current_app.config['IMAGE_STORAGE_PATH'] = get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_PATH')
current_app.config['IMAGE_SUBFOLDER_NAME'] = get_setting('MD_NOTES_APP', 'IMAGE_SUBFOLDER_NAME')
except Exception as e:
current_app.logger.error(f"Error updating app config: {str(e)}")
def get_all_folders(directory, base_path=None, level=0):
"""Get a flat list of all folders in the directory with their paths and levels."""
if base_path is None:
base_path = directory
folders = []
try:
for item in sorted(as_path(directory).iterdir(), key=lambda x: x.name.lower()):
if item.name.startswith('.') or (item.is_dir() and item.name == 'attatched'):
continue
if item.is_dir():
rel_path = str(item.relative_to(base_path))
folders.append({
'name': item.name,
'path': rel_path,
'level': level
})
# Recursively get subfolders
folders.extend(get_all_folders(item, base_path, level + 1))
except Exception as e:
print(f"Error reading directory {directory}: {str(e)}")
return folders
def check_notes_dir_security(path):
"""
Check if a path is secure to use as notes directory.
Returns (is_valid, error_message)
"""
try:
# Convert to absolute path
abs_path = Path(path).resolve()
# Check for system directories we want to protect
system_dirs = [
'/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc',
'/root', '/run', '/sbin', '/sys', '/tmp', '/usr', '/var',
'/opt', '/lost+found'
]
str_path = str(abs_path)
for sys_dir in system_dirs:
if str_path == sys_dir or str_path.startswith(sys_dir + '/'):
return False, f'Cannot use system directory: {sys_dir}'
# Check if path contains forbidden characters
if any(char in str_path for char in ['\\', '*', '?', '"', '<', '>', '|', ';', '&']):
return False, 'Path contains invalid characters'
# Don't allow paths that are too short
if len(str_path) < 2:
return False, 'Path is too short'
return True, None
except Exception as e:
return False, f'Invalid path: {str(e)}'
def handle_uploaded_image(request_files, note_path=None):
"""Handle an image upload from the markdown editor."""
if 'image' not in request_files:
return jsonify({'error': 'No image file provided'}), 400
file = request_files['image']
if file.filename == '':
return jsonify({'error': 'No image file selected'}), 400
if not allowed_image_file(file.filename):
return jsonify({'error': 'Invalid file type'}), 400
# Get storage directory based on mode and note path
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'Pasted_image_{timestamp}{Path(file.filename).suffix}'
storage_dir, storage_subfolder = get_image_storage_info(note_path)
storage_mode = get_setting('MD_NOTES_APP', 'IMAGE_STORAGE_MODE')
try:
# Create storage directory if needed
os.makedirs(storage_dir, exist_ok=True)
filepath = storage_dir / filename
file.save(filepath)
# Construct the markdown link and relative path based on storage mode
if storage_mode == '1':
# Mode 1: Direct in NOTES_DIR root - just the filename
markdown = f'![[{filename}]]'
relative_path = filename
elif storage_mode == '2':
# Mode 2: Specific storage folder - use special URL
return jsonify({
'filename': filename,
'url': url_for('md_viewer.serve_stored_image', filename=filename),
'markdown': f'![[{filename}]]'
})
elif storage_mode == '3':
# Mode 3: Same directory as note - just the filename since it's in same dir
markdown = f'![[{filename}]]'
# For serving, we need the full relative path from NOTES_DIR
note_dir = os.path.dirname(note_path) if note_path else ''
relative_path = f'{note_dir}/{filename}' if note_dir else filename
elif storage_mode == '4':
# Mode 4: In subfolder under note - include the configured subfolder name
if storage_subfolder: # Should always be true for mode 4
markdown = f'![[{storage_subfolder}/{filename}]]'
# For serving, we need the full relative path from NOTES_DIR
note_dir = os.path.dirname(note_path) if note_path else ''
relative_path = f'{note_dir}/{storage_subfolder}/{filename}' if note_dir else f'{storage_subfolder}/{filename}'
else:
# Fallback if somehow storage_subfolder is None
markdown = f'![[{filename}]]'
relative_path = filename
else:
# Default to mode 1 behavior
markdown = f'![[{filename}]]'
relative_path = filename
# Clean up any double slashes in paths
relative_path = re.sub(r'/+', '/', relative_path)
# Return the appropriate URL for serving the image
return jsonify({
'filename': filename,
'url': url_for('md_viewer.serve_attatched_image', image_path=relative_path),
'markdown': markdown
})
except Exception as e:
return jsonify({'error': f'Failed to save image: {str(e)}'}), 500
def get_allowed_file_types():
"""Get allowed file types from settings"""
# Get configurable extensions
image_extensions = set('.' + ext.strip() for ext in get_setting('MD_NOTES_APP', 'ALLOWED_IMAGE_EXTENSIONS',
fallback='jpg,jpeg,png,bmp').split(','))
file_extensions = set('.' + ext.strip() for ext in get_setting('MD_NOTES_APP', 'ALLOWED_FILE_EXTENSIONS',
fallback='txt,csv,json,html,htm,xml,yaml,yml,js,css,py,md').split(','))
# Build file types dictionary
return {
'images': image_extensions,
'text': file_extensions
}
def get_file_type(extension):
"""Determine file type based on extension"""
if not extension:
return None
allowed_types = get_allowed_file_types()
extension = extension.lower()
for type_name, extensions in allowed_types.items():
if extension in extensions:
return type_name
return None
def get_language_from_extension(extension):
"""Map file extensions to highlight.js language classes"""
extension = extension.lower()
language_map = {
'.html': 'html',
'.htm': 'html',
'.js': 'javascript',
'.css': 'css',
'.json': 'json',
'.py': 'python',
'.xml': 'xml',
'.yaml': 'yaml',
'.yml': 'yaml',
'.ini': 'ini',
'.txt': 'plaintext',
'.log': 'plaintext',
'.csv': 'plaintext'
}
return language_map.get(extension, 'plaintext')
def verify_file_type(file_path, claimed_extension):
"""
Verify that the file's content matches its claimed extension.
Returns tuple (is_valid, actual_type)
"""
mime = magic.Magic(mime=True)
file_type = mime.from_file(str(file_path))
# Define allowed MIME types for each extension
extension_mime_types = {
# Images
'.jpg': ['image/jpeg'],
'.jpeg': ['image/jpeg'],
'.png': ['image/png'],
'.gif': ['image/gif'],
'.bmp': ['image/bmp'],
'.webp': ['image/webp'],
# Documents
'.pdf': ['application/pdf'],
'.txt': ['text/plain'],
'.md': ['text/plain', 'text/markdown'],
'.csv': ['text/csv', 'text/plain'],
'.json': ['application/json', 'text/plain'],
'.xml': ['application/xml', 'text/xml', 'text/plain'],
'.yaml': ['text/plain', 'text/yaml'],
'.yml': ['text/plain', 'text/yaml'],
'.ini': ['text/plain'],
'.log': ['text/plain'],
# Code files
'.js': ['text/javascript', 'application/javascript', 'text/plain'],
'.css': ['text/css', 'text/plain'],
'.py': ['text/x-python', 'text/plain'],
'.html': ['text/html', 'text/plain'],
'.htm': ['text/html', 'text/plain']
}
# Get allowed MIME types for the claimed extension
allowed_mime_types = extension_mime_types.get(claimed_extension.lower(), [])
# Special case for text files - if it's claimed to be a text format
# and the MIME type indicates it's text, consider it valid
text_extensions = {'.txt', '.md', '.csv', '.json', '.xml', '.yaml', '.yml',
'.ini', '.log', '.js', '.css', '.py', '.html', '.htm'}
if claimed_extension.lower() in text_extensions and (
file_type.startswith('text/') or
'charset=us-ascii' in file_type or
'charset=utf-8' in file_type
):
return True, file_type
return file_type in allowed_mime_types, file_type