diff --git a/backend/export.py b/backend/export.py
new file mode 100644
index 0000000..15a8bdf
--- /dev/null
+++ b/backend/export.py
@@ -0,0 +1,560 @@
+"""
+HTML Export Module for NoteDiscovery
+Generates standalone HTML files for notes with embedded images and styling.
+Used by both /api/export (download) and /public (sharing) endpoints.
+"""
+
+import base64
+import re
+from pathlib import Path
+from typing import Optional
+import mimetypes
+
+
+def get_image_as_base64(image_path: Path) -> Optional[str]:
+ """Read an image file and return it as a base64 data URL."""
+ if not image_path.exists() or not image_path.is_file():
+ return None
+
+ # Get MIME type
+ mime_type, _ = mimetypes.guess_type(str(image_path))
+ if not mime_type or not mime_type.startswith('image/'):
+ return None
+
+ try:
+ with open(image_path, 'rb') as f:
+ image_data = f.read()
+ base64_data = base64.b64encode(image_data).decode('utf-8')
+ return f"data:{mime_type};base64,{base64_data}"
+ except Exception as e:
+ print(f"Failed to read image {image_path}: {e}")
+ return None
+
+
+def strip_frontmatter(content: str) -> str:
+ """
+ Remove YAML frontmatter from markdown content.
+ Frontmatter is delimited by --- at the start and end.
+ """
+ if not content.strip().startswith('---'):
+ return content
+
+ lines = content.split('\n')
+ if lines[0].strip() != '---':
+ return content
+
+ # Find closing ---
+ end_idx = -1
+ for i in range(1, len(lines)):
+ if lines[i].strip() == '---':
+ end_idx = i
+ break
+
+ if end_idx == -1:
+ return content
+
+ # Remove frontmatter and return the rest
+ return '\n'.join(lines[end_idx + 1:]).strip()
+
+
+def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
+ """
+ Search for an image file in common attachment locations.
+ Returns the resolved path if found, None otherwise.
+ """
+ # Common locations to search for images (fast path)
+ search_paths = [
+ note_folder / image_name, # Same folder as note
+ note_folder / '_attachments' / image_name, # Note's _attachments folder
+ notes_dir / '_attachments' / image_name, # Root _attachments folder
+ ]
+
+ # Also search in parent folders' _attachments (for nested notes)
+ current = note_folder
+ while current != notes_dir and current.parent != current:
+ search_paths.append(current / '_attachments' / image_name)
+ current = current.parent
+
+ for path in search_paths:
+ resolved = path.resolve()
+ if resolved.exists() and resolved.is_file():
+ # Security: ensure path is within notes_dir
+ try:
+ resolved.relative_to(notes_dir.resolve())
+ return resolved
+ except ValueError:
+ continue
+
+ # Fallback: search all _attachments folders recursively (slower but thorough)
+ # This handles cross-folder image references like in Obsidian
+ try:
+ for attachment_folder in notes_dir.rglob('_attachments'):
+ if attachment_folder.is_dir():
+ candidate = attachment_folder / image_name
+ if candidate.exists() and candidate.is_file():
+ try:
+ candidate.resolve().relative_to(notes_dir.resolve())
+ return candidate.resolve()
+ except ValueError:
+ continue
+ except Exception:
+ pass # Ignore errors in recursive search
+
+ return None
+
+
+def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
+ """
+ Find all image references in markdown and embed them as base64.
+ Handles:
+ - Standard markdown images: 
+ - Wikilink images: ![[image.png]] or ![[image.png|alt text]]
+ """
+
+ # First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]]
+ wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
+
+ def replace_wikilink_image(match):
+ image_name = match.group(1).strip()
+ alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0]
+
+ # Find the image
+ resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
+
+ if resolved_path:
+ base64_url = get_image_as_base64(resolved_path)
+ if base64_url:
+ return f''
+
+ # Image not found, convert to placeholder
+ return f'![{alt_text}]()'
+
+ markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content)
+
+ # Then, handle standard markdown images: 
+ img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
+
+ def replace_image(match):
+ alt_text = match.group(1)
+ img_path = match.group(2)
+
+ # Skip external URLs and already-embedded base64
+ if img_path.startswith(('http://', 'https://', 'data:')):
+ return match.group(0)
+
+ # Skip empty paths (from failed wikilink conversion)
+ if not img_path:
+ return match.group(0)
+
+ # Handle /api/images/ paths (convert to filesystem paths)
+ if img_path.startswith('/api/images/'):
+ # Strip the /api/images/ prefix to get the relative path within notes_dir
+ relative_path = img_path[len('/api/images/'):]
+ resolved_path = (notes_dir / relative_path).resolve()
+ else:
+ # Try to resolve the image path relative to note folder
+ resolved_path = (note_folder / img_path).resolve()
+
+ # If not found, try the attachment search
+ if not resolved_path.exists():
+ # Extract just the filename and search
+ image_name = Path(img_path).name
+ resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
+ if not resolved_path:
+ return match.group(0) # Keep original if not found
+
+ # Security: ensure path is within notes_dir
+ try:
+ resolved_path.relative_to(notes_dir.resolve())
+ except ValueError:
+ # Path is outside notes_dir, skip
+ return match.group(0)
+
+ # Get base64 data
+ base64_url = get_image_as_base64(resolved_path)
+ if base64_url:
+ return f''
+
+ # Image not found, keep original
+ return match.group(0)
+
+ markdown_content = re.sub(img_pattern, replace_image, markdown_content)
+
+ return markdown_content
+
+
+def convert_wikilinks_to_html(markdown_content: str) -> str:
+ """
+ Convert wikilinks [[note]] or [[note|display text]] to HTML links.
+ In standalone export mode, these are non-functional decorative links.
+ """
+ # Pattern for wikilinks: [[target]] or [[target|display text]]
+ # But NOT image wikilinks (those start with !)
+ wikilink_pattern = r'(?{display}'
+
+ return re.sub(wikilink_pattern, replace_wikilink, markdown_content)
+
+
+def generate_export_html(
+ title: str,
+ content: str,
+ theme_css: str,
+ is_dark: bool = False
+) -> str:
+ """
+ Generate a standalone HTML document for a note.
+ Uses marked.js for client-side markdown rendering.
+
+ Args:
+ title: The note title (for
and display)
+ content: Raw markdown content (images should already be base64 embedded)
+ theme_css: CSS content for theming
+ is_dark: Whether using a dark theme (for Mermaid/Highlight.js)
+
+ Returns:
+ Complete HTML document as string
+ """
+ # Escape content for JavaScript string
+ escaped_content = (
+ content
+ .replace('\\', '\\\\')
+ .replace('`', '\\`')
+ .replace('$', '\\$')
+ .replace('', '<\\/') # Prevent breaking
+ )
+
+ highlight_theme = 'github-dark' if is_dark else 'github'
+ mermaid_theme = 'dark' if is_dark else 'default'
+
+ html = f'''
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+ return html
diff --git a/backend/main.py b/backend/main.py
index aaa547e..7747747 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -44,6 +44,17 @@ from .utils import (
)
from .plugins import PluginManager
from .themes import get_available_themes, get_theme_css
+from .share import (
+ create_share_token,
+ get_share_token,
+ get_share_info,
+ revoke_share_token,
+ get_note_by_token,
+ delete_token_for_note,
+ update_token_path,
+ get_all_shared_paths,
+)
+from .export import generate_export_html, embed_images_as_base64, convert_wikilinks_to_html, strip_frontmatter
# Load configuration
config_path = Path(__file__).parent.parent / "config.yaml"
@@ -715,6 +726,9 @@ async def move_note_endpoint(request: Request, data: dict):
if not success:
raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
+ # Update share token path if note was shared
+ update_token_path(config['storage']['notes_dir'], old_path, new_path)
+
# Run plugin hooks
plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
@@ -1041,6 +1055,9 @@ async def remove_note(request: Request, note_path: str):
if not success:
raise HTTPException(status_code=404, detail="Note not found")
+ # Clean up any share token for this note
+ delete_token_for_note(config['storage']['notes_dir'], note_path)
+
# Run plugin hooks
plugin_manager.run_hook('on_note_delete', note_path=note_path)
@@ -1256,6 +1273,195 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
+# ============================================================================
+# Share Token Endpoints (authenticated)
+# ============================================================================
+
+@api_router.post("/share/{note_path:path}")
+@limiter.limit("30/minute")
+async def create_share(request: Request, note_path: str, data: dict = None):
+ """
+ Create a share token for a note.
+ Returns the share URL that can be accessed without authentication.
+ Optionally accepts { "theme": "theme-name" } to set the display theme.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Get theme from request body (default to light)
+ theme = "light"
+ if data and isinstance(data, dict):
+ theme = data.get('theme', 'light')
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Check if note exists
+ content = get_note_content(notes_dir, note_path)
+ if content is None:
+ raise HTTPException(status_code=404, detail="Note not found")
+
+ # Create or get existing token (with theme)
+ token = create_share_token(notes_dir, note_path, theme)
+ if not token:
+ raise HTTPException(status_code=500, detail="Failed to create share token")
+
+ # Build share URL
+ base_url = str(request.base_url).rstrip('/')
+ share_url = f"{base_url}/share/{token}"
+
+ return {
+ "success": True,
+ "token": token,
+ "url": share_url,
+ "path": note_path,
+ "theme": theme
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create share"))
+
+
+@api_router.get("/share/{note_path:path}")
+async def get_share_status(note_path: str, request: Request):
+ """
+ Get the share status for a note.
+ Returns whether the note is shared and its share URL if so.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Get share info
+ info = get_share_info(notes_dir, note_path)
+
+ if info.get('shared'):
+ base_url = str(request.base_url).rstrip('/')
+ info['url'] = f"{base_url}/share/{info['token']}"
+
+ return info
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get share status"))
+
+
+@api_router.get("/shared-notes")
+async def list_shared_notes():
+ """
+ Get a list of all currently shared note paths.
+ Used for displaying share indicators in the UI.
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+ shared_paths = get_all_shared_paths(notes_dir)
+ return {"paths": shared_paths}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes"))
+
+
+@api_router.delete("/share/{note_path:path}")
+@limiter.limit("30/minute")
+async def delete_share(request: Request, note_path: str):
+ """
+ Revoke sharing for a note (delete the share token).
+ """
+ try:
+ notes_dir = config['storage']['notes_dir']
+
+ # Add .md extension if not present
+ if not note_path.endswith('.md'):
+ note_path = f"{note_path}.md"
+
+ # Revoke token
+ success = revoke_share_token(notes_dir, note_path)
+
+ return {
+ "success": success,
+ "message": "Share revoked" if success else "Note was not shared"
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to revoke share"))
+
+
+# ============================================================================
+# Public Share Endpoint (no authentication required)
+# ============================================================================
+
+@app.get("/share/{token}", response_class=HTMLResponse)
+@limiter.limit("60/minute")
+async def view_shared_note(request: Request, token: str):
+ """
+ View a shared note by its token.
+ No authentication required - anyone with the token can view.
+ """
+ try:
+ notes_dir = Path(config['storage']['notes_dir'])
+
+ # Look up note by token (returns dict with path and theme)
+ share_info = get_note_by_token(str(notes_dir), token)
+ if not share_info:
+ raise HTTPException(status_code=404, detail="Shared note not found or link expired")
+
+ note_path = share_info['path']
+ theme = share_info.get('theme', 'light')
+
+ # Read note content
+ content = get_note_content(str(notes_dir), note_path)
+ if content is None:
+ # Note was deleted but token still exists - clean up
+ delete_token_for_note(str(notes_dir), note_path)
+ raise HTTPException(status_code=404, detail="Note no longer exists")
+
+ # Strip YAML frontmatter (like the preview does)
+ content = strip_frontmatter(content)
+
+ # Get note folder for resolving relative image paths
+ note_file_path = notes_dir / note_path
+ note_folder = note_file_path.parent
+
+ # Embed images as base64
+ content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
+
+ # Convert wikilinks to decorative HTML links
+ content_with_links = convert_wikilinks_to_html(content_with_images)
+
+ # Use the theme that was set when sharing
+ themes_dir = Path(__file__).parent.parent / "themes"
+ theme_css = get_theme_css(str(themes_dir), theme)
+ if not theme_css:
+ theme_css = get_theme_css(str(themes_dir), "light")
+ theme = "light"
+
+ # Strip data-theme selector
+ theme_css = theme_css.replace(f':root[data-theme="{theme}"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
+ theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
+
+ # Determine if dark theme
+ is_dark = 'dark' in theme.lower() or theme in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
+
+ # Get note title
+ title = Path(note_path).stem
+
+ # Generate HTML
+ html_content = generate_export_html(
+ title=title,
+ content=content_with_links,
+ theme_css=theme_css,
+ is_dark=is_dark
+ )
+
+ return HTMLResponse(content=html_content)
+ except HTTPException:
+ raise
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load shared note"))
+
+
@app.get("/health")
async def health_check():
"""Health check endpoint"""
@@ -1305,4 +1511,3 @@ if __name__ == "__main__":
port=config['server']['port'],
reload=config['server']['reload']
)
-
diff --git a/backend/share.py b/backend/share.py
new file mode 100644
index 0000000..c4198b5
--- /dev/null
+++ b/backend/share.py
@@ -0,0 +1,238 @@
+"""
+Share Token Management for NoteDiscovery
+Handles creating, storing, and revoking share tokens for public note access.
+"""
+
+import json
+import secrets
+import string
+from pathlib import Path
+from datetime import datetime, timezone
+from typing import Optional, Dict, Any
+import threading
+
+# Thread lock for safe concurrent access
+_lock = threading.Lock()
+
+
+def generate_token(length: int = 16) -> str:
+ """Generate a URL-safe random token."""
+ # Use alphanumeric + underscore/hyphen (URL-safe)
+ alphabet = string.ascii_letters + string.digits + '_-'
+ return ''.join(secrets.choice(alphabet) for _ in range(length))
+
+
+def get_tokens_file_path(data_dir: str) -> Path:
+ """Get the path to the share tokens file."""
+ return Path(data_dir) / '.share-tokens.json'
+
+
+def load_tokens(data_dir: str) -> Dict[str, Dict[str, Any]]:
+ """Load share tokens from file."""
+ tokens_file = get_tokens_file_path(data_dir)
+
+ if not tokens_file.exists():
+ return {}
+
+ try:
+ with open(tokens_file, 'r', encoding='utf-8') as f:
+ return json.load(f)
+ except (json.JSONDecodeError, IOError):
+ return {}
+
+
+def save_tokens(data_dir: str, tokens: Dict[str, Dict[str, Any]]) -> bool:
+ """Save share tokens to file."""
+ tokens_file = get_tokens_file_path(data_dir)
+
+ try:
+ # Ensure parent directory exists
+ tokens_file.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(tokens_file, 'w', encoding='utf-8') as f:
+ json.dump(tokens, f, indent=2, ensure_ascii=False)
+ return True
+ except IOError as e:
+ print(f"Failed to save share tokens: {e}")
+ return False
+
+
+def create_share_token(data_dir: str, note_path: str, theme: str = "light") -> Optional[str]:
+ """
+ Create a share token for a note.
+ If the note already has a token, returns the existing one.
+
+ Args:
+ data_dir: Path to the data directory
+ note_path: Path to the note (relative to notes_dir)
+ theme: The theme to use when viewing the shared note
+
+ Returns:
+ The share token, or None on error
+ """
+ with _lock:
+ tokens = load_tokens(data_dir)
+
+ # Check if note already has a token
+ for token, info in tokens.items():
+ if info.get('path') == note_path:
+ return token
+
+ # Generate new token
+ token = generate_token()
+
+ # Ensure uniqueness (extremely unlikely collision, but check anyway)
+ while token in tokens:
+ token = generate_token()
+
+ # Store token with theme
+ tokens[token] = {
+ 'path': note_path,
+ 'theme': theme,
+ 'created': datetime.now(timezone.utc).isoformat()
+ }
+
+ if save_tokens(data_dir, tokens):
+ return token
+ return None
+
+
+def get_share_token(data_dir: str, note_path: str) -> Optional[str]:
+ """
+ Get the share token for a note, if it exists.
+
+ Args:
+ data_dir: Path to the data directory
+ note_path: Path to the note
+
+ Returns:
+ The share token, or None if not shared
+ """
+ tokens = load_tokens(data_dir)
+
+ for token, info in tokens.items():
+ if info.get('path') == note_path:
+ return token
+
+ return None
+
+
+def get_note_by_token(data_dir: str, token: str) -> Optional[Dict[str, Any]]:
+ """
+ Get the note info for a share token.
+
+ Args:
+ data_dir: Path to the data directory
+ token: The share token
+
+ Returns:
+ Dict with 'path' and 'theme', or None if token not found
+ """
+ tokens = load_tokens(data_dir)
+
+ if token in tokens:
+ return {
+ 'path': tokens[token].get('path'),
+ 'theme': tokens[token].get('theme', 'light')
+ }
+
+ return None
+
+
+def get_all_shared_paths(data_dir: str) -> list:
+ """
+ Get a list of all currently shared note paths.
+ Used for displaying share indicators in the UI.
+
+ Args:
+ data_dir: Path to the data directory
+
+ Returns:
+ List of note paths that are currently shared
+ """
+ tokens = load_tokens(data_dir)
+ return [info.get('path') for info in tokens.values() if info.get('path')]
+
+
+def revoke_share_token(data_dir: str, note_path: str) -> bool:
+ """
+ Revoke (delete) the share token for a note.
+
+ Args:
+ data_dir: Path to the data directory
+ note_path: Path to the note
+
+ Returns:
+ True if token was revoked, False if not found or error
+ """
+ with _lock:
+ tokens = load_tokens(data_dir)
+
+ # Find and remove token for this note
+ token_to_remove = None
+ for token, info in tokens.items():
+ if info.get('path') == note_path:
+ token_to_remove = token
+ break
+
+ if token_to_remove:
+ del tokens[token_to_remove]
+ return save_tokens(data_dir, tokens)
+
+ return False
+
+
+def get_share_info(data_dir: str, note_path: str) -> Optional[Dict[str, Any]]:
+ """
+ Get share information for a note.
+
+ Args:
+ data_dir: Path to the data directory
+ note_path: Path to the note
+
+ Returns:
+ Dict with token, theme, and created date, or None if not shared
+ """
+ tokens = load_tokens(data_dir)
+
+ for token, info in tokens.items():
+ if info.get('path') == note_path:
+ return {
+ 'token': token,
+ 'theme': info.get('theme', 'light'),
+ 'created': info.get('created'),
+ 'shared': True
+ }
+
+ return {'shared': False}
+
+
+def update_token_path(data_dir: str, old_path: str, new_path: str) -> bool:
+ """
+ Update the path for a token when a note is moved/renamed.
+
+ Args:
+ data_dir: Path to the data directory
+ old_path: Old note path
+ new_path: New note path
+
+ Returns:
+ True if updated, False if not found or error
+ """
+ with _lock:
+ tokens = load_tokens(data_dir)
+
+ for token, info in tokens.items():
+ if info.get('path') == old_path:
+ info['path'] = new_path
+ return save_tokens(data_dir, tokens)
+
+ return False
+
+
+def delete_token_for_note(data_dir: str, note_path: str) -> bool:
+ """
+ Delete the share token when a note is deleted.
+ Alias for revoke_share_token for clarity.
+ """
+ return revoke_share_token(data_dir, note_path)
diff --git a/frontend/app.js b/frontend/app.js
index 525c23b..592fd2f 100644
--- a/frontend/app.js
+++ b/frontend/app.js
@@ -255,6 +255,13 @@ function noteApp() {
selectedTemplate: '',
newTemplateNoteName: '',
+ // Share state
+ showShareModal: false,
+ shareInfo: null,
+ shareLoading: false,
+ shareLinkCopied: false,
+ _sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
+
// Homepage state
selectedHomepageFolder: '',
_homepageCache: {
@@ -442,6 +449,7 @@ function noteApp() {
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
// loadLocale() is only called when user changes language from settings
await this.loadNotes();
+ await this.loadSharedNotePaths();
await this.loadTemplates();
await this.checkStatsPlugin();
this.loadSidebarWidth();
@@ -483,6 +491,7 @@ function noteApp() {
this.noteContent = '';
this.currentNoteName = '';
this.outline = [];
+ this.shareInfo = null; // Reset share info
document.title = this.appName;
// Restore homepage folder state if it was saved
@@ -1792,7 +1801,9 @@ function noteApp() {
const isCurrentImage = this.currentImage === note.path;
const isCurrent = isImage ? isCurrentImage : isCurrentNote;
- // Different icon for images
+ // Different icon for images, share icon for shared notes
+ const isShared = !isImage && this.isNoteShared(note.path);
+ const shareIcon = isShared ? '' : '';
const icon = isImage ? '🖼️' : '';
// Escape paths for use in native event handlers
@@ -1818,7 +1829,7 @@ function noteApp() {
onmouseover="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='var(--bg-hover)'"
onmouseout="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='transparent'"
>
- ${icon}${icon ? ' ' : ''}${note.name}
+ ${shareIcon}${icon}${icon ? ' ' : ''}${note.name}
+
+
+