Merge pull request #148 from gamosoft/features/public-notes
Features/public notes
This commit is contained in:
commit
e6595ff6b5
|
|
@ -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: 
|
||||||
|
- 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'(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||||
|
|
||||||
|
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
|
||||||
441
backend/main.py
441
backend/main.py
|
|
@ -44,6 +44,17 @@ from .utils import (
|
||||||
)
|
)
|
||||||
from .plugins import PluginManager
|
from .plugins import PluginManager
|
||||||
from .themes import get_available_themes, get_theme_css
|
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
|
# Load configuration
|
||||||
config_path = Path(__file__).parent.parent / "config.yaml"
|
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')
|
config['authentication']['secret_key'] = os.getenv('AUTHENTICATION_SECRET_KEY')
|
||||||
print("🔐 Secret key loaded from AUTHENTICATION_SECRET_KEY env var")
|
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
|
# Initialize app
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=config['app']['name'],
|
title=config['app']['name'],
|
||||||
version=config['app']['version'],
|
version=config['app']['version'],
|
||||||
docs_url=None, # Disable Swagger UI at /docs
|
docs_url='/api', # Default is /docs
|
||||||
redoc_url=None # Disable ReDoc at /redoc
|
redoc_url=None, # Disable ReDoc at /redoc
|
||||||
|
openapi_tags=tags_metadata
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware configuration
|
# CORS middleware configuration
|
||||||
|
|
@ -346,171 +373,7 @@ pages_router = APIRouter(
|
||||||
# Application Routes (with auth via router dependencies)
|
# Application Routes (with auth via router dependencies)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
@api_router.get("")
|
@api_router.get("/config", tags=["System"])
|
||||||
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")
|
|
||||||
async def get_config():
|
async def get_config():
|
||||||
"""Get app configuration for frontend"""
|
"""Get app configuration for frontend"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -524,7 +387,7 @@ async def get_config():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@api_router.get("/themes")
|
@api_router.get("/themes", tags=["Themes"])
|
||||||
async def list_themes():
|
async def list_themes():
|
||||||
"""Get all available themes"""
|
"""Get all available themes"""
|
||||||
themes_dir = Path(__file__).parent.parent / "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)}")
|
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")
|
@limiter.limit("30/minute")
|
||||||
async def create_new_folder(request: Request, data: dict):
|
async def create_new_folder(request: Request, data: dict):
|
||||||
"""Create a new folder"""
|
"""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"))
|
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):
|
async def get_image(image_path: str):
|
||||||
"""
|
"""
|
||||||
Serve an image file with authentication protection.
|
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"))
|
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")
|
@limiter.limit("20/minute")
|
||||||
async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
|
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"))
|
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")
|
@limiter.limit("30/minute")
|
||||||
async def move_note_endpoint(request: Request, data: dict):
|
async def move_note_endpoint(request: Request, data: dict):
|
||||||
"""Move a note to a different folder"""
|
"""Move a note to a different folder"""
|
||||||
|
|
@ -715,6 +578,9 @@ async def move_note_endpoint(request: Request, data: dict):
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
|
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
|
# Run plugin hooks
|
||||||
plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
|
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"))
|
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")
|
@limiter.limit("20/minute")
|
||||||
async def move_folder_endpoint(request: Request, data: dict):
|
async def move_folder_endpoint(request: Request, data: dict):
|
||||||
"""Move a folder to a different location"""
|
"""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"))
|
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")
|
@limiter.limit("30/minute")
|
||||||
async def rename_folder_endpoint(request: Request, data: dict):
|
async def rename_folder_endpoint(request: Request, data: dict):
|
||||||
"""Rename a folder"""
|
"""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"))
|
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")
|
@limiter.limit("20/minute")
|
||||||
async def delete_folder_endpoint(request: Request, folder_path: str):
|
async def delete_folder_endpoint(request: Request, folder_path: str):
|
||||||
"""Delete a folder and all its contents"""
|
"""Delete a folder and all its contents"""
|
||||||
|
|
@ -812,7 +678,7 @@ async def delete_folder_endpoint(request: Request, folder_path: str):
|
||||||
|
|
||||||
# --- Tags Endpoints ---
|
# --- Tags Endpoints ---
|
||||||
|
|
||||||
@api_router.get("/tags")
|
@api_router.get("/tags", tags=["Tags"])
|
||||||
async def list_tags():
|
async def list_tags():
|
||||||
"""
|
"""
|
||||||
Get all tags used across all notes with their counts.
|
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"))
|
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):
|
async def get_notes_by_tag_endpoint(tag_name: str):
|
||||||
"""
|
"""
|
||||||
Get all notes that have a specific tag.
|
Get all notes that have a specific tag.
|
||||||
|
|
@ -851,7 +717,7 @@ async def get_notes_by_tag_endpoint(tag_name: str):
|
||||||
|
|
||||||
# --- Template Endpoints ---
|
# --- Template Endpoints ---
|
||||||
|
|
||||||
@api_router.get("/templates")
|
@api_router.get("/templates", tags=["Templates"])
|
||||||
@limiter.limit("120/minute")
|
@limiter.limit("120/minute")
|
||||||
async def list_templates(request: Request):
|
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"))
|
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")
|
@limiter.limit("120/minute")
|
||||||
async def get_template(request: Request, template_name: str):
|
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"))
|
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")
|
@limiter.limit("60/minute")
|
||||||
async def create_note_from_template(request: Request, data: dict):
|
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 ---
|
# --- Notes Endpoints ---
|
||||||
|
|
||||||
@api_router.get("/notes")
|
@api_router.get("/notes", tags=["Notes"])
|
||||||
async def list_notes():
|
async def list_notes():
|
||||||
"""List all notes with metadata"""
|
"""List all notes with metadata"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -966,7 +832,7 @@ async def list_notes():
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to 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):
|
async def get_note(note_path: str):
|
||||||
"""Get a specific note's content"""
|
"""Get a specific note's content"""
|
||||||
try:
|
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"))
|
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")
|
@limiter.limit("60/minute")
|
||||||
async def create_or_update_note(request: Request, note_path: str, content: dict):
|
async def create_or_update_note(request: Request, note_path: str, content: dict):
|
||||||
"""Create or update a note"""
|
"""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"))
|
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")
|
@limiter.limit("30/minute")
|
||||||
async def remove_note(request: Request, note_path: str):
|
async def remove_note(request: Request, note_path: str):
|
||||||
"""Delete a note"""
|
"""Delete a note"""
|
||||||
|
|
@ -1041,6 +907,9 @@ async def remove_note(request: Request, note_path: str):
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Note not found")
|
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
|
# Run plugin hooks
|
||||||
plugin_manager.run_hook('on_note_delete', note_path=note_path)
|
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"))
|
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):
|
async def search(q: str):
|
||||||
"""Search notes by content"""
|
"""Search notes by content"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1073,7 +942,7 @@ async def search(q: str):
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Search failed"))
|
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():
|
async def get_graph():
|
||||||
"""Get graph data for note visualization with wikilink and markdown link detection"""
|
"""Get graph data for note visualization with wikilink and markdown link detection"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1216,13 +1085,13 @@ async def get_graph():
|
||||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to generate graph data"))
|
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():
|
async def list_plugins():
|
||||||
"""List all available plugins"""
|
"""List all available plugins"""
|
||||||
return {"plugins": plugin_manager.list_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):
|
async def calculate_note_stats(content: str):
|
||||||
"""Calculate statistics for note content (if plugin enabled)"""
|
"""Calculate statistics for note content (if plugin enabled)"""
|
||||||
try:
|
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"))
|
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")
|
@limiter.limit("10/minute")
|
||||||
async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
|
async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
|
||||||
"""Enable or disable a plugin"""
|
"""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"))
|
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():
|
async def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return {
|
return {
|
||||||
|
|
@ -1305,4 +1365,3 @@ if __name__ == "__main__":
|
||||||
port=config['server']['port'],
|
port=config['server']['port'],
|
||||||
reload=config['server']['reload']
|
reload=config['server']['reload']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -980,6 +980,14 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="benefit-item">
|
||||||
<span class="emoji">🔗</span>
|
<span class="emoji">🔗</span>
|
||||||
<div class="text">
|
<div class="text">
|
||||||
|
|
|
||||||
|
|
@ -305,11 +305,11 @@ GET /health
|
||||||
```
|
```
|
||||||
Returns system health status.
|
Returns system health status.
|
||||||
|
|
||||||
### API Info
|
### Swagger UI (Interactive Docs)
|
||||||
```http
|
```http
|
||||||
GET /api
|
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
|
## 📝 Response Format
|
||||||
|
|
||||||
All endpoints return JSON responses:
|
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!
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@
|
||||||
- **Copy code blocks** - One-click copy button on hover
|
- **Copy code blocks** - One-click copy button on hover
|
||||||
- **LaTeX/Math rendering** - Beautiful mathematical equations with MathJax (see [MATHJAX.md](MATHJAX.md))
|
- **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))
|
- **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
|
### Image Support
|
||||||
- **Drag & drop upload** - Drop images from your file system directly into the editor
|
- **Drag & drop upload** - Drop images from your file system directly into the editor
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
151
frontend/app.js
151
frontend/app.js
|
|
@ -255,6 +255,13 @@ function noteApp() {
|
||||||
selectedTemplate: '',
|
selectedTemplate: '',
|
||||||
newTemplateNoteName: '',
|
newTemplateNoteName: '',
|
||||||
|
|
||||||
|
// Share state
|
||||||
|
showShareModal: false,
|
||||||
|
shareInfo: null,
|
||||||
|
shareLoading: false,
|
||||||
|
shareLinkCopied: false,
|
||||||
|
_sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
|
||||||
|
|
||||||
// Homepage state
|
// Homepage state
|
||||||
selectedHomepageFolder: '',
|
selectedHomepageFolder: '',
|
||||||
_homepageCache: {
|
_homepageCache: {
|
||||||
|
|
@ -442,6 +449,7 @@ function noteApp() {
|
||||||
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
|
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
|
||||||
// loadLocale() is only called when user changes language from settings
|
// loadLocale() is only called when user changes language from settings
|
||||||
await this.loadNotes();
|
await this.loadNotes();
|
||||||
|
await this.loadSharedNotePaths();
|
||||||
await this.loadTemplates();
|
await this.loadTemplates();
|
||||||
await this.checkStatsPlugin();
|
await this.checkStatsPlugin();
|
||||||
this.loadSidebarWidth();
|
this.loadSidebarWidth();
|
||||||
|
|
@ -483,6 +491,7 @@ function noteApp() {
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentNoteName = '';
|
this.currentNoteName = '';
|
||||||
this.outline = [];
|
this.outline = [];
|
||||||
|
this.shareInfo = null; // Reset share info
|
||||||
document.title = this.appName;
|
document.title = this.appName;
|
||||||
|
|
||||||
// Restore homepage folder state if it was saved
|
// Restore homepage folder state if it was saved
|
||||||
|
|
@ -1792,7 +1801,9 @@ function noteApp() {
|
||||||
const isCurrentImage = this.currentImage === note.path;
|
const isCurrentImage = this.currentImage === note.path;
|
||||||
const isCurrent = isImage ? isCurrentImage : isCurrentNote;
|
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 ? '🖼️' : '';
|
const icon = isImage ? '🖼️' : '';
|
||||||
|
|
||||||
// Escape paths for use in native event handlers
|
// 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)'"
|
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'"
|
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
|
<button
|
||||||
onclick="${deleteHandler}"
|
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"
|
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.currentNoteName = '';
|
||||||
this.noteContent = '';
|
this.noteContent = '';
|
||||||
this.currentImage = imagePath;
|
this.currentImage = imagePath;
|
||||||
|
this.shareInfo = null; // Reset share info
|
||||||
this.viewMode = 'preview'; // Use preview mode to show image
|
this.viewMode = 'preview'; // Use preview mode to show image
|
||||||
|
|
||||||
// Update browser tab title for image
|
// Update browser tab title for image
|
||||||
|
|
@ -2531,6 +2543,7 @@ function noteApp() {
|
||||||
this.noteContent = data.content;
|
this.noteContent = data.content;
|
||||||
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
|
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
|
||||||
this.currentImage = ''; // Clear image viewer when loading a note
|
this.currentImage = ''; // Clear image viewer when loading a note
|
||||||
|
this.shareInfo = null; // Reset share info for new note
|
||||||
|
|
||||||
// Update browser tab title
|
// Update browser tab title
|
||||||
document.title = `${this.currentNoteName} - ${this.appName}`;
|
document.title = `${this.currentNoteName} - ${this.appName}`;
|
||||||
|
|
@ -5004,6 +5017,140 @@ function noteApp() {
|
||||||
}, 1500);
|
}, 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)
|
// Toggle Zen Mode (full immersive writing experience)
|
||||||
async toggleZenMode() {
|
async toggleZenMode() {
|
||||||
if (!this.zenMode) {
|
if (!this.zenMode) {
|
||||||
|
|
|
||||||
|
|
@ -2242,6 +2242,21 @@
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
|
<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
|
<button
|
||||||
@click="toggleZenMode()"
|
@click="toggleZenMode()"
|
||||||
class="p-2 transition"
|
class="p-2 transition"
|
||||||
|
|
@ -2769,6 +2784,101 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Mobile Bottom Tabs -->
|
||||||
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
|
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Zen-Modus beenden (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Tags",
|
"title": "Tags",
|
||||||
"no_tags": "Keine Tags gefunden",
|
"no_tags": "Keine Tags gefunden",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Exit Zen Mode (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Tags",
|
"title": "Tags",
|
||||||
"no_tags": "No tags found",
|
"no_tags": "No tags found",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Exit Zen Mode (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Tags",
|
"title": "Tags",
|
||||||
"no_tags": "No tags found",
|
"no_tags": "No tags found",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Salir del Modo Zen (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Etiquetas",
|
"title": "Etiquetas",
|
||||||
"no_tags": "No se encontraron etiquetas",
|
"no_tags": "No se encontraron etiquetas",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Quitter le Mode Zen (Échap)"
|
"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": {
|
"tags": {
|
||||||
"title": "Étiquettes",
|
"title": "Étiquettes",
|
||||||
"no_tags": "Aucune étiquette trouvée",
|
"no_tags": "Aucune étiquette trouvée",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Esci dalla Modalità Zen (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Tag",
|
"title": "Tag",
|
||||||
"no_tags": "Nessun tag trovato",
|
"no_tags": "Nessun tag trovato",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "集中モードを終了 (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "タグ",
|
"title": "タグ",
|
||||||
"no_tags": "タグが見つかりません",
|
"no_tags": "タグが見つかりません",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Выйти из режима концентрации (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Теги",
|
"title": "Теги",
|
||||||
"no_tags": "Теги не найдены",
|
"no_tags": "Теги не найдены",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "Izhod iz zen načina (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "Oznake",
|
"title": "Oznake",
|
||||||
"no_tags": "Ni najdenih oznak",
|
"no_tags": "Ni najdenih oznak",
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,19 @@
|
||||||
"exit_hint": "退出禅模式 (Esc)"
|
"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": {
|
"tags": {
|
||||||
"title": "标签",
|
"title": "标签",
|
||||||
"no_tags": "未找到标签",
|
"no_tags": "未找到标签",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue