Merge pull request #148 from gamosoft/features/public-notes

Features/public notes
This commit is contained in:
Guillermo Villar 2026-01-15 12:06:35 +01:00 committed by GitHub
commit e6595ff6b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1641 additions and 197 deletions

622
backend/export.py Normal file
View File

@ -0,0 +1,622 @@
"""
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: ![alt](path)
- 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'![{alt_text}]({base64_url})'
# 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: ![alt](path)
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'![{alt_text}]({base64_url})'
# 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'(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink(match):
target = match.group(1).strip()
display = match.group(2).strip() if match.group(2) else target
# Create a decorative link (href="#" since it's standalone)
return f'<a href="#" class="wikilink" title="{target}" style="color: var(--accent-primary, #0366d6); text-decoration: none; border-bottom: 1px dashed currentColor;">{display}</a>'
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 <title> 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 </script> breaking
)
highlight_theme = 'github-dark' if is_dark else 'github'
mermaid_theme = 'dark' if is_dark else 'default'
html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<!-- Highlight.js for code syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/{highlight_theme}.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<!-- Marked.js for markdown parsing -->
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
<!-- MathJax for LaTeX math rendering -->
<script>
MathJax = {{
tex: {{
inlineMath: [['$', '$']],
displayMath: [['$$', '$$']],
processEscapes: true,
processEnvironments: true
}},
options: {{
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre', 'code']
}},
startup: {{
pageReady: () => {{
return MathJax.startup.defaultPageReady().then(() => {{
// Highlight code blocks after MathJax is done
document.querySelectorAll('pre code:not(.language-mermaid)').forEach((block) => {{
hljs.highlightElement(block);
}});
}});
}}
}}
}};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3.2.2/es5/tex-mml-chtml.js"></script>
<!-- Mermaid.js for diagrams -->
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11.12.2/dist/mermaid.esm.min.mjs';
mermaid.initialize({{
startOnLoad: false,
theme: '{mermaid_theme}',
securityLevel: 'strict',
fontFamily: 'inherit',
flowchart: {{ useMaxWidth: true }},
sequence: {{ useMaxWidth: true }},
gantt: {{ useMaxWidth: true }},
state: {{ useMaxWidth: true }},
er: {{ useMaxWidth: true }},
pie: {{ useMaxWidth: true }},
mindmap: {{ useMaxWidth: true }},
gitGraph: {{ useMaxWidth: true }}
}});
// Render Mermaid diagrams after page load
document.addEventListener('DOMContentLoaded', async () => {{
const mermaidBlocks = document.querySelectorAll('pre code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {{
const block = mermaidBlocks[i];
const pre = block.parentElement;
try {{
const code = block.textContent;
const id = 'mermaid-diagram-' + i;
const {{ svg }} = await mermaid.render(id, code);
const container = document.createElement('div');
container.className = 'mermaid-rendered';
container.style.cssText = 'background-color: transparent; padding: 20px; text-align: center; overflow-x: auto;';
container.innerHTML = svg;
pre.parentElement.replaceChild(container, pre);
}} catch (error) {{
console.error('Mermaid rendering error:', error);
}}
}}
}});
</script>
<style>
/* Theme CSS */
{theme_css}
/* Base styles */
* {{
box-sizing: border-box;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
max-width: 900px;
margin-left: auto;
margin-right: auto;
background-color: var(--bg-primary, #ffffff);
color: var(--text-primary, #333333);
}}
/* Markdown content styles */
.markdown-preview {{
line-height: 1.6;
}}
.markdown-preview h1,
.markdown-preview h2,
.markdown-preview h3,
.markdown-preview h4,
.markdown-preview h5,
.markdown-preview h6 {{
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
line-height: 1.25;
}}
.markdown-preview h1 {{ font-size: 2em; border-bottom: 1px solid var(--border-color, #e1e4e8); padding-bottom: 0.3em; }}
.markdown-preview h2 {{ font-size: 1.5em; border-bottom: 1px solid var(--border-color, #e1e4e8); padding-bottom: 0.3em; }}
.markdown-preview h3 {{ font-size: 1.25em; }}
.markdown-preview h4 {{ font-size: 1em; }}
.markdown-preview p {{
margin: 1em 0;
}}
.markdown-preview a {{
color: var(--accent-primary, #0366d6);
text-decoration: none;
}}
.markdown-preview a:hover {{
text-decoration: underline;
}}
.markdown-preview img {{
max-width: 100%;
height: auto;
border-radius: 4px;
}}
/* Inline code */
.markdown-preview code:not(pre code) {{
background-color: var(--bg-tertiary, #f6f8fa);
color: var(--accent-primary, #0366d6);
padding: 0.2rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-weight: 500;
}}
/* Code blocks */
.markdown-preview pre {{
background-color: var(--bg-tertiary, #f6f8fa);
margin-bottom: 1.5rem;
border-radius: 0.5rem;
overflow-x: auto;
border: 1px solid var(--border-primary, #e1e4e8);
}}
.markdown-preview pre code {{
background: transparent;
padding: 1rem;
display: block;
font-size: 0.875rem;
line-height: 1.6;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: inherit;
}}
.markdown-preview blockquote {{
margin: 1em 0;
padding: 0 1em;
border-left: 4px solid var(--accent-primary, #0366d6);
color: var(--text-secondary, #6a737d);
}}
.markdown-preview ul,
.markdown-preview ol {{
padding-left: 2em;
margin: 1em 0;
}}
.markdown-preview li {{
margin: 0.25em 0;
}}
.markdown-preview table {{
border-collapse: collapse;
width: 100%;
margin: 1em 0;
}}
.markdown-preview th,
.markdown-preview td {{
border: 1px solid var(--border-color, #e1e4e8);
padding: 0.5em 1em;
text-align: left;
}}
.markdown-preview th {{
background-color: var(--bg-secondary, #f6f8fa);
font-weight: 600;
}}
.markdown-preview hr {{
border: none;
border-top: 1px solid var(--border-color, #e1e4e8);
margin: 2em 0;
}}
/* Task list styling */
.markdown-preview input[type="checkbox"] {{
margin-right: 0.5em;
}}
/* Enhanced Shell/Bash Syntax Highlighting */
.markdown-preview pre code.language-shell .hljs-meta,
.markdown-preview pre code.language-bash .hljs-meta,
.markdown-preview pre code.language-sh .hljs-meta {{
color: #7c3aed !important;
font-weight: 600;
}}
.markdown-preview pre code.language-shell .hljs-built_in,
.markdown-preview pre code.language-bash .hljs-built_in,
.markdown-preview pre code.language-sh .hljs-built_in {{
color: #10b981 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-shell .hljs-string,
.markdown-preview pre code.language-bash .hljs-string,
.markdown-preview pre code.language-sh .hljs-string {{
color: #f59e0b !important;
}}
.markdown-preview pre code.language-shell .hljs-variable,
.markdown-preview pre code.language-bash .hljs-variable,
.markdown-preview pre code.language-sh .hljs-variable {{
color: #06b6d4 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-shell .hljs-comment,
.markdown-preview pre code.language-bash .hljs-comment,
.markdown-preview pre code.language-sh .hljs-comment {{
color: #6b7280 !important;
font-style: italic;
}}
.markdown-preview pre code.language-shell .hljs-keyword,
.markdown-preview pre code.language-bash .hljs-keyword,
.markdown-preview pre code.language-sh .hljs-keyword {{
color: #ec4899 !important;
font-weight: 600;
}}
/* Enhanced PowerShell Syntax Highlighting */
.markdown-preview pre code.language-powershell .hljs-built_in,
.markdown-preview pre code.language-ps1 .hljs-built_in {{
color: #10b981 !important;
font-weight: 600;
}}
.markdown-preview pre code.language-powershell .hljs-variable,
.markdown-preview pre code.language-ps1 .hljs-variable {{
color: #06b6d4 !important;
font-weight: 500;
}}
.markdown-preview pre code.language-powershell .hljs-string,
.markdown-preview pre code.language-ps1 .hljs-string {{
color: #f59e0b !important;
}}
.markdown-preview pre code.language-powershell .hljs-keyword,
.markdown-preview pre code.language-ps1 .hljs-keyword {{
color: #ec4899 !important;
font-weight: 600;
}}
.markdown-preview pre code.language-powershell .hljs-comment,
.markdown-preview pre code.language-ps1 .hljs-comment {{
color: #6b7280 !important;
font-style: italic;
}}
/* Copy button for code blocks */
.markdown-preview pre {{
position: relative;
}}
.copy-btn {{
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background-color: var(--bg-secondary, #e1e4e8);
color: var(--text-secondary, #586069);
border: 1px solid var(--border-primary, #d0d7de);
border-radius: 0.25rem;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}}
.markdown-preview pre:hover .copy-btn {{
opacity: 1;
}}
.copy-btn:hover {{
background-color: var(--accent-primary, #0366d6);
color: white;
border-color: var(--accent-primary, #0366d6);
}}
.copy-btn.copied {{
background-color: #10b981;
color: white;
border-color: #10b981;
}}
@media (max-width: 768px) {{
body {{
padding: 1rem;
}}
}}
@media print {{
body {{
padding: 0.5in;
max-width: none;
}}
}}
</style>
</head>
<body>
<div class="markdown-preview" id="content"></div>
<script>
// Configure marked
marked.setOptions({{
gfm: true,
breaks: true,
headerIds: true,
mangle: false
}});
// Raw markdown content
const markdown = `{escaped_content}`;
// Render markdown
document.getElementById('content').innerHTML = marked.parse(markdown);
// Add copy buttons to code blocks
document.querySelectorAll('.markdown-preview pre').forEach(pre => {{
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = 'Copy';
btn.addEventListener('click', async () => {{
const code = pre.querySelector('code');
if (code) {{
try {{
await navigator.clipboard.writeText(code.textContent);
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => {{
btn.textContent = 'Copy';
btn.classList.remove('copied');
}}, 2000);
}} catch (err) {{
btn.textContent = 'Failed';
setTimeout(() => btn.textContent = 'Copy', 2000);
}}
}}
}});
pre.appendChild(btn);
}});
</script>
</body>
</html>'''
return html

View File

@ -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"
@ -101,12 +112,28 @@ if 'AUTHENTICATION_SECRET_KEY' in os.environ:
config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
print("🔐 Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
# OpenAPI tag metadata for grouping endpoints in Swagger UI
tags_metadata = [
{"name": "Notes", "description": "Create, read, update, delete notes"},
{"name": "Folders", "description": "Folder management"},
{"name": "Images", "description": "Image viewing and uploads"},
{"name": "Search", "description": "Full-text search"},
{"name": "Sharing", "description": "Public note sharing via tokens"},
{"name": "Themes", "description": "UI theme management"},
{"name": "Templates", "description": "Note templates"},
{"name": "Tags", "description": "Tag-based organization"},
{"name": "Graph", "description": "Note relationship graph"},
{"name": "Plugins", "description": "Plugin management"},
{"name": "System", "description": "Health checks and configuration"},
]
# Initialize app
app = FastAPI(
title=config['app']['name'],
version=config['app']['version'],
docs_url=None, # Disable Swagger UI at /docs
redoc_url=None # Disable ReDoc at /redoc
docs_url='/api', # Default is /docs
redoc_url=None, # Disable ReDoc at /redoc
openapi_tags=tags_metadata
)
# CORS middleware configuration
@ -346,171 +373,7 @@ pages_router = APIRouter(
# Application Routes (with auth via router dependencies)
# ============================================================================
@api_router.get("")
async def api_documentation():
"""API Documentation - List all available endpoints"""
return {
"app": {
"name": config['app']['name'],
"version": config['app']['version']
},
"endpoints": [
{
"method": "GET",
"path": "/api",
"description": "API documentation - lists all available endpoints",
"response": "API documentation object"
},
{
"method": "GET",
"path": "/api/config",
"description": "Get application configuration",
"response": "{ name, version, searchEnabled }"
},
{
"method": "GET",
"path": "/api/themes",
"description": "List all available themes",
"response": "{ themes: [{ id, name, builtin }] }"
},
{
"method": "GET",
"path": "/api/themes/{theme_id}",
"description": "Get CSS content for a specific theme",
"parameters": {"theme_id": "Theme identifier (e.g., 'dark', 'light', 'dracula')"},
"response": "{ css, theme_id }"
},
{
"method": "GET",
"path": "/api/notes",
"description": "List all notes and folders",
"response": "{ notes: [{ path, name, folder }], folders: [path] }"
},
{
"method": "GET",
"path": "/api/notes/{note_path}",
"description": "Get content of a specific note",
"parameters": {"note_path": "Path to note (e.g., 'test.md', 'folder/note.md')"},
"response": "{ content }"
},
{
"method": "POST",
"path": "/api/notes/{note_path}",
"description": "Create or update a note",
"parameters": {"note_path": "Path to note"},
"body": {"content": "Markdown content of the note"},
"response": "{ success, message }"
},
{
"method": "DELETE",
"path": "/api/notes/{note_path}",
"description": "Delete a note",
"parameters": {"note_path": "Path to note"},
"response": "{ success, message }"
},
{
"method": "POST",
"path": "/api/notes/move",
"description": "Move a note to a different location",
"body": {"oldPath": "Current note path", "newPath": "New note path"},
"response": "{ success, oldPath, newPath }"
},
{
"method": "POST",
"path": "/api/folders",
"description": "Create a new folder",
"body": {"path": "Folder path (e.g., 'Projects', 'Work/2025')"},
"response": "{ success, path }"
},
{
"method": "POST",
"path": "/api/folders/move",
"description": "Move a folder to a different location",
"body": {"oldPath": "Current folder path", "newPath": "New folder path"},
"response": "{ success, oldPath, newPath }"
},
{
"method": "POST",
"path": "/api/folders/rename",
"description": "Rename a folder",
"body": {"oldPath": "Current folder path", "newPath": "New folder path"},
"response": "{ success, oldPath, newPath }"
},
{
"method": "GET",
"path": "/api/tags",
"description": "Get all tags used across all notes with their counts",
"response": "{ tags: { tag_name: count, ... } }"
},
{
"method": "GET",
"path": "/api/tags/{tag_name}",
"description": "Get all notes that have a specific tag",
"parameters": {"tag_name": "Tag to filter by (case-insensitive)"},
"response": "{ tag, count, notes: [{ path, name, folder, tags }] }"
},
{
"method": "GET",
"path": "/api/search",
"description": "Search notes by content",
"parameters": {"q": "Search query string"},
"response": "{ results: [{ path, name, folder, snippet }], query }"
},
{
"method": "GET",
"path": "/api/graph",
"description": "Get graph data for note visualization",
"response": "{ nodes: [{ id, label }], edges: [] }"
},
{
"method": "GET",
"path": "/api/plugins",
"description": "List all loaded plugins",
"response": "{ plugins: [{ id, name, version, enabled }] }"
},
{
"method": "POST",
"path": "/api/plugins/{plugin_name}/toggle",
"description": "Enable or disable a plugin",
"parameters": {"plugin_name": "Plugin identifier"},
"body": {"enabled": "true/false"},
"response": "{ success, plugin, enabled }"
},
{
"method": "GET",
"path": "/health",
"description": "Health check endpoint",
"response": "{ status: 'healthy', app, version }"
}
],
"notes": {
"authentication": "Not required (add authentication in config.yaml if needed)",
"base_url": "http://localhost:8000",
"content_type": "application/json",
"cors": "Enabled for all origins"
},
"examples": {
"create_note": {
"curl": "curl -X POST http://localhost:8000/api/notes/test.md -H 'Content-Type: application/json' -d '{\"content\": \"# Hello World\"}'",
"description": "Create a new note named test.md"
},
"search_notes": {
"curl": "curl http://localhost:8000/api/search?q=hello",
"description": "Search for notes containing 'hello'"
},
"list_themes": {
"curl": "curl http://localhost:8000/api/themes",
"description": "Get all available themes"
},
"enable_plugin": {
"curl": "curl -X POST http://localhost:8000/api/plugins/git_backup/toggle -H 'Content-Type: application/json' -d '{\"enabled\": true}'",
"description": "Enable the git_backup plugin"
}
}
}
@api_router.get("/config")
@api_router.get("/config", tags=["System"])
async def get_config():
"""Get app configuration for frontend"""
return {
@ -524,7 +387,7 @@ async def get_config():
}
@api_router.get("/themes")
@api_router.get("/themes", tags=["Themes"])
async def list_themes():
"""Get all available themes"""
themes_dir = Path(__file__).parent.parent / "themes"
@ -588,7 +451,7 @@ async def get_locale(locale_code: str):
raise HTTPException(status_code=500, detail=f"Failed to load locale: {str(e)}")
@api_router.post("/folders")
@api_router.post("/folders", tags=["Folders"])
@limiter.limit("30/minute")
async def create_new_folder(request: Request, data: dict):
"""Create a new folder"""
@ -613,7 +476,7 @@ async def create_new_folder(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
@api_router.get("/images/{image_path:path}")
@api_router.get("/images/{image_path:path}", tags=["Images"])
async def get_image(image_path: str):
"""
Serve an image file with authentication protection.
@ -643,7 +506,7 @@ async def get_image(image_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image"))
@api_router.post("/upload-image")
@api_router.post("/upload-image", tags=["Images"])
@limiter.limit("20/minute")
async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
"""
@ -699,7 +562,7 @@ async def upload_image(request: Request, file: UploadFile = File(...), note_path
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image"))
@api_router.post("/notes/move")
@api_router.post("/notes/move", tags=["Notes"])
@limiter.limit("30/minute")
async def move_note_endpoint(request: Request, data: dict):
"""Move a note to a different folder"""
@ -715,6 +578,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='')
@ -730,7 +596,7 @@ async def move_note_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move note"))
@api_router.post("/folders/move")
@api_router.post("/folders/move", tags=["Folders"])
@limiter.limit("20/minute")
async def move_folder_endpoint(request: Request, data: dict):
"""Move a folder to a different location"""
@ -758,7 +624,7 @@ async def move_folder_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move folder"))
@api_router.post("/folders/rename")
@api_router.post("/folders/rename", tags=["Folders"])
@limiter.limit("30/minute")
async def rename_folder_endpoint(request: Request, data: dict):
"""Rename a folder"""
@ -786,7 +652,7 @@ async def rename_folder_endpoint(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to rename folder"))
@api_router.delete("/folders/{folder_path:path}")
@api_router.delete("/folders/{folder_path:path}", tags=["Folders"])
@limiter.limit("20/minute")
async def delete_folder_endpoint(request: Request, folder_path: str):
"""Delete a folder and all its contents"""
@ -812,7 +678,7 @@ async def delete_folder_endpoint(request: Request, folder_path: str):
# --- Tags Endpoints ---
@api_router.get("/tags")
@api_router.get("/tags", tags=["Tags"])
async def list_tags():
"""
Get all tags used across all notes with their counts.
@ -827,7 +693,7 @@ async def list_tags():
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load tags"))
@api_router.get("/tags/{tag_name}")
@api_router.get("/tags/{tag_name}", tags=["Tags"])
async def get_notes_by_tag_endpoint(tag_name: str):
"""
Get all notes that have a specific tag.
@ -851,7 +717,7 @@ async def get_notes_by_tag_endpoint(tag_name: str):
# --- Template Endpoints ---
@api_router.get("/templates")
@api_router.get("/templates", tags=["Templates"])
@limiter.limit("120/minute")
async def list_templates(request: Request):
"""
@ -867,7 +733,7 @@ async def list_templates(request: Request):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list templates"))
@api_router.get("/templates/{template_name}")
@api_router.get("/templates/{template_name}", tags=["Templates"])
@limiter.limit("120/minute")
async def get_template(request: Request, template_name: str):
"""
@ -895,7 +761,7 @@ async def get_template(request: Request, template_name: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get template"))
@api_router.post("/templates/create-note")
@api_router.post("/templates/create-note", tags=["Templates"])
@limiter.limit("60/minute")
async def create_note_from_template(request: Request, data: dict):
"""
@ -955,7 +821,7 @@ async def create_note_from_template(request: Request, data: dict):
# --- Notes Endpoints ---
@api_router.get("/notes")
@api_router.get("/notes", tags=["Notes"])
async def list_notes():
"""List all notes with metadata"""
try:
@ -966,7 +832,7 @@ async def list_notes():
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to list notes"))
@api_router.get("/notes/{note_path:path}")
@api_router.get("/notes/{note_path:path}", tags=["Notes"])
async def get_note(note_path: str):
"""Get a specific note's content"""
try:
@ -990,7 +856,7 @@ async def get_note(note_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load note"))
@api_router.post("/notes/{note_path:path}")
@api_router.post("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("60/minute")
async def create_or_update_note(request: Request, note_path: str, content: dict):
"""Create or update a note"""
@ -1031,7 +897,7 @@ async def create_or_update_note(request: Request, note_path: str, content: dict)
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save note"))
@api_router.delete("/notes/{note_path:path}")
@api_router.delete("/notes/{note_path:path}", tags=["Notes"])
@limiter.limit("30/minute")
async def remove_note(request: Request, note_path: str):
"""Delete a note"""
@ -1041,6 +907,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)
@ -1054,7 +923,7 @@ async def remove_note(request: Request, note_path: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to delete note"))
@api_router.get("/search")
@api_router.get("/search", tags=["Search"])
async def search(q: str):
"""Search notes by content"""
try:
@ -1073,7 +942,7 @@ async def search(q: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed"))
@api_router.get("/graph")
@api_router.get("/graph", tags=["Graph"])
async def get_graph():
"""Get graph data for note visualization with wikilink and markdown link detection"""
try:
@ -1216,13 +1085,13 @@ async def get_graph():
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
@api_router.get("/plugins")
@api_router.get("/plugins", tags=["Plugins"])
async def list_plugins():
"""List all available plugins"""
return {"plugins": plugin_manager.list_plugins()}
@api_router.get("/plugins/note_stats/calculate")
@api_router.get("/plugins/note_stats/calculate", tags=["Plugins"])
async def calculate_note_stats(content: str):
"""Calculate statistics for note content (if plugin enabled)"""
try:
@ -1236,7 +1105,7 @@ async def calculate_note_stats(content: str):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to calculate note statistics"))
@api_router.post("/plugins/{plugin_name}/toggle")
@api_router.post("/plugins/{plugin_name}/toggle", tags=["Plugins"])
@limiter.limit("10/minute")
async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
"""Enable or disable a plugin"""
@ -1256,7 +1125,198 @@ 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"))
@app.get("/health")
# ============================================================================
# Share Token Endpoints (authenticated)
# ============================================================================
@api_router.post("/share/{note_path:path}", tags=["Sharing"])
@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}", tags=["Sharing"])
@limiter.limit("120/minute")
async def get_share_status(request: Request, note_path: str):
"""
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", tags=["Sharing"])
@limiter.limit("60/minute")
async def list_shared_notes(request: Request):
"""
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}", tags=["Sharing"])
@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, tags=["Sharing"])
@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", tags=["System"])
async def health_check():
"""Health check endpoint"""
return {
@ -1305,4 +1365,3 @@ if __name__ == "__main__":
port=config['server']['port'],
reload=config['server']['reload']
)

238
backend/share.py Normal file
View File

@ -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)

