add app config and settings generator
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
notes/**
|
notes/**/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*.pyo
|
*.pyo
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Flask application to view Obsidian notes in web browser
|
||||||
|
- just point the location of your folder with `.md` notes to app with `NOTES_DIR`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
This app uses a `settings.ini` file for configuration. On first run, it will be created automatically if missing, and any missing values will be filled in with defaults.
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
# app.py
|
# app.py
|
||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, abort
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
import os
|
import os
|
||||||
import mistune
|
import mistune
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
|
||||||
import re
|
import re
|
||||||
from urllib.parse import quote
|
from app_settings_loader import ensure_settings_ini, FLASK_HOST, FLASK_PORT, SECRET_KEY, DEBUG, NOTES_DIR, MAX_CONTENT_LENGTH
|
||||||
|
|
||||||
def generate_slug(text):
|
|
||||||
# Remove non-alphanumeric characters, replace spaces with hyphens, and lowercase
|
|
||||||
slug = re.sub(r'[^a-zA-Z0-9\s]', '', text).strip().lower()
|
|
||||||
slug = re.sub(r'\s+', '-', slug)
|
|
||||||
return quote(slug) # URL-encode for safety
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['NOTES_DIR'] = Path('notes') # Store Markdown files here
|
ensure_settings_ini()
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
|
||||||
|
app.config['SECRET_KEY'] = SECRET_KEY
|
||||||
|
app.config['DEBUG'] = DEBUG
|
||||||
|
app.config['NOTES_DIR'] = NOTES_DIR
|
||||||
|
max_content_length = MAX_CONTENT_LENGTH * 1024 * 1024 # Convert MB to bytes
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = max_content_length
|
||||||
|
|
||||||
|
# Custom error handler using base.html
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_error(error):
|
||||||
|
if isinstance(error, HTTPException):
|
||||||
|
code = error.code
|
||||||
|
message = getattr(error, 'description', str(error))
|
||||||
|
else:
|
||||||
|
code = 500
|
||||||
|
message = str(error)
|
||||||
|
return render_template('error.html', message=message, error=error), code
|
||||||
|
|
||||||
# Create directory for notes if it doesn't exist
|
# Create directory for notes if it doesn't exist
|
||||||
if not app.config['NOTES_DIR'].exists():
|
if not app.config['NOTES_DIR'].exists():
|
||||||
@@ -75,22 +85,42 @@ def generate_breadcrumbs(note_path, title=None):
|
|||||||
current_path = current_path + '/' + part if current_path else part
|
current_path = current_path + '/' + part if current_path else part
|
||||||
crumbs.append({
|
crumbs.append({
|
||||||
'name': part,
|
'name': part,
|
||||||
'url': url_for('index') + '#' + current_path # Using hash for directory navigation
|
'url': url_for('folder', folder_path=current_path)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Add the file name (use title if provided)
|
# Add the file name (always use file name without .md)
|
||||||
if len(parts) > 0:
|
if len(parts) > 0:
|
||||||
if title:
|
name = parts[-1][:-3] if parts[-1].endswith('.md') else parts[-1]
|
||||||
crumbs.append({'name': title, 'url': None})
|
url = None if parts[-1].endswith('.md') else url_for('folder', folder_path=current_path + '/' + parts[-1] if current_path else parts[-1])
|
||||||
else:
|
crumbs.append({'name': name, 'url': url})
|
||||||
crumbs.append({'name': parts[-1][:-3], 'url': None}) # Remove .md extension
|
|
||||||
|
|
||||||
return crumbs
|
return crumbs
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
|
folder_contents = []
|
||||||
|
try:
|
||||||
|
for item in sorted(app.config['NOTES_DIR'].iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
||||||
|
if item.name.startswith('.'): # Skip hidden files
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dir = item.is_dir()
|
||||||
|
rel_path = str(item.relative_to(app.config['NOTES_DIR']))
|
||||||
|
|
||||||
|
if is_dir or item.suffix == '.md': # Only include directories and markdown files
|
||||||
|
folder_contents.append({
|
||||||
|
'name': item.name,
|
||||||
|
'type': 'dir' if is_dir else 'file',
|
||||||
|
'path': rel_path,
|
||||||
|
'display_name': item.name if is_dir else item.stem # Show full name for folders, remove .md for files
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error reading root directory: {str(e)}")
|
||||||
|
|
||||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||||
return render_template('index.html',
|
return render_template('folder.html',
|
||||||
|
folder_path='',
|
||||||
|
folder_contents=folder_contents,
|
||||||
notes_tree=notes_tree,
|
notes_tree=notes_tree,
|
||||||
active_path=[],
|
active_path=[],
|
||||||
current_note=None,
|
current_note=None,
|
||||||
@@ -105,9 +135,6 @@ def note(note_path):
|
|||||||
try:
|
try:
|
||||||
with open(full_path, 'r', encoding='utf-8') as f:
|
with open(full_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
title = full_path.stem
|
|
||||||
if '##' in content:
|
|
||||||
title = content.split('##')[1].split('\n')[0].strip()
|
|
||||||
|
|
||||||
# Convert markdown to HTML
|
# Convert markdown to HTML
|
||||||
renderer = mistune.HTMLRenderer()
|
renderer = mistune.HTMLRenderer()
|
||||||
@@ -117,6 +144,8 @@ def note(note_path):
|
|||||||
# Build tree and get path components
|
# Build tree and get path components
|
||||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||||
active_path = get_path_components(note_path)
|
active_path = get_path_components(note_path)
|
||||||
|
# Use file name (without .md) for breadcrumbs and title
|
||||||
|
title = full_path.stem
|
||||||
breadcrumbs = generate_breadcrumbs(note_path, title)
|
breadcrumbs = generate_breadcrumbs(note_path, title)
|
||||||
|
|
||||||
return render_template('note.html',
|
return render_template('note.html',
|
||||||
@@ -149,9 +178,6 @@ def edit_note(note_path):
|
|||||||
with open(full_path, 'r', encoding='utf-8') as f:
|
with open(full_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
title = full_path.stem
|
title = full_path.stem
|
||||||
if '##' in content:
|
|
||||||
title = content.split('##')[1].split('\n')[0].strip()
|
|
||||||
|
|
||||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||||
active_path = get_path_components(note_path)
|
active_path = get_path_components(note_path)
|
||||||
breadcrumbs = generate_breadcrumbs(note_path, f"Edit {title}")
|
breadcrumbs = generate_breadcrumbs(note_path, f"Edit {title}")
|
||||||
@@ -166,32 +192,96 @@ def edit_note(note_path):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error reading note: {str(e)}", 500
|
return f"Error reading note: {str(e)}", 500
|
||||||
|
|
||||||
|
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(directory.iterdir(), key=lambda x: x.name.lower()):
|
||||||
|
if item.name.startswith('.'): # Skip hidden files
|
||||||
|
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
|
||||||
|
|
||||||
@app.route('/create', methods=['GET', 'POST'])
|
@app.route('/create', methods=['GET', 'POST'])
|
||||||
def create_note():
|
def create_note():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
title = request.form.get('title', '').strip() or 'Untitled Note'
|
title = request.form.get('title', '').strip()
|
||||||
|
if not title:
|
||||||
|
abort(400, description="Note title is required")
|
||||||
|
|
||||||
|
# Validate file name: only allow safe characters
|
||||||
|
# Letters, numbers, dash, underscore, dot (no slashes)
|
||||||
|
if not re.match(r'^[\w\-. ]+$', title):
|
||||||
|
return "Invalid file name. Only letters, numbers, dash, underscore, dot, and space are allowed.", 400
|
||||||
|
if '/' in title or '\\' in title:
|
||||||
|
return "Invalid file name. Slashes are not allowed.", 400
|
||||||
|
if title.startswith('.') or title.endswith('.'):
|
||||||
|
return "Invalid file name. Cannot start or end with a dot.", 400
|
||||||
|
if title in ['CON', 'PRN', 'AUX', 'NUL'] or re.match(r'^COM[1-9]$|^LPT[1-9]$', title, re.IGNORECASE):
|
||||||
|
return "Invalid file name.", 400
|
||||||
|
|
||||||
content = request.form.get('content', '').strip()
|
content = request.form.get('content', '').strip()
|
||||||
path = request.form.get('path', '').strip()
|
path = request.form.get('path', '').strip()
|
||||||
|
|
||||||
# Generate filename
|
# Use the title as the file name
|
||||||
filename = f"{generate_slug(title)}.md"
|
filename = f"{title}.md"
|
||||||
if path:
|
|
||||||
# Create directories if they don't exist
|
|
||||||
dir_path = Path(app.config['NOTES_DIR']) / path
|
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
|
||||||
file_path = dir_path / filename
|
|
||||||
else:
|
|
||||||
file_path = Path(app.config['NOTES_DIR']) / filename
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'w', encoding='utf-8') as f:
|
# Safely resolve the full path to ensure it's within NOTES_DIR
|
||||||
f.write(f"## {title}\n\n{content}")
|
if path:
|
||||||
return redirect(url_for('note', note_path=str(file_path.relative_to(app.config['NOTES_DIR']))))
|
dir_path = (Path(app.config['NOTES_DIR']) / path).resolve()
|
||||||
except Exception as e:
|
if not str(dir_path).startswith(str(app.config['NOTES_DIR'].resolve())):
|
||||||
return f"Error creating note: {str(e)}", 500
|
return "Invalid folder path", 400
|
||||||
|
os.makedirs(dir_path, exist_ok=True)
|
||||||
|
file_path = dir_path / filename
|
||||||
|
else:
|
||||||
|
file_path = Path(app.config['NOTES_DIR']) / filename
|
||||||
|
|
||||||
|
# Final safety check
|
||||||
|
file_path = file_path.resolve()
|
||||||
|
if not str(file_path).startswith(str(app.config['NOTES_DIR'].resolve())):
|
||||||
|
return "Invalid file path", 400
|
||||||
|
|
||||||
|
# Check if file already exists
|
||||||
|
if file_path.exists():
|
||||||
|
return "A note with this name already exists in the selected folder. Please choose a different name.", 400
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
# Use os.path.relpath for robust path calculation
|
||||||
|
rel_path = os.path.relpath(str(file_path), str(app.config['NOTES_DIR']))
|
||||||
|
return redirect(url_for('note', note_path=rel_path))
|
||||||
|
except Exception as e:
|
||||||
|
abort(500, description=f"Error creating note: {str(e)}")
|
||||||
|
|
||||||
|
# Get all available folders for autocomplete
|
||||||
|
available_folders = get_all_folders(app.config['NOTES_DIR'])
|
||||||
|
|
||||||
|
# Get current folder from query parameter if provided
|
||||||
|
current_folder = request.args.get('folder', '')
|
||||||
|
|
||||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||||
return render_template('create.html', notes_tree=notes_tree, active_path=[], current_note=None)
|
return render_template('create.html',
|
||||||
|
notes_tree=notes_tree,
|
||||||
|
available_folders=available_folders,
|
||||||
|
current_folder=current_folder,
|
||||||
|
active_path=current_folder.split('/') if current_folder else [],
|
||||||
|
current_note=None)
|
||||||
|
|
||||||
@app.route('/delete/<path:note_path>', methods=['POST'])
|
@app.route('/delete/<path:note_path>', methods=['POST'])
|
||||||
def delete_note(note_path):
|
def delete_note(note_path):
|
||||||
@@ -222,10 +312,7 @@ def search():
|
|||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
if query in content.lower():
|
if query in content.lower():
|
||||||
title = file_path.stem
|
title = file_path.stem # Always use file name without .md
|
||||||
if '##' in content:
|
|
||||||
title = content.split('##')[1].split('\n')[0].strip()
|
|
||||||
|
|
||||||
# Find context for the match
|
# Find context for the match
|
||||||
pos = content.lower().find(query)
|
pos = content.lower().find(query)
|
||||||
start = max(0, pos - 50)
|
start = max(0, pos - 50)
|
||||||
@@ -242,5 +329,43 @@ def search():
|
|||||||
|
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
|
@app.route('/folder/<path:folder_path>')
|
||||||
|
def folder(folder_path):
|
||||||
|
folder_full_path = Path(app.config['NOTES_DIR']) / folder_path
|
||||||
|
if not folder_full_path.is_dir():
|
||||||
|
return "Folder not found", 404
|
||||||
|
|
||||||
|
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||||
|
|
||||||
|
# Get the current folder's contents
|
||||||
|
current_folder = folder_full_path
|
||||||
|
folder_contents = []
|
||||||
|
try:
|
||||||
|
for item in sorted(current_folder.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
|
||||||
|
if item.name.startswith('.'): # Skip hidden files
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dir = item.is_dir()
|
||||||
|
rel_path = str(item.relative_to(app.config['NOTES_DIR']))
|
||||||
|
|
||||||
|
if is_dir or item.suffix == '.md': # Only include directories and markdown files
|
||||||
|
folder_contents.append({
|
||||||
|
'name': item.name,
|
||||||
|
'type': 'dir' if is_dir else 'file',
|
||||||
|
'path': rel_path,
|
||||||
|
'display_name': item.name if is_dir else item.stem # Show full name for folders, remove .md for files
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error reading folder: {str(e)}", 500
|
||||||
|
|
||||||
|
return render_template('folder.html',
|
||||||
|
folder_path=folder_path,
|
||||||
|
folder_contents=folder_contents,
|
||||||
|
notes_tree=notes_tree,
|
||||||
|
active_path=folder_path.split('/'),
|
||||||
|
current_note=None,
|
||||||
|
breadcrumbs=generate_breadcrumbs(folder_path))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True, host=FLASK_HOST, port=FLASK_PORT)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""
|
||||||
|
USAGE:
|
||||||
|
|
||||||
|
from app_settings_loader import ensure_settings_ini, get_setting
|
||||||
|
|
||||||
|
# Ensure settings.ini exists and is up to date
|
||||||
|
ensure_settings_ini()
|
||||||
|
|
||||||
|
# Get settings as needed
|
||||||
|
host = get_setting('FLASK', 'FLASK_HOST', fallback='0.0.0.0')
|
||||||
|
port = get_setting('FLASK', 'FLASK_PORT', fallback=5000, type_=int)
|
||||||
|
debug = get_setting('FLASK', 'DEBUG', fallback=False, type_=bool)
|
||||||
|
notes_dir = get_setting('FLASK', 'NOTES_DIR', fallback='notes')
|
||||||
|
|
||||||
|
# You can also use the preloaded variables if you want:
|
||||||
|
# FLASK_HOST, FLASK_PORT, SECRET_KEY, DEBUG, NOTES_DIR, MAX_CONTENT_LENGTH
|
||||||
|
"""
|
||||||
|
|
||||||
|
import configparser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_CONFIG = {
|
||||||
|
'FLASK': {
|
||||||
|
'FLASK_HOST': '0.0.0.0',
|
||||||
|
'FLASK_PORT': '5000',
|
||||||
|
'SECRET_KEY': 'change-this-secret',
|
||||||
|
'DEBUG': 'False',
|
||||||
|
'Specify location where your notes .md files are at - the root folder': None,
|
||||||
|
'NOTES_DIR': 'notes',
|
||||||
|
'Maximum content length in MB': None,
|
||||||
|
'MAX_CONTENT_LENGTH': '16',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
CONFIG_FILE = 'settings.ini'
|
||||||
|
|
||||||
|
def ensure_settings_ini():
|
||||||
|
"""
|
||||||
|
Ensure that settings.ini exists and contains all required settings.
|
||||||
|
- If the file does not exist, it is created with default values and comments.
|
||||||
|
- If the file exists but is missing any required setting, only the missing setting(s)
|
||||||
|
are added with default values (existing values and comments are not changed).
|
||||||
|
"""
|
||||||
|
# First check if file exists at all
|
||||||
|
if not Path(CONFIG_FILE).exists():
|
||||||
|
# Create new file with all default settings and comments
|
||||||
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
|
for section, values in DEFAULT_CONFIG.items():
|
||||||
|
f.write(f'[{section}]\n')
|
||||||
|
|
||||||
|
# Get all items in order
|
||||||
|
items = list(values.items())
|
||||||
|
|
||||||
|
# Process each item in order
|
||||||
|
for i, (key, value) in enumerate(items):
|
||||||
|
if value is not None: # This is a setting
|
||||||
|
# Find the comment that belongs right before this setting
|
||||||
|
prev_comment = None
|
||||||
|
# Look backwards from current position to find the nearest comment
|
||||||
|
for prev_key, prev_val in reversed(items[:i]):
|
||||||
|
if prev_val is None: # It's a comment
|
||||||
|
# Check no other setting exists between comment and current setting
|
||||||
|
has_setting_between = any(
|
||||||
|
k for k, v in items[:i]
|
||||||
|
if v is not None and
|
||||||
|
prev_key.upper() < k.upper() < key.upper()
|
||||||
|
)
|
||||||
|
if not has_setting_between:
|
||||||
|
prev_comment = prev_key
|
||||||
|
break
|
||||||
|
|
||||||
|
# Write the comment if found
|
||||||
|
if prev_comment:
|
||||||
|
f.write(f'; {prev_comment}\n')
|
||||||
|
# Write the setting
|
||||||
|
f.write(f'{key} = {value}\n')
|
||||||
|
f.write('\n')
|
||||||
|
return
|
||||||
|
|
||||||
|
# If file exists, read it line by line to preserve comments and case
|
||||||
|
with open(CONFIG_FILE, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
# Parse existing settings while preserving case
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(CONFIG_FILE)
|
||||||
|
|
||||||
|
# Track what settings we've seen and what we need to add
|
||||||
|
current_section = None
|
||||||
|
settings_seen = {section: set() for section in DEFAULT_CONFIG}
|
||||||
|
missing_settings = {}
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# First pass: identify missing settings while preserving case
|
||||||
|
for section in DEFAULT_CONFIG:
|
||||||
|
missing_settings[section] = {}
|
||||||
|
if section not in config:
|
||||||
|
# New section needed
|
||||||
|
changed = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, value in DEFAULT_CONFIG[section].items():
|
||||||
|
if value is not None: # Skip comments
|
||||||
|
key_lower = key.lower()
|
||||||
|
# Check if setting exists (case-insensitive)
|
||||||
|
if not any(k.lower() == key_lower for k in config[section]):
|
||||||
|
missing_settings[section][key] = value
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
# Remember the actual case of existing keys
|
||||||
|
for existing_key in config[section]:
|
||||||
|
if existing_key.lower() == key_lower:
|
||||||
|
settings_seen[section].add(existing_key)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Second pass: write the updated file
|
||||||
|
new_lines = []
|
||||||
|
current_section = None
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
line = line.rstrip('\n')
|
||||||
|
|
||||||
|
# Handle section headers
|
||||||
|
if line.strip().startswith('[') and line.strip().endswith(']'):
|
||||||
|
if current_section and current_section in missing_settings and missing_settings[current_section]:
|
||||||
|
# Remove trailing empty lines before adding missing settings
|
||||||
|
while new_lines and not new_lines[-1].strip():
|
||||||
|
new_lines.pop()
|
||||||
|
# Add any missing settings before moving to next section
|
||||||
|
for key, value in missing_settings[current_section].items():
|
||||||
|
new_lines.append(f'{key} = {value}')
|
||||||
|
new_lines.append('') # Single newline after section
|
||||||
|
|
||||||
|
current_section = line.strip('[]').strip()
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# If this is a new section, add all its settings
|
||||||
|
if current_section in DEFAULT_CONFIG and current_section not in config:
|
||||||
|
for key, value in DEFAULT_CONFIG[current_section].items():
|
||||||
|
if value is not None: # Only add settings, not comments
|
||||||
|
new_lines.append(f'{key} = {value}')
|
||||||
|
new_lines.append('') # Single newline after section
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip empty lines at the end of sections
|
||||||
|
if line.strip() or not current_section:
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# Handle missing settings in the last section
|
||||||
|
if current_section and current_section in missing_settings and missing_settings[current_section]:
|
||||||
|
# Remove trailing empty lines before adding missing settings
|
||||||
|
while new_lines and not new_lines[-1].strip():
|
||||||
|
new_lines.pop()
|
||||||
|
# Add missing settings
|
||||||
|
for key, value in missing_settings[current_section].items():
|
||||||
|
new_lines.append(f'{key} = {value}')
|
||||||
|
new_lines.append('') # Single newline after section
|
||||||
|
|
||||||
|
# Write the updated file
|
||||||
|
with open(CONFIG_FILE, 'w') as f:
|
||||||
|
f.write('\n'.join(new_lines) + '\n')
|
||||||
|
|
||||||
|
def get_setting(section, key, fallback=None, type_=str):
|
||||||
|
"""
|
||||||
|
Retrieve a setting from settings.ini.
|
||||||
|
Args:
|
||||||
|
section (str): The section in the ini file (e.g. 'FLASK').
|
||||||
|
key (str): The key to retrieve.
|
||||||
|
fallback: The value to return if the key is not found.
|
||||||
|
type_ (type): Optionally convert the value to int or bool. Default is str.
|
||||||
|
Returns:
|
||||||
|
The value from settings.ini, converted to the requested type if specified.
|
||||||
|
"""
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(CONFIG_FILE)
|
||||||
|
if config.has_option(section, key):
|
||||||
|
value = config.get(section, key)
|
||||||
|
if type_ == int:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except Exception:
|
||||||
|
return fallback if fallback is not None else 0
|
||||||
|
if type_ == bool:
|
||||||
|
return value.lower() in ('true', '1', 'yes', 'on')
|
||||||
|
return value
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
# Load settings from the configuration file settings.ini
|
||||||
|
FLASK_HOST = get_setting('FLASK', 'FLASK_HOST', fallback='0.0.0.0')
|
||||||
|
FLASK_PORT = get_setting('FLASK', 'FLASK_PORT', fallback=5000, type_=int)
|
||||||
|
SECRET_KEY = get_setting('FLASK', 'SECRET_KEY', fallback='change-this')
|
||||||
|
DEBUG = get_setting('FLASK', 'DEBUG', fallback='INFO')
|
||||||
|
NOTES_DIR = Path(get_setting('FLASK', 'NOTES_DIR', fallback='notes')).resolve()
|
||||||
|
MAX_CONTENT_LENGTH = get_setting('FLASK', 'MAX_CONTENT_LENGTH', fallback=16, type_=int)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
## Hello You dubas :D
|
||||||
|
- blower sux out
|
||||||
|
|
||||||
|
```python
|
||||||
|
print("Not today")
|
||||||
|
|
||||||
|
def run_over()
|
||||||
|
geto = os.pwd()
|
||||||
|
return geto
|
||||||
|
```
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[FLASK]
|
||||||
|
FLASK_HOST = 0.0.0.0
|
||||||
|
FLASK_PORT = 5000
|
||||||
|
SECRET_KEY = change-this-secret
|
||||||
|
DEBUG = False
|
||||||
|
; Specify location where your notes .md files are at - the root folder
|
||||||
|
NOTES_DIR = notes
|
||||||
|
; Maximum content length in MB
|
||||||
|
MAX_CONTENT_LENGTH = 16
|
||||||
|
|
||||||
@@ -233,4 +233,7 @@ color: #6c757d;
|
|||||||
}
|
}
|
||||||
.file-tree-children {
|
.file-tree-children {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
#sidebar {
|
||||||
|
padding-bottom: 2.5rem !important;
|
||||||
|
}
|
||||||
|
|||||||
+203
-6
@@ -1,10 +1,55 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block title %}Create Note - Flask Blog{% endblock %}
|
{% block title %}Create Note - Flask Blog{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
|
||||||
|
<style>
|
||||||
|
.folder-suggestion {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.folder-suggestion:hover {
|
||||||
|
background-color: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
.folder-suggestion i {
|
||||||
|
opacity: 0.7;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
.folder-suggestion .folder-path {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.9em;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
color: #d0d0d0;
|
||||||
|
}
|
||||||
|
.dropdown-menu.show {
|
||||||
|
display: block;
|
||||||
|
background: #2d2d2d;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3 position-relative">
|
||||||
<label for="title" class="form-label">Title</label>
|
<label for="note-path" class="form-label">Note path</label>
|
||||||
<input id="title" name="title" class="form-control" required>
|
<div class="input-group">
|
||||||
|
<input type="text" id="note-path" class="form-control" placeholder="folder/subfolder/title"
|
||||||
|
autocomplete="off" autocapitalize="off" spellcheck="false"
|
||||||
|
value="{{ current_folder + '/' if current_folder else '' }}">
|
||||||
|
<input type="hidden" id="title" name="title">
|
||||||
|
<input type="hidden" id="path" name="path">
|
||||||
|
</div>
|
||||||
|
<div id="folder-suggestions" class="dropdown-menu" style="width: 100%; max-height: 300px; overflow-y: auto;"></div>
|
||||||
|
<div class="form-text" style="color: aliceblue;">Type '/' for folder autocomplete. The last segment will be the note title.</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="content" class="form-label">Content</label>
|
<label for="content" class="form-label">Content</label>
|
||||||
@@ -14,11 +59,163 @@
|
|||||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ url_for('index') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/python.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/javascript.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/bash.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/markdown.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/json.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const easyMDE = new EasyMDE({
|
// Make folders data available to JavaScript
|
||||||
element: document.getElementById('content')
|
window.availableFolders = {{ available_folders|tojson|safe }};
|
||||||
});
|
|
||||||
|
// Initialize highlight.js
|
||||||
|
window.hljs = hljs;
|
||||||
|
|
||||||
|
// Configure highlight.js with secure options
|
||||||
|
hljs.configure({
|
||||||
|
ignoreUnescapedHTML: true,
|
||||||
|
throwUnescapedHTML: false,
|
||||||
|
languages: ['python', 'javascript', 'bash', 'markdown', 'json']
|
||||||
|
});
|
||||||
|
|
||||||
|
const easyMDE = new EasyMDE({
|
||||||
|
element: document.getElementById('content'),
|
||||||
|
renderingConfig: {
|
||||||
|
codeSyntaxHighlighting: true,
|
||||||
|
},
|
||||||
|
previewRender: function(plainText) {
|
||||||
|
// First, escape the markdown using EasyMDE's default renderer
|
||||||
|
let preview = this.parent.markdown(plainText);
|
||||||
|
|
||||||
|
// After the markdown is rendered, initialize highlighting on any code blocks
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('pre code').forEach((block) => {
|
||||||
|
// Remove all classes that might be from previous highlights
|
||||||
|
block.className = block.className.replace(/hljs-.*\s*/g, '');
|
||||||
|
|
||||||
|
// Get the language if specified in the class
|
||||||
|
const language = Array.from(block.classList)
|
||||||
|
.find(cls => cls.startsWith('language-'))
|
||||||
|
?.substring(9);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply highlighting using the current API
|
||||||
|
if (language) {
|
||||||
|
block.className = `language-${language}`;
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
} else {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error highlighting block:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GitHub-style path input handler
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const pathInput = document.getElementById('note-path');
|
||||||
|
const titleInput = document.getElementById('title');
|
||||||
|
const pathHiddenInput = document.getElementById('path');
|
||||||
|
const suggestionsDiv = document.getElementById('folder-suggestions');
|
||||||
|
const folders = availableFolders;
|
||||||
|
|
||||||
|
let currentPath = '';
|
||||||
|
let lastKeyWasSlash = false;
|
||||||
|
|
||||||
|
function showSuggestions(searchPath) {
|
||||||
|
const parts = searchPath.split('/');
|
||||||
|
const searchTerm = parts[parts.length - 1].toLowerCase();
|
||||||
|
const parentPath = parts.slice(0, -1).join('/');
|
||||||
|
|
||||||
|
// Filter folders that match the current path and search term
|
||||||
|
const matches = folders.filter(folder => {
|
||||||
|
if (parentPath) {
|
||||||
|
return folder.path.startsWith(parentPath + '/') &&
|
||||||
|
folder.name.toLowerCase().includes(searchTerm);
|
||||||
|
}
|
||||||
|
return folder.name.toLowerCase().includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matches.length > 0) {
|
||||||
|
suggestionsDiv.innerHTML = matches.map(folder => `
|
||||||
|
<div class="folder-suggestion" data-path="${folder.path}">
|
||||||
|
<i class="fa fa-folder text-warning"></i>
|
||||||
|
<span>${folder.name}</span>
|
||||||
|
<span class="folder-path">/${folder.path}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
suggestionsDiv.classList.add('show');
|
||||||
|
} else {
|
||||||
|
suggestionsDiv.classList.remove('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSuggestions() {
|
||||||
|
suggestionsDiv.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateInputs(value) {
|
||||||
|
const parts = value.split('/');
|
||||||
|
const title = parts.pop() || '';
|
||||||
|
const path = parts.join('/');
|
||||||
|
|
||||||
|
titleInput.value = title;
|
||||||
|
pathHiddenInput.value = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
pathInput.addEventListener('input', function(e) {
|
||||||
|
const value = e.target.value;
|
||||||
|
updateInputs(value);
|
||||||
|
|
||||||
|
if (lastKeyWasSlash || value.includes('/')) {
|
||||||
|
showSuggestions(value);
|
||||||
|
} else {
|
||||||
|
hideSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
lastKeyWasSlash = value.endsWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
pathInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === '/') {
|
||||||
|
lastKeyWasSlash = true;
|
||||||
|
} else {
|
||||||
|
lastKeyWasSlash = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle suggestion clicks
|
||||||
|
suggestionsDiv.addEventListener('click', function(e) {
|
||||||
|
const suggestion = e.target.closest('.folder-suggestion');
|
||||||
|
if (suggestion) {
|
||||||
|
const folderPath = suggestion.dataset.path;
|
||||||
|
pathInput.value = folderPath + '/';
|
||||||
|
updateInputs(pathInput.value);
|
||||||
|
hideSuggestions();
|
||||||
|
pathInput.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close suggestions when clicking outside
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('#note-path') && !e.target.closest('#folder-suggestions')) {
|
||||||
|
hideSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle initial value if any
|
||||||
|
if (pathInput.value) {
|
||||||
|
updateInputs(pathInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+56
-2
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
{% block title %}Edit {{ title }} - Flask Blog{% endblock %}
|
{% block title %}Edit {{ title }} - Flask Blog{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -14,10 +18,60 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/python.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/javascript.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/bash.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/markdown.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/json.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const easyMDE = new EasyMDE({
|
// Initialize highlight.js
|
||||||
element: document.getElementById('content')
|
window.hljs = hljs;
|
||||||
|
|
||||||
|
// Configure highlight.js with secure options
|
||||||
|
hljs.configure({
|
||||||
|
ignoreUnescapedHTML: true,
|
||||||
|
throwUnescapedHTML: false,
|
||||||
|
languages: ['python', 'javascript', 'bash', 'markdown', 'json']
|
||||||
|
});
|
||||||
|
|
||||||
|
const easyMDE = new EasyMDE({
|
||||||
|
element: document.getElementById('content'),
|
||||||
|
renderingConfig: {
|
||||||
|
codeSyntaxHighlighting: true,
|
||||||
|
},
|
||||||
|
previewRender: function(plainText) {
|
||||||
|
// First, escape the markdown using EasyMDE's default renderer
|
||||||
|
let preview = this.parent.markdown(plainText);
|
||||||
|
|
||||||
|
// After the markdown is rendered, initialize highlighting on any code blocks
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('pre code').forEach((block) => {
|
||||||
|
// Remove all classes that might be from previous highlights
|
||||||
|
block.className = block.className.replace(/hljs-.*\s*/g, '');
|
||||||
|
|
||||||
|
// Get the language if specified in the class
|
||||||
|
const language = Array.from(block.classList)
|
||||||
|
.find(cls => cls.startsWith('language-'))
|
||||||
|
?.substring(9);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Apply highlighting using the current API
|
||||||
|
if (language) {
|
||||||
|
block.className = `language-${language}`;
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
} else {
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error highlighting block:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Error{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
<h4 class="alert-heading">An error occurred</h4>
|
||||||
|
<p>{{ message|safe }}</p>
|
||||||
|
{% if error and error.code %}
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0">Error code: {{ error.code }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('index') }}" class="btn btn-secondary mt-3">Back to Home</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{% if folder_path %}{{ folder_path }}{% else %}Home{% endif %} - Flask Blog{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="m-0">{% if folder_path %}Folder: {{ folder_path }}{% else %}All Notes{% endif %}</h4>
|
||||||
|
<a href="{{ url_for('create_note') }}" class="btn btn-primary btn-sm">New Note</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if folder_contents %}
|
||||||
|
<div class="list-group">
|
||||||
|
{% for item in folder_contents %}
|
||||||
|
{% if item.type == 'dir' %}
|
||||||
|
<a href="{{ url_for('folder', folder_path=item.path) }}"
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center">
|
||||||
|
<i class="fa fa-folder text-warning me-2"></i>
|
||||||
|
<span>{{ item.display_name }}</span>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('note', note_path=item.path) }}"
|
||||||
|
class="list-group-item list-group-item-action d-flex align-items-center">
|
||||||
|
<i class="fa fa-file-text-o text-secondary me-2"></i>
|
||||||
|
<span>{{ item.display_name }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted">This folder is empty.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
+1
-2
@@ -4,8 +4,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h1>{{ title }}</h1>
|
<div class="mb-3">
|
||||||
<div class="mb-4">
|
|
||||||
<a href="{{ url_for('edit_note', note_path=current_note) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
<a href="{{ url_for('edit_note', note_path=current_note) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||||
<form action="{{ url_for('delete_note', note_path=current_note) }}" method="post" style="display:inline;">
|
<form action="{{ url_for('delete_note', note_path=current_note) }}" method="post" style="display:inline;">
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Delete this note?');">Delete</button>
|
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Delete this note?');">Delete</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user