add app config and settings generator
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
notes/**
|
||||
notes/**/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
6
README.md
Normal file
6
README.md
Normal file
@@ -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.
|
||||
219
app.py
219
app.py
@@ -1,21 +1,31 @@
|
||||
# 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 mistune
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
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
|
||||
from app_settings_loader import ensure_settings_ini, FLASK_HOST, FLASK_PORT, SECRET_KEY, DEBUG, NOTES_DIR, MAX_CONTENT_LENGTH
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['NOTES_DIR'] = Path('notes') # Store Markdown files here
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||
ensure_settings_ini()
|
||||
|
||||
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
|
||||
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
|
||||
crumbs.append({
|
||||
'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 title:
|
||||
crumbs.append({'name': title, 'url': None})
|
||||
else:
|
||||
crumbs.append({'name': parts[-1][:-3], 'url': None}) # Remove .md extension
|
||||
|
||||
name = parts[-1][:-3] if parts[-1].endswith('.md') else parts[-1]
|
||||
url = None if parts[-1].endswith('.md') else url_for('folder', folder_path=current_path + '/' + parts[-1] if current_path else parts[-1])
|
||||
crumbs.append({'name': name, 'url': url})
|
||||
|
||||
return crumbs
|
||||
|
||||
@app.route('/')
|
||||
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'])
|
||||
return render_template('index.html',
|
||||
return render_template('folder.html',
|
||||
folder_path='',
|
||||
folder_contents=folder_contents,
|
||||
notes_tree=notes_tree,
|
||||
active_path=[],
|
||||
current_note=None,
|
||||
@@ -105,9 +135,6 @@ def note(note_path):
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
title = full_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
# Convert markdown to HTML
|
||||
renderer = mistune.HTMLRenderer()
|
||||
@@ -117,6 +144,8 @@ def note(note_path):
|
||||
# Build tree and get path components
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
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)
|
||||
|
||||
return render_template('note.html',
|
||||
@@ -149,9 +178,6 @@ def edit_note(note_path):
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
title = full_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
notes_tree = build_tree_structure(app.config['NOTES_DIR'])
|
||||
active_path = get_path_components(note_path)
|
||||
breadcrumbs = generate_breadcrumbs(note_path, f"Edit {title}")
|
||||
@@ -166,32 +192,96 @@ def edit_note(note_path):
|
||||
except Exception as e:
|
||||
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'])
|
||||
def create_note():
|
||||
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()
|
||||
path = request.form.get('path', '').strip()
|
||||
|
||||
# Generate filename
|
||||
filename = f"{generate_slug(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
|
||||
|
||||
# Use the title as the file name
|
||||
filename = f"{title}.md"
|
||||
|
||||
try:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(f"## {title}\n\n{content}")
|
||||
return redirect(url_for('note', note_path=str(file_path.relative_to(app.config['NOTES_DIR']))))
|
||||
except Exception as e:
|
||||
return f"Error creating note: {str(e)}", 500
|
||||
# Safely resolve the full path to ensure it's within NOTES_DIR
|
||||
if path:
|
||||
dir_path = (Path(app.config['NOTES_DIR']) / path).resolve()
|
||||
if not str(dir_path).startswith(str(app.config['NOTES_DIR'].resolve())):
|
||||
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'])
|
||||
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'])
|
||||
def delete_note(note_path):
|
||||
@@ -222,10 +312,7 @@ def search():
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
if query in content.lower():
|
||||
title = file_path.stem
|
||||
if '##' in content:
|
||||
title = content.split('##')[1].split('\n')[0].strip()
|
||||
|
||||
title = file_path.stem # Always use file name without .md
|
||||
# Find context for the match
|
||||
pos = content.lower().find(query)
|
||||
start = max(0, pos - 50)
|
||||
@@ -242,5 +329,43 @@ def search():
|
||||
|
||||
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__':
|
||||
app.run(debug=True)
|
||||
app.run(debug=True, host=FLASK_HOST, port=FLASK_PORT)
|
||||
196
app_settings_loader.py
Normal file
196
app_settings_loader.py
Normal file
@@ -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)
|
||||
10
notes/hello-world.md
Normal file
10
notes/hello-world.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## Hello You dubas :D
|
||||
- blower sux out
|
||||
|
||||
```python
|
||||
print("Not today")
|
||||
|
||||
def run_over()
|
||||
geto = os.pwd()
|
||||
return geto
|
||||
```
|
||||
10
settings.ini
Normal file
10
settings.ini
Normal file
@@ -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 {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
#sidebar {
|
||||
padding-bottom: 2.5rem !important;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,55 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% 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 %}
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title</label>
|
||||
<input id="title" name="title" class="form-control" required>
|
||||
<div class="mb-3 position-relative">
|
||||
<label for="note-path" class="form-label">Note path</label>
|
||||
<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 class="mb-3">
|
||||
<label for="content" class="form-label">Content</label>
|
||||
@@ -14,11 +59,163 @@
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
const easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content')
|
||||
});
|
||||
// Make folders data available to JavaScript
|
||||
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>
|
||||
{% endblock %}
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
{% 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 %}
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
@@ -14,10 +18,60 @@
|
||||
{% endblock %}
|
||||
|
||||
{% 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>
|
||||
const easyMDE = new EasyMDE({
|
||||
element: document.getElementById('content')
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
17
templates/error.html
Normal file
17
templates/error.html
Normal file
@@ -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 %}
|
||||
34
templates/folder.html
Normal file
34
templates/folder.html
Normal file
@@ -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 %}
|
||||
@@ -4,8 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-3">
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="mb-4">
|
||||
<div class="mb-3">
|
||||
<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;">
|
||||
<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