View File

@ -980,6 +980,14 @@
</div>
</div>
<div class="benefit-item">
<span class="emoji">🌐</span>
<div class="text">
<strong>Public Sharing</strong>
Share notes via link, even with auth enabled
</div>
</div>
<div class="benefit-item">
<span class="emoji">🔗</span>
<div class="text">

View File

@ -305,11 +305,11 @@ GET /health
```
Returns system health status.
### API Info
### Swagger UI (Interactive Docs)
```http
GET /api
```
Self-documenting endpoint listing all available API routes.
Interactive API documentation with try-it-out functionality (Swagger UI).
---
@ -430,6 +430,75 @@ Creates a new note from a template with placeholder replacement.
---
## 🔗 Sharing
Share notes publicly without requiring authentication.
### Create Share Link
```http
POST /api/share/{note_path}
Content-Type: application/json
{
"theme": "dracula"
}
```
Creates a share token for the note. The `theme` is optional (defaults to "light").
**Response:**
```json
{
"success": true,
"token": "LRFEo86oSVeJ3Gju",
"url": "http://localhost:8000/share/LRFEo86oSVeJ3Gju",
"note_path": "folder/note.md"
}
```
### Get Share Status
```http
GET /api/share/{note_path}
```
Check if a note is currently shared.
**Response:**
```json
{
"shared": true,
"token": "LRFEo86oSVeJ3Gju",
"url": "http://localhost:8000/share/LRFEo86oSVeJ3Gju",
"theme": "dracula",
"created": "2026-01-15T10:30:00+00:00"
}
```
### Revoke Share
```http
DELETE /api/share/{note_path}
```
Removes public access to the note.
### List Shared Notes
```http
GET /api/shared-notes
```
Returns paths of all currently shared notes.
**Response:**
```json
{
"paths": ["folder/note.md", "another.md"]
}
```
### View Shared Note (Public)
```http
GET /share/{token}
```
Public endpoint - no authentication required. Returns the note as a standalone HTML page with the theme set when sharing was created.
---
## 📝 Response Format
All endpoints return JSON responses:
@ -450,5 +519,5 @@ All endpoints return JSON responses:
```
---
💡 **Tip:** Use the `/api` endpoint to get a live, self-documented list of all available endpoints!
💡 **Tip:** Visit `/api` for interactive Swagger UI documentation where you can try endpoints directly in your browser!

View File

@ -12,7 +12,8 @@
- **Copy code blocks** - One-click copy button on hover
- **LaTeX/Math rendering** - Beautiful mathematical equations with MathJax (see [MATHJAX.md](MATHJAX.md))
- **Mermaid diagrams** - Create flowcharts, sequence diagrams, and more (see [MERMAID.md](MERMAID.md))
- **HTML Export** - Export notes as standalone HTML files
- **HTML Export** - Export notes as standalone HTML files with embedded images
- **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md))
### Image Support
- **Drag & drop upload** - Drop images from your file system directly into the editor

60
documentation/SHARING.md Normal file
View File

@ -0,0 +1,60 @@
# 🔗 Public Sharing
Share notes publicly without requiring viewers to log in.
## How It Works
1. Open a note you want to share
2. Click the **Share** button in the toolbar
3. Click **Create Share Link**
4. Copy the generated URL and send it to anyone
The recipient can view the note in their browser - no account needed.
## Revoking Access
To stop sharing a note:
1. Open the note
2. Click the **Share** button
3. Click **Revoke Link**
The old URL will immediately stop working.
## Features
- **Theme preserved** - Shared notes display with the theme active when you created the link
- **Images embedded** - All images are included in the shared view
- **Code highlighting** - Syntax highlighting works in shared notes
- **Copy button** - Code blocks have a copy-to-clipboard button
- **MathJax & Mermaid** - Math equations and diagrams render correctly
- **No expiration** - Links work until you revoke them
## Visual Indicators
- A **share icon** appears next to shared notes in the sidebar
- The Share modal shows the current sharing status
## Technical Details
### Token Storage
Share tokens are stored in `.share-tokens.json` in your data folder:
```json
{
"LRFEo86oSVeJ3Gju": {
"path": "folder/note.md",
"theme": "dracula",
"created": "2026-01-15T10:30:00+00:00"
}
}
```
Each note can have one share token. Creating a new link for an already-shared note returns the existing token.
### Security
- Tokens are random 16-character strings
- Only the exact token URL grants access
- Revoking deletes the token permanently
- Shared notes are read-only (viewers cannot edit)

View File

@ -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 ? '<svg title="Shared" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : '';
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'"
>
<span class="truncate" style="display: block; padding-right: 30px;">${icon}${icon ? ' ' : ''}${note.name}</span>
<span class="truncate" style="display: block; padding-right: 30px;">${shareIcon}${icon}${icon ? ' ' : ''}${note.name}</span>
<button
onclick="${deleteHandler}"
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
@ -2197,6 +2208,7 @@ function noteApp() {
this.currentNoteName = '';
this.noteContent = '';
this.currentImage = imagePath;
this.shareInfo = null; // Reset share info
this.viewMode = 'preview'; // Use preview mode to show image
// Update browser tab title for image
@ -2531,6 +2543,7 @@ function noteApp() {
this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentImage = ''; // Clear image viewer when loading a note
this.shareInfo = null; // Reset share info for new note
// Update browser tab title
document.title = `${this.currentNoteName} - ${this.appName}`;
@ -5004,6 +5017,140 @@ function noteApp() {
}, 1500);
},
// ============================================================================
// Share Functions
// ============================================================================
// Load list of shared note paths (for visual indicators)
async loadSharedNotePaths() {
try {
const response = await fetch('/api/shared-notes');
if (response.ok) {
const data = await response.json();
this._sharedNotePaths = new Set(data.paths || []);
}
} catch (error) {
console.error('Failed to load shared note paths:', error);
this._sharedNotePaths = new Set();
}
},
// Check if a note is currently shared (O(1) lookup)
isNoteShared(notePath) {
return this._sharedNotePaths.has(notePath);
},
// Open share modal and fetch current share status
async openShareModal() {
if (!this.currentNote) return;
this.showShareModal = true;
this.shareLoading = true;
this.shareInfo = null;
try {
const notePath = this.currentNote.replace('.md', '');
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const response = await fetch(`/api/share/${encodedPath}`);
if (response.ok) {
this.shareInfo = await response.json();
} else {
this.shareInfo = { shared: false };
}
} catch (error) {
console.error('Failed to get share status:', error);
this.shareInfo = { shared: false };
} finally {
this.shareLoading = false;
}
},
// Create a share link for the current note (with current theme)
async createShareLink() {
if (!this.currentNote) return;
this.shareLoading = true;
try {
const notePath = this.currentNote.replace('.md', '');
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const response = await fetch(`/api/share/${encodedPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme: this.currentTheme || 'light' })
});
if (response.ok) {
this.shareInfo = await response.json();
this.shareInfo.shared = true;
// Update the shared paths set
this._sharedNotePaths.add(this.currentNote);
} else {
const error = await response.json();
alert(this.t('share.error_creating', { error: error.detail || 'Unknown error' }));
}
} catch (error) {
console.error('Failed to create share link:', error);
alert(this.t('share.error_creating', { error: error.message }));
} finally {
this.shareLoading = false;
}
},
// Copy share link to clipboard
async copyShareLink() {
if (!this.shareInfo?.url) return;
try {
await navigator.clipboard.writeText(this.shareInfo.url);
} catch (error) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = this.shareInfo.url;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
this.shareLinkCopied = true;
setTimeout(() => {
this.shareLinkCopied = false;
}, 2000);
},
// Revoke share link
async revokeShareLink() {
if (!this.currentNote) return;
if (!confirm(this.t('share.confirm_revoke'))) return;
this.shareLoading = true;
try {
const notePath = this.currentNote.replace('.md', '');
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
const response = await fetch(`/api/share/${encodedPath}`, {
method: 'DELETE'
});
if (response.ok) {
this.shareInfo = { shared: false };
// Update the shared paths set
this._sharedNotePaths.delete(this.currentNote);
} else {
const error = await response.json();
alert(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }));
}
} catch (error) {
console.error('Failed to revoke share link:', error);
alert(this.t('share.error_revoking', { error: error.message }));
} finally {
this.shareLoading = false;
}
},
// Toggle Zen Mode (full immersive writing experience)
async toggleZenMode() {
if (!this.zenMode) {

View File

@ -2242,6 +2242,21 @@
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<!-- Share button -->
<button
@click="openShareModal()"
class="p-2 transition hover:bg-opacity-80"
:title="t('share.button_tooltip')"
style="color: var(--text-secondary);"
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
@mouseleave="$el.style.backgroundColor = 'transparent'"
>
<!-- Share/Globe icon -->
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path>
</svg>
</button>
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
<button
@click="toggleZenMode()"
class="p-2 transition"
@ -2769,6 +2784,101 @@
</div>
</div>
<!-- Share Modal -->
<div x-show="showShareModal"
@click.self="showShareModal = false"
@keydown.escape.window="showShareModal && (showShareModal = false)"
class="fixed inset-0 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.5); z-index: 1100;"
x-cloak>
<div class="rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
@click.stop>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2" style="color: var(--text-primary);">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path>
</svg>
<span x-text="t('share.modal_title')"></span>
</h2>
<!-- Loading state -->
<div x-show="shareLoading" class="text-center py-4">
<span x-text="t('common.loading')"></span>
</div>
<!-- Not shared state -->
<div x-show="!shareLoading && !shareInfo?.shared">
<p class="mb-4 text-sm" style="color: var(--text-secondary);" x-text="t('share.not_shared_description')"></p>
<button
@click="createShareLink()"
class="w-full px-4 py-2 rounded font-medium text-white transition-colors"
style="background-color: var(--accent-primary);"
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
x-text="t('share.create_link')"
>
</button>
</div>
<!-- Shared state -->
<div x-show="!shareLoading && shareInfo?.shared">
<p class="mb-2 text-sm" style="color: var(--text-secondary);" x-text="t('share.shared_description')"></p>
<!-- Share URL -->
<div class="flex gap-2 mb-4">
<input
type="text"
:value="shareInfo?.url || ''"
readonly
class="flex-1 px-3 py-2 rounded border text-sm"
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
@click="$el.select()"
/>
<button
@click="copyShareLink()"
class="px-3 py-2 rounded font-medium text-white transition-colors flex items-center gap-1"
:style="shareLinkCopied ? 'background-color: var(--success, #22c55e);' : 'background-color: var(--accent-primary);'"
onmouseover="if(!this.classList.contains('copied')) this.style.backgroundColor='var(--accent-hover)'"
onmouseout="if(!this.classList.contains('copied')) this.style.backgroundColor='var(--accent-primary)'"
>
<svg x-show="!shareLinkCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
</svg>
<svg x-show="shareLinkCopied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span x-text="shareLinkCopied ? t('common.copied') : t('share.copy')"></span>
</button>
</div>
<!-- Revoke sharing -->
<button
@click="revokeShareLink()"
class="w-full px-4 py-2 rounded font-medium transition-colors"
style="background-color: transparent; color: var(--error); border: 1px solid var(--error);"
onmouseover="this.style.backgroundColor='rgba(239, 68, 68, 0.1)'"
onmouseout="this.style.backgroundColor='transparent'"
x-text="t('share.stop_sharing')"
>
</button>
</div>
<!-- Close button -->
<div class="mt-4 pt-4 border-t" style="border-color: var(--border-primary);">
<button
@click="showShareModal = false"
class="w-full px-4 py-2 rounded font-medium transition-colors"
style="background-color: var(--bg-tertiary); color: var(--text-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-tertiary)'"
x-text="t('common.close')"
>
</button>
</div>
</div>
</div>
<!-- Mobile Bottom Tabs -->
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
<button

View File

@ -116,6 +116,19 @@
"exit_hint": "Zen-Modus beenden (Esc)"
},
"share": {
"button_tooltip": "Notiz teilen",
"modal_title": "Notiz teilen",
"not_shared_description": "Diese Notiz ist derzeit nicht geteilt. Erstellen Sie einen Link, um sie mit anderen zu teilen.",
"shared_description": "Jeder mit diesem Link kann diese Notiz sehen:",
"create_link": "Freigabelink erstellen",
"copy": "Kopieren",
"stop_sharing": "Freigabe beenden",
"confirm_revoke": "Sind Sie sicher, dass Sie die Freigabe dieser Notiz beenden möchten? Der Freigabelink funktioniert dann nicht mehr.",
"error_creating": "Fehler beim Erstellen des Freigabelinks: {{error}}",
"error_revoking": "Fehler beim Widerrufen des Freigabelinks: {{error}}"
},
"tags": {
"title": "Tags",
"no_tags": "Keine Tags gefunden",

View File

@ -116,6 +116,19 @@
"exit_hint": "Exit Zen Mode (Esc)"
},
"share": {
"button_tooltip": "Share note",
"modal_title": "Share Note",
"not_shared_description": "This note is not currently shared. Create a link to share it with anyone.",
"shared_description": "Anyone with this link can view this note:",
"create_link": "Create Share Link",
"copy": "Copy",
"stop_sharing": "Stop Sharing",
"confirm_revoke": "Are you sure you want to stop sharing this note? The share link will no longer work.",
"error_creating": "Failed to create share link: {{error}}",
"error_revoking": "Failed to revoke share link: {{error}}"
},
"tags": {
"title": "Tags",
"no_tags": "No tags found",

View File

@ -116,6 +116,19 @@
"exit_hint": "Exit Zen Mode (Esc)"
},
"share": {
"button_tooltip": "Share note",
"modal_title": "Share Note",
"not_shared_description": "This note is not currently shared. Create a link to share it with anyone.",
"shared_description": "Anyone with this link can view this note:",
"create_link": "Create Share Link",
"copy": "Copy",
"stop_sharing": "Stop Sharing",
"confirm_revoke": "Are you sure you want to stop sharing this note? The share link will no longer work.",
"error_creating": "Failed to create share link: {{error}}",
"error_revoking": "Failed to revoke share link: {{error}}"
},
"tags": {
"title": "Tags",
"no_tags": "No tags found",

View File

@ -116,6 +116,19 @@
"exit_hint": "Salir del Modo Zen (Esc)"
},
"share": {
"button_tooltip": "Compartir nota",
"modal_title": "Compartir Nota",
"not_shared_description": "Esta nota no está compartida actualmente. Crea un enlace para compartirla con cualquiera.",
"shared_description": "Cualquiera con este enlace puede ver esta nota:",
"create_link": "Crear enlace para compartir",
"copy": "Copiar",
"stop_sharing": "Dejar de compartir",
"confirm_revoke": "¿Estás seguro de que quieres dejar de compartir esta nota? El enlace dejará de funcionar.",
"error_creating": "Error al crear el enlace: {{error}}",
"error_revoking": "Error al revocar el enlace: {{error}}"
},
"tags": {
"title": "Etiquetas",
"no_tags": "No se encontraron etiquetas",

View File

@ -116,6 +116,19 @@
"exit_hint": "Quitter le Mode Zen (Échap)"
},
"share": {
"button_tooltip": "Partager la note",
"modal_title": "Partager la Note",
"not_shared_description": "Cette note n'est pas partagée actuellement. Créez un lien pour la partager avec n'importe qui.",
"shared_description": "Toute personne disposant de ce lien peut voir cette note :",
"create_link": "Créer un lien de partage",
"copy": "Copier",
"stop_sharing": "Arrêter le partage",
"confirm_revoke": "Êtes-vous sûr de vouloir arrêter le partage de cette note ? Le lien ne fonctionnera plus.",
"error_creating": "Échec de la création du lien : {{error}}",
"error_revoking": "Échec de la révocation du lien : {{error}}"
},
"tags": {
"title": "Étiquettes",
"no_tags": "Aucune étiquette trouvée",

View File

@ -116,6 +116,19 @@
"exit_hint": "Esci dalla Modalità Zen (Esc)"
},
"share": {
"button_tooltip": "Condividi nota",
"modal_title": "Condividi Nota",
"not_shared_description": "Questa nota non è attualmente condivisa. Crea un link per condividerla con chiunque.",
"shared_description": "Chiunque abbia questo link può vedere questa nota:",
"create_link": "Crea link di condivisione",
"copy": "Copia",
"stop_sharing": "Interrompi condivisione",
"confirm_revoke": "Sei sicuro di voler interrompere la condivisione di questa nota? Il link non funzionerà più.",
"error_creating": "Impossibile creare il link: {{error}}",
"error_revoking": "Impossibile revocare il link: {{error}}"
},
"tags": {
"title": "Tag",
"no_tags": "Nessun tag trovato",

View File

@ -116,6 +116,19 @@
"exit_hint": "集中モードを終了 (Esc)"
},
"share": {
"button_tooltip": "ノートを共有",
"modal_title": "ノートを共有",
"not_shared_description": "このノートは現在共有されていません。リンクを作成して誰とでも共有できます。",
"shared_description": "このリンクを持っている人は誰でもこのノートを閲覧できます:",
"create_link": "共有リンクを作成",
"copy": "コピー",
"stop_sharing": "共有を停止",
"confirm_revoke": "このノートの共有を停止してもよろしいですか?共有リンクは機能しなくなります。",
"error_creating": "共有リンクの作成に失敗しました: {{error}}",
"error_revoking": "共有リンクの取り消しに失敗しました: {{error}}"
},
"tags": {
"title": "タグ",
"no_tags": "タグが見つかりません",

View File

@ -116,6 +116,19 @@
"exit_hint": "Выйти из режима концентрации (Esc)"
},
"share": {
"button_tooltip": "Поделиться заметкой",
"modal_title": "Поделиться заметкой",
"not_shared_description": "Эта заметка сейчас не опубликована. Создайте ссылку, чтобы поделиться ею с кем угодно.",
"shared_description": "Любой, у кого есть эта ссылка, может просмотреть заметку:",
"create_link": "Создать ссылку",
"copy": "Копировать",
"stop_sharing": "Прекратить доступ",
"confirm_revoke": "Вы уверены, что хотите прекратить доступ к этой заметке? Ссылка перестанет работать.",
"error_creating": "Не удалось создать ссылку: {{error}}",
"error_revoking": "Не удалось отозвать ссылку: {{error}}"
},
"tags": {
"title": "Теги",
"no_tags": "Теги не найдены",

View File

@ -116,6 +116,19 @@
"exit_hint": "Izhod iz zen načina (Esc)"
},
"share": {
"button_tooltip": "Deli zapis",
"modal_title": "Deli zapis",
"not_shared_description": "Ta zapis trenutno ni deljen. Ustvarite povezavo za deljenje z drugimi.",
"shared_description": "Kdor ima to povezavo, si lahko ogleda zapis:",
"create_link": "Ustvari povezavo za deljenje",
"copy": "Kopiraj",
"stop_sharing": "Prenehaj deliti",
"confirm_revoke": "Ali ste prepričani, da želite prenehati deliti ta zapis? Povezava ne bo več delovala.",
"error_creating": "Napaka pri ustvarjanju povezave: {{error}}",
"error_revoking": "Napaka pri preklicu povezave: {{error}}"
},
"tags": {
"title": "Oznake",
"no_tags": "Ni najdenih oznak",

View File

@ -116,6 +116,19 @@
"exit_hint": "退出禅模式 (Esc)"
},
"share": {
"button_tooltip": "分享笔记",
"modal_title": "分享笔记",
"not_shared_description": "此笔记当前未分享。创建链接以与任何人分享。",
"shared_description": "任何拥有此链接的人都可以查看此笔记:",
"create_link": "创建分享链接",
"copy": "复制",
"stop_sharing": "停止分享",
"confirm_revoke": "您确定要停止分享此笔记吗?分享链接将不再有效。",
"error_creating": "创建分享链接失败:{{error}}",
"error_revoking": "撤销分享链接失败:{{error}}"
},
"tags": {
"title": "标签",
"no_tags": "未找到标签",