added share notes
This commit is contained in:
parent
b330e65b38
commit
50590a75b6
|
|
@ -0,0 +1,560 @@
|
|||
"""
|
||||
HTML Export Module for NoteDiscovery
|
||||
Generates standalone HTML files for notes with embedded images and styling.
|
||||
Used by both /api/export (download) and /public (sharing) endpoints.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import mimetypes
|
||||
|
||||
|
||||
def get_image_as_base64(image_path: Path) -> Optional[str]:
|
||||
"""Read an image file and return it as a base64 data URL."""
|
||||
if not image_path.exists() or not image_path.is_file():
|
||||
return None
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(str(image_path))
|
||||
if not mime_type or not mime_type.startswith('image/'):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(image_path, 'rb') as f:
|
||||
image_data = f.read()
|
||||
base64_data = base64.b64encode(image_data).decode('utf-8')
|
||||
return f"data:{mime_type};base64,{base64_data}"
|
||||
except Exception as e:
|
||||
print(f"Failed to read image {image_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def strip_frontmatter(content: str) -> str:
|
||||
"""
|
||||
Remove YAML frontmatter from markdown content.
|
||||
Frontmatter is delimited by --- at the start and end.
|
||||
"""
|
||||
if not content.strip().startswith('---'):
|
||||
return content
|
||||
|
||||
lines = content.split('\n')
|
||||
if lines[0].strip() != '---':
|
||||
return content
|
||||
|
||||
# Find closing ---
|
||||
end_idx = -1
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
end_idx = i
|
||||
break
|
||||
|
||||
if end_idx == -1:
|
||||
return content
|
||||
|
||||
# Remove frontmatter and return the rest
|
||||
return '\n'.join(lines[end_idx + 1:]).strip()
|
||||
|
||||
|
||||
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
|
||||
"""
|
||||
Search for an image file in common attachment locations.
|
||||
Returns the resolved path if found, None otherwise.
|
||||
"""
|
||||
# Common locations to search for images (fast path)
|
||||
search_paths = [
|
||||
note_folder / image_name, # Same folder as note
|
||||
note_folder / '_attachments' / image_name, # Note's _attachments folder
|
||||
notes_dir / '_attachments' / image_name, # Root _attachments folder
|
||||
]
|
||||
|
||||
# Also search in parent folders' _attachments (for nested notes)
|
||||
current = note_folder
|
||||
while current != notes_dir and current.parent != current:
|
||||
search_paths.append(current / '_attachments' / image_name)
|
||||
current = current.parent
|
||||
|
||||
for path in search_paths:
|
||||
resolved = path.resolve()
|
||||
if resolved.exists() and resolved.is_file():
|
||||
# Security: ensure path is within notes_dir
|
||||
try:
|
||||
resolved.relative_to(notes_dir.resolve())
|
||||
return resolved
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Fallback: search all _attachments folders recursively (slower but thorough)
|
||||
# This handles cross-folder image references like in Obsidian
|
||||
try:
|
||||
for attachment_folder in notes_dir.rglob('_attachments'):
|
||||
if attachment_folder.is_dir():
|
||||
candidate = attachment_folder / image_name
|
||||
if candidate.exists() and candidate.is_file():
|
||||
try:
|
||||
candidate.resolve().relative_to(notes_dir.resolve())
|
||||
return candidate.resolve()
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass # Ignore errors in recursive search
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
|
||||
"""
|
||||
Find all image references in markdown and embed them as base64.
|
||||
Handles:
|
||||
- Standard markdown images: 
|
||||
- Wikilink images: ![[image.png]] or ![[image.png|alt text]]
|
||||
"""
|
||||
|
||||
# First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]]
|
||||
wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||
|
||||
def replace_wikilink_image(match):
|
||||
image_name = match.group(1).strip()
|
||||
alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0]
|
||||
|
||||
# Find the image
|
||||
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
|
||||
|
||||
if resolved_path:
|
||||
base64_url = get_image_as_base64(resolved_path)
|
||||
if base64_url:
|
||||
return f''
|
||||
|
||||
# Image not found, convert to placeholder
|
||||
return f'![{alt_text}]()'
|
||||
|
||||
markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content)
|
||||
|
||||
# Then, handle standard markdown images: 
|
||||
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
|
||||
|
||||
def replace_image(match):
|
||||
alt_text = match.group(1)
|
||||
img_path = match.group(2)
|
||||
|
||||
# Skip external URLs and already-embedded base64
|
||||
if img_path.startswith(('http://', 'https://', 'data:')):
|
||||
return match.group(0)
|
||||
|
||||
# Skip empty paths (from failed wikilink conversion)
|
||||
if not img_path:
|
||||
return match.group(0)
|
||||
|
||||
# Handle /api/images/ paths (convert to filesystem paths)
|
||||
if img_path.startswith('/api/images/'):
|
||||
# Strip the /api/images/ prefix to get the relative path within notes_dir
|
||||
relative_path = img_path[len('/api/images/'):]
|
||||
resolved_path = (notes_dir / relative_path).resolve()
|
||||
else:
|
||||
# Try to resolve the image path relative to note folder
|
||||
resolved_path = (note_folder / img_path).resolve()
|
||||
|
||||
# If not found, try the attachment search
|
||||
if not resolved_path.exists():
|
||||
# Extract just the filename and search
|
||||
image_name = Path(img_path).name
|
||||
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
|
||||
if not resolved_path:
|
||||
return match.group(0) # Keep original if not found
|
||||
|
||||
# Security: ensure path is within notes_dir
|
||||
try:
|
||||
resolved_path.relative_to(notes_dir.resolve())
|
||||
except ValueError:
|
||||
# Path is outside notes_dir, skip
|
||||
return match.group(0)
|
||||
|
||||
# Get base64 data
|
||||
base64_url = get_image_as_base64(resolved_path)
|
||||
if base64_url:
|
||||
return f''
|
||||
|
||||
# Image not found, keep original
|
||||
return match.group(0)
|
||||
|
||||
markdown_content = re.sub(img_pattern, replace_image, markdown_content)
|
||||
|
||||
return markdown_content
|
||||
|
||||
|
||||
def convert_wikilinks_to_html(markdown_content: str) -> str:
|
||||
"""
|
||||
Convert wikilinks [[note]] or [[note|display text]] to HTML links.
|
||||
In standalone export mode, these are non-functional decorative links.
|
||||
"""
|
||||
# Pattern for wikilinks: [[target]] or [[target|display text]]
|
||||
# But NOT image wikilinks (those start with !)
|
||||
wikilink_pattern = r'(?<!!)\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
|
||||
|
||||
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;
|
||||
}}
|
||||
|
||||
@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);
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
return html
|
||||
207
backend/main.py
207
backend/main.py
|
|
@ -44,6 +44,17 @@ from .utils import (
|
|||
)
|
||||
from .plugins import PluginManager
|
||||
from .themes import get_available_themes, get_theme_css
|
||||
from .share import (
|
||||
create_share_token,
|
||||
get_share_token,
|
||||
get_share_info,
|
||||
revoke_share_token,
|
||||
get_note_by_token,
|
||||
delete_token_for_note,
|
||||
update_token_path,
|
||||
get_all_shared_paths,
|
||||
)
|
||||
from .export import generate_export_html, embed_images_as_base64, convert_wikilinks_to_html, strip_frontmatter
|
||||
|
||||
# Load configuration
|
||||
config_path = Path(__file__).parent.parent / "config.yaml"
|
||||
|
|
@ -715,6 +726,9 @@ async def move_note_endpoint(request: Request, data: dict):
|
|||
if not success:
|
||||
raise HTTPException(status_code=400, detail=error_msg or "Failed to move note")
|
||||
|
||||
# Update share token path if note was shared
|
||||
update_token_path(config['storage']['notes_dir'], old_path, new_path)
|
||||
|
||||
# Run plugin hooks
|
||||
plugin_manager.run_hook('on_note_save', note_path=new_path, content='')
|
||||
|
||||
|
|
@ -1041,6 +1055,9 @@ async def remove_note(request: Request, note_path: str):
|
|||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Clean up any share token for this note
|
||||
delete_token_for_note(config['storage']['notes_dir'], note_path)
|
||||
|
||||
# Run plugin hooks
|
||||
plugin_manager.run_hook('on_note_delete', note_path=note_path)
|
||||
|
||||
|
|
@ -1256,6 +1273,195 @@ async def toggle_plugin(request: Request, plugin_name: str, enabled: dict):
|
|||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to toggle plugin"))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Share Token Endpoints (authenticated)
|
||||
# ============================================================================
|
||||
|
||||
@api_router.post("/share/{note_path:path}")
|
||||
@limiter.limit("30/minute")
|
||||
async def create_share(request: Request, note_path: str, data: dict = None):
|
||||
"""
|
||||
Create a share token for a note.
|
||||
Returns the share URL that can be accessed without authentication.
|
||||
Optionally accepts { "theme": "theme-name" } to set the display theme.
|
||||
"""
|
||||
try:
|
||||
notes_dir = config['storage']['notes_dir']
|
||||
|
||||
# Get theme from request body (default to light)
|
||||
theme = "light"
|
||||
if data and isinstance(data, dict):
|
||||
theme = data.get('theme', 'light')
|
||||
|
||||
# Add .md extension if not present
|
||||
if not note_path.endswith('.md'):
|
||||
note_path = f"{note_path}.md"
|
||||
|
||||
# Check if note exists
|
||||
content = get_note_content(notes_dir, note_path)
|
||||
if content is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
|
||||
# Create or get existing token (with theme)
|
||||
token = create_share_token(notes_dir, note_path, theme)
|
||||
if not token:
|
||||
raise HTTPException(status_code=500, detail="Failed to create share token")
|
||||
|
||||
# Build share URL
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
share_url = f"{base_url}/share/{token}"
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"token": token,
|
||||
"url": share_url,
|
||||
"path": note_path,
|
||||
"theme": theme
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create share"))
|
||||
|
||||
|
||||
@api_router.get("/share/{note_path:path}")
|
||||
async def get_share_status(note_path: str, request: Request):
|
||||
"""
|
||||
Get the share status for a note.
|
||||
Returns whether the note is shared and its share URL if so.
|
||||
"""
|
||||
try:
|
||||
notes_dir = config['storage']['notes_dir']
|
||||
|
||||
# Add .md extension if not present
|
||||
if not note_path.endswith('.md'):
|
||||
note_path = f"{note_path}.md"
|
||||
|
||||
# Get share info
|
||||
info = get_share_info(notes_dir, note_path)
|
||||
|
||||
if info.get('shared'):
|
||||
base_url = str(request.base_url).rstrip('/')
|
||||
info['url'] = f"{base_url}/share/{info['token']}"
|
||||
|
||||
return info
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get share status"))
|
||||
|
||||
|
||||
@api_router.get("/shared-notes")
|
||||
async def list_shared_notes():
|
||||
"""
|
||||
Get a list of all currently shared note paths.
|
||||
Used for displaying share indicators in the UI.
|
||||
"""
|
||||
try:
|
||||
notes_dir = config['storage']['notes_dir']
|
||||
shared_paths = get_all_shared_paths(notes_dir)
|
||||
return {"paths": shared_paths}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to get shared notes"))
|
||||
|
||||
|
||||
@api_router.delete("/share/{note_path:path}")
|
||||
@limiter.limit("30/minute")
|
||||
async def delete_share(request: Request, note_path: str):
|
||||
"""
|
||||
Revoke sharing for a note (delete the share token).
|
||||
"""
|
||||
try:
|
||||
notes_dir = config['storage']['notes_dir']
|
||||
|
||||
# Add .md extension if not present
|
||||
if not note_path.endswith('.md'):
|
||||
note_path = f"{note_path}.md"
|
||||
|
||||
# Revoke token
|
||||
success = revoke_share_token(notes_dir, note_path)
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Share revoked" if success else "Note was not shared"
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to revoke share"))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Public Share Endpoint (no authentication required)
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/share/{token}", response_class=HTMLResponse)
|
||||
@limiter.limit("60/minute")
|
||||
async def view_shared_note(request: Request, token: str):
|
||||
"""
|
||||
View a shared note by its token.
|
||||
No authentication required - anyone with the token can view.
|
||||
"""
|
||||
try:
|
||||
notes_dir = Path(config['storage']['notes_dir'])
|
||||
|
||||
# Look up note by token (returns dict with path and theme)
|
||||
share_info = get_note_by_token(str(notes_dir), token)
|
||||
if not share_info:
|
||||
raise HTTPException(status_code=404, detail="Shared note not found or link expired")
|
||||
|
||||
note_path = share_info['path']
|
||||
theme = share_info.get('theme', 'light')
|
||||
|
||||
# Read note content
|
||||
content = get_note_content(str(notes_dir), note_path)
|
||||
if content is None:
|
||||
# Note was deleted but token still exists - clean up
|
||||
delete_token_for_note(str(notes_dir), note_path)
|
||||
raise HTTPException(status_code=404, detail="Note no longer exists")
|
||||
|
||||
# Strip YAML frontmatter (like the preview does)
|
||||
content = strip_frontmatter(content)
|
||||
|
||||
# Get note folder for resolving relative image paths
|
||||
note_file_path = notes_dir / note_path
|
||||
note_folder = note_file_path.parent
|
||||
|
||||
# Embed images as base64
|
||||
content_with_images = embed_images_as_base64(content, note_folder, notes_dir)
|
||||
|
||||
# Convert wikilinks to decorative HTML links
|
||||
content_with_links = convert_wikilinks_to_html(content_with_images)
|
||||
|
||||
# Use the theme that was set when sharing
|
||||
themes_dir = Path(__file__).parent.parent / "themes"
|
||||
theme_css = get_theme_css(str(themes_dir), theme)
|
||||
if not theme_css:
|
||||
theme_css = get_theme_css(str(themes_dir), "light")
|
||||
theme = "light"
|
||||
|
||||
# Strip data-theme selector
|
||||
theme_css = theme_css.replace(f':root[data-theme="{theme}"]', ':root')
|
||||
theme_css = theme_css.replace(':root[data-theme="light"]', ':root')
|
||||
theme_css = theme_css.replace(':root[data-theme="dark"]', ':root')
|
||||
|
||||
# Determine if dark theme
|
||||
is_dark = 'dark' in theme.lower() or theme in ['dracula', 'nord', 'monokai', 'cobalt2', 'gruvbox-dark']
|
||||
|
||||
# Get note title
|
||||
title = Path(note_path).stem
|
||||
|
||||
# Generate HTML
|
||||
html_content = generate_export_html(
|
||||
title=title,
|
||||
content=content_with_links,
|
||||
theme_css=theme_css,
|
||||
is_dark=is_dark
|
||||
)
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load shared note"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
|
|
@ -1305,4 +1511,3 @@ if __name__ == "__main__":
|
|||
port=config['server']['port'],
|
||||
reload=config['server']['reload']
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
151
frontend/app.js
151
frontend/app.js
|
|
@ -255,6 +255,13 @@ function noteApp() {
|
|||
selectedTemplate: '',
|
||||
newTemplateNoteName: '',
|
||||
|
||||
// Share state
|
||||
showShareModal: false,
|
||||
shareInfo: null,
|
||||
shareLoading: false,
|
||||
shareLinkCopied: false,
|
||||
_sharedNotePaths: new Set(), // O(1) lookup for shared note indicators
|
||||
|
||||
// Homepage state
|
||||
selectedHomepageFolder: '',
|
||||
_homepageCache: {
|
||||
|
|
@ -442,6 +449,7 @@ function noteApp() {
|
|||
// Note: Translations are preloaded synchronously before Alpine init (see index.html)
|
||||
// loadLocale() is only called when user changes language from settings
|
||||
await this.loadNotes();
|
||||
await this.loadSharedNotePaths();
|
||||
await this.loadTemplates();
|
||||
await this.checkStatsPlugin();
|
||||
this.loadSidebarWidth();
|
||||
|
|
@ -483,6 +491,7 @@ function noteApp() {
|
|||
this.noteContent = '';
|
||||
this.currentNoteName = '';
|
||||
this.outline = [];
|
||||
this.shareInfo = null; // Reset share info
|
||||
document.title = this.appName;
|
||||
|
||||
// Restore homepage folder state if it was saved
|
||||
|
|
@ -1792,7 +1801,9 @@ function noteApp() {
|
|||
const isCurrentImage = this.currentImage === note.path;
|
||||
const isCurrent = isImage ? isCurrentImage : isCurrentNote;
|
||||
|
||||
// Different icon for images
|
||||
// Different icon for images, share icon for shared notes
|
||||
const isShared = !isImage && this.isNoteShared(note.path);
|
||||
const shareIcon = isShared ? '<svg title="Shared" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : '';
|
||||
const icon = isImage ? '🖼️' : '';
|
||||
|
||||
// Escape paths for use in native event handlers
|
||||
|
|
@ -1818,7 +1829,7 @@ function noteApp() {
|
|||
onmouseover="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='var(--bg-hover)'"
|
||||
onmouseout="if('${escapedNotePath}' !== window.$root.currentNote && '${escapedNotePath}' !== window.$root.currentImage) this.style.backgroundColor='transparent'"
|
||||
>
|
||||
<span class="truncate" style="display: block; padding-right: 30px;">${icon}${icon ? ' ' : ''}${note.name}</span>
|
||||
<span class="truncate" style="display: block; padding-right: 30px;">${shareIcon}${icon}${icon ? ' ' : ''}${note.name}</span>
|
||||
<button
|
||||
onclick="${deleteHandler}"
|
||||
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
|
||||
|
|
@ -2197,6 +2208,7 @@ function noteApp() {
|
|||
this.currentNoteName = '';
|
||||
this.noteContent = '';
|
||||
this.currentImage = imagePath;
|
||||
this.shareInfo = null; // Reset share info
|
||||
this.viewMode = 'preview'; // Use preview mode to show image
|
||||
|
||||
// Update browser tab title for image
|
||||
|
|
@ -2531,6 +2543,7 @@ function noteApp() {
|
|||
this.noteContent = data.content;
|
||||
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
|
||||
this.currentImage = ''; // Clear image viewer when loading a note
|
||||
this.shareInfo = null; // Reset share info for new note
|
||||
|
||||
// Update browser tab title
|
||||
document.title = `${this.currentNoteName} - ${this.appName}`;
|
||||
|
|
@ -5004,6 +5017,140 @@ function noteApp() {
|
|||
}, 1500);
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Share Functions
|
||||
// ============================================================================
|
||||
|
||||
// Load list of shared note paths (for visual indicators)
|
||||
async loadSharedNotePaths() {
|
||||
try {
|
||||
const response = await fetch('/api/shared-notes');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this._sharedNotePaths = new Set(data.paths || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load shared note paths:', error);
|
||||
this._sharedNotePaths = new Set();
|
||||
}
|
||||
},
|
||||
|
||||
// Check if a note is currently shared (O(1) lookup)
|
||||
isNoteShared(notePath) {
|
||||
return this._sharedNotePaths.has(notePath);
|
||||
},
|
||||
|
||||
// Open share modal and fetch current share status
|
||||
async openShareModal() {
|
||||
if (!this.currentNote) return;
|
||||
|
||||
this.showShareModal = true;
|
||||
this.shareLoading = true;
|
||||
this.shareInfo = null;
|
||||
|
||||
try {
|
||||
const notePath = this.currentNote.replace('.md', '');
|
||||
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const response = await fetch(`/api/share/${encodedPath}`);
|
||||
|
||||
if (response.ok) {
|
||||
this.shareInfo = await response.json();
|
||||
} else {
|
||||
this.shareInfo = { shared: false };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get share status:', error);
|
||||
this.shareInfo = { shared: false };
|
||||
} finally {
|
||||
this.shareLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Create a share link for the current note (with current theme)
|
||||
async createShareLink() {
|
||||
if (!this.currentNote) return;
|
||||
|
||||
this.shareLoading = true;
|
||||
|
||||
try {
|
||||
const notePath = this.currentNote.replace('.md', '');
|
||||
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const response = await fetch(`/api/share/${encodedPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: this.currentTheme || 'light' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.shareInfo = await response.json();
|
||||
this.shareInfo.shared = true;
|
||||
// Update the shared paths set
|
||||
this._sharedNotePaths.add(this.currentNote);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(this.t('share.error_creating', { error: error.detail || 'Unknown error' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create share link:', error);
|
||||
alert(this.t('share.error_creating', { error: error.message }));
|
||||
} finally {
|
||||
this.shareLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Copy share link to clipboard
|
||||
async copyShareLink() {
|
||||
if (!this.shareInfo?.url) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.shareInfo.url);
|
||||
} catch (error) {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = this.shareInfo.url;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
this.shareLinkCopied = true;
|
||||
setTimeout(() => {
|
||||
this.shareLinkCopied = false;
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// Revoke share link
|
||||
async revokeShareLink() {
|
||||
if (!this.currentNote) return;
|
||||
|
||||
if (!confirm(this.t('share.confirm_revoke'))) return;
|
||||
|
||||
this.shareLoading = true;
|
||||
|
||||
try {
|
||||
const notePath = this.currentNote.replace('.md', '');
|
||||
const encodedPath = notePath.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
||||
const response = await fetch(`/api/share/${encodedPath}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
this.shareInfo = { shared: false };
|
||||
// Update the shared paths set
|
||||
this._sharedNotePaths.delete(this.currentNote);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(this.t('share.error_revoking', { error: error.detail || 'Unknown error' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke share link:', error);
|
||||
alert(this.t('share.error_revoking', { error: error.message }));
|
||||
} finally {
|
||||
this.shareLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle Zen Mode (full immersive writing experience)
|
||||
async toggleZenMode() {
|
||||
if (!this.zenMode) {
|
||||
|
|
|
|||
|
|
@ -2242,6 +2242,21 @@
|
|||
</svg>
|
||||
</button>
|
||||
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
|
||||
<!-- Share button -->
|
||||
<button
|
||||
@click="openShareModal()"
|
||||
class="p-2 transition hover:bg-opacity-80"
|
||||
:title="t('share.button_tooltip')"
|
||||
style="color: var(--text-secondary);"
|
||||
@mouseenter="$el.style.backgroundColor = 'var(--bg-secondary)'"
|
||||
@mouseleave="$el.style.backgroundColor = 'transparent'"
|
||||
>
|
||||
<!-- Share/Globe icon -->
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div style="width: 1px; height: 20px; background-color: var(--border-primary);"></div>
|
||||
<button
|
||||
@click="toggleZenMode()"
|
||||
class="p-2 transition"
|
||||
|
|
@ -2769,6 +2784,101 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Modal -->
|
||||
<div x-show="showShareModal"
|
||||
@click.self="showShareModal = false"
|
||||
@keydown.escape.window="showShareModal && (showShareModal = false)"
|
||||
class="fixed inset-0 flex items-center justify-center"
|
||||
style="background-color: rgba(0, 0, 0, 0.5); z-index: 1100;"
|
||||
x-cloak>
|
||||
<div class="rounded-lg shadow-xl p-6 max-w-md w-full mx-4"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||
@click.stop>
|
||||
|
||||
<h2 class="text-xl font-bold mb-4 flex items-center gap-2" style="color: var(--text-primary);">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path>
|
||||
</svg>
|
||||
<span x-text="t('share.modal_title')"></span>
|
||||
</h2>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div x-show="shareLoading" class="text-center py-4">
|
||||
<span x-text="t('common.loading')"></span>
|
||||
</div>
|
||||
|
||||
<!-- Not shared state -->
|
||||
<div x-show="!shareLoading && !shareInfo?.shared">
|
||||
<p class="mb-4 text-sm" style="color: var(--text-secondary);" x-text="t('share.not_shared_description')"></p>
|
||||
<button
|
||||
@click="createShareLink()"
|
||||
class="w-full px-4 py-2 rounded font-medium text-white transition-colors"
|
||||
style="background-color: var(--accent-primary);"
|
||||
onmouseover="this.style.backgroundColor='var(--accent-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--accent-primary)'"
|
||||
x-text="t('share.create_link')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shared state -->
|
||||
<div x-show="!shareLoading && shareInfo?.shared">
|
||||
<p class="mb-2 text-sm" style="color: var(--text-secondary);" x-text="t('share.shared_description')"></p>
|
||||
|
||||
<!-- Share URL -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
:value="shareInfo?.url || ''"
|
||||
readonly
|
||||
class="flex-1 px-3 py-2 rounded border text-sm"
|
||||
style="background-color: var(--bg-tertiary); color: var(--text-primary); border-color: var(--border-primary);"
|
||||
@click="$el.select()"
|
||||
/>
|
||||
<button
|
||||
@click="copyShareLink()"
|
||||
class="px-3 py-2 rounded font-medium text-white transition-colors flex items-center gap-1"
|
||||
:style="shareLinkCopied ? 'background-color: var(--success, #22c55e);' : 'background-color: var(--accent-primary);'"
|
||||
onmouseover="if(!this.classList.contains('copied')) this.style.backgroundColor='var(--accent-hover)'"
|
||||
onmouseout="if(!this.classList.contains('copied')) this.style.backgroundColor='var(--accent-primary)'"
|
||||
>
|
||||
<svg x-show="!shareLinkCopied" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
|
||||
</svg>
|
||||
<svg x-show="shareLinkCopied" x-cloak class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span x-text="shareLinkCopied ? t('common.copied') : t('share.copy')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Revoke sharing -->
|
||||
<button
|
||||
@click="revokeShareLink()"
|
||||
class="w-full px-4 py-2 rounded font-medium transition-colors"
|
||||
style="background-color: transparent; color: var(--error); border: 1px solid var(--error);"
|
||||
onmouseover="this.style.backgroundColor='rgba(239, 68, 68, 0.1)'"
|
||||
onmouseout="this.style.backgroundColor='transparent'"
|
||||
x-text="t('share.stop_sharing')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<div class="mt-4 pt-4 border-t" style="border-color: var(--border-primary);">
|
||||
<button
|
||||
@click="showShareModal = false"
|
||||
class="w-full px-4 py-2 rounded font-medium transition-colors"
|
||||
style="background-color: var(--bg-tertiary); color: var(--text-primary);"
|
||||
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
|
||||
onmouseout="this.style.backgroundColor='var(--bg-tertiary)'"
|
||||
x-text="t('common.close')"
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Bottom Tabs -->
|
||||
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Zen-Modus beenden (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Notiz teilen",
|
||||
"modal_title": "Notiz teilen",
|
||||
"not_shared_description": "Diese Notiz ist derzeit nicht geteilt. Erstellen Sie einen Link, um sie mit anderen zu teilen.",
|
||||
"shared_description": "Jeder mit diesem Link kann diese Notiz sehen:",
|
||||
"create_link": "Freigabelink erstellen",
|
||||
"copy": "Kopieren",
|
||||
"stop_sharing": "Freigabe beenden",
|
||||
"confirm_revoke": "Sind Sie sicher, dass Sie die Freigabe dieser Notiz beenden möchten? Der Freigabelink funktioniert dann nicht mehr.",
|
||||
"error_creating": "Fehler beim Erstellen des Freigabelinks: {{error}}",
|
||||
"error_revoking": "Fehler beim Widerrufen des Freigabelinks: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"no_tags": "Keine Tags gefunden",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Exit Zen Mode (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Share note",
|
||||
"modal_title": "Share Note",
|
||||
"not_shared_description": "This note is not currently shared. Create a link to share it with anyone.",
|
||||
"shared_description": "Anyone with this link can view this note:",
|
||||
"create_link": "Create Share Link",
|
||||
"copy": "Copy",
|
||||
"stop_sharing": "Stop Sharing",
|
||||
"confirm_revoke": "Are you sure you want to stop sharing this note? The share link will no longer work.",
|
||||
"error_creating": "Failed to create share link: {{error}}",
|
||||
"error_revoking": "Failed to revoke share link: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"no_tags": "No tags found",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Exit Zen Mode (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Share note",
|
||||
"modal_title": "Share Note",
|
||||
"not_shared_description": "This note is not currently shared. Create a link to share it with anyone.",
|
||||
"shared_description": "Anyone with this link can view this note:",
|
||||
"create_link": "Create Share Link",
|
||||
"copy": "Copy",
|
||||
"stop_sharing": "Stop Sharing",
|
||||
"confirm_revoke": "Are you sure you want to stop sharing this note? The share link will no longer work.",
|
||||
"error_creating": "Failed to create share link: {{error}}",
|
||||
"error_revoking": "Failed to revoke share link: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Tags",
|
||||
"no_tags": "No tags found",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Salir del Modo Zen (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Compartir nota",
|
||||
"modal_title": "Compartir Nota",
|
||||
"not_shared_description": "Esta nota no está compartida actualmente. Crea un enlace para compartirla con cualquiera.",
|
||||
"shared_description": "Cualquiera con este enlace puede ver esta nota:",
|
||||
"create_link": "Crear enlace para compartir",
|
||||
"copy": "Copiar",
|
||||
"stop_sharing": "Dejar de compartir",
|
||||
"confirm_revoke": "¿Estás seguro de que quieres dejar de compartir esta nota? El enlace dejará de funcionar.",
|
||||
"error_creating": "Error al crear el enlace: {{error}}",
|
||||
"error_revoking": "Error al revocar el enlace: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Etiquetas",
|
||||
"no_tags": "No se encontraron etiquetas",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Quitter le Mode Zen (Échap)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Partager la note",
|
||||
"modal_title": "Partager la Note",
|
||||
"not_shared_description": "Cette note n'est pas partagée actuellement. Créez un lien pour la partager avec n'importe qui.",
|
||||
"shared_description": "Toute personne disposant de ce lien peut voir cette note :",
|
||||
"create_link": "Créer un lien de partage",
|
||||
"copy": "Copier",
|
||||
"stop_sharing": "Arrêter le partage",
|
||||
"confirm_revoke": "Êtes-vous sûr de vouloir arrêter le partage de cette note ? Le lien ne fonctionnera plus.",
|
||||
"error_creating": "Échec de la création du lien : {{error}}",
|
||||
"error_revoking": "Échec de la révocation du lien : {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Étiquettes",
|
||||
"no_tags": "Aucune étiquette trouvée",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Esci dalla Modalità Zen (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Condividi nota",
|
||||
"modal_title": "Condividi Nota",
|
||||
"not_shared_description": "Questa nota non è attualmente condivisa. Crea un link per condividerla con chiunque.",
|
||||
"shared_description": "Chiunque abbia questo link può vedere questa nota:",
|
||||
"create_link": "Crea link di condivisione",
|
||||
"copy": "Copia",
|
||||
"stop_sharing": "Interrompi condivisione",
|
||||
"confirm_revoke": "Sei sicuro di voler interrompere la condivisione di questa nota? Il link non funzionerà più.",
|
||||
"error_creating": "Impossibile creare il link: {{error}}",
|
||||
"error_revoking": "Impossibile revocare il link: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Tag",
|
||||
"no_tags": "Nessun tag trovato",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "集中モードを終了 (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "ノートを共有",
|
||||
"modal_title": "ノートを共有",
|
||||
"not_shared_description": "このノートは現在共有されていません。リンクを作成して誰とでも共有できます。",
|
||||
"shared_description": "このリンクを持っている人は誰でもこのノートを閲覧できます:",
|
||||
"create_link": "共有リンクを作成",
|
||||
"copy": "コピー",
|
||||
"stop_sharing": "共有を停止",
|
||||
"confirm_revoke": "このノートの共有を停止してもよろしいですか?共有リンクは機能しなくなります。",
|
||||
"error_creating": "共有リンクの作成に失敗しました: {{error}}",
|
||||
"error_revoking": "共有リンクの取り消しに失敗しました: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "タグ",
|
||||
"no_tags": "タグが見つかりません",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Выйти из режима концентрации (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Поделиться заметкой",
|
||||
"modal_title": "Поделиться заметкой",
|
||||
"not_shared_description": "Эта заметка сейчас не опубликована. Создайте ссылку, чтобы поделиться ею с кем угодно.",
|
||||
"shared_description": "Любой, у кого есть эта ссылка, может просмотреть заметку:",
|
||||
"create_link": "Создать ссылку",
|
||||
"copy": "Копировать",
|
||||
"stop_sharing": "Прекратить доступ",
|
||||
"confirm_revoke": "Вы уверены, что хотите прекратить доступ к этой заметке? Ссылка перестанет работать.",
|
||||
"error_creating": "Не удалось создать ссылку: {{error}}",
|
||||
"error_revoking": "Не удалось отозвать ссылку: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Теги",
|
||||
"no_tags": "Теги не найдены",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "Izhod iz zen načina (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "Deli zapis",
|
||||
"modal_title": "Deli zapis",
|
||||
"not_shared_description": "Ta zapis trenutno ni deljen. Ustvarite povezavo za deljenje z drugimi.",
|
||||
"shared_description": "Kdor ima to povezavo, si lahko ogleda zapis:",
|
||||
"create_link": "Ustvari povezavo za deljenje",
|
||||
"copy": "Kopiraj",
|
||||
"stop_sharing": "Prenehaj deliti",
|
||||
"confirm_revoke": "Ali ste prepričani, da želite prenehati deliti ta zapis? Povezava ne bo več delovala.",
|
||||
"error_creating": "Napaka pri ustvarjanju povezave: {{error}}",
|
||||
"error_revoking": "Napaka pri preklicu povezave: {{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "Oznake",
|
||||
"no_tags": "Ni najdenih oznak",
|
||||
|
|
|
|||
|
|
@ -116,6 +116,19 @@
|
|||
"exit_hint": "退出禅模式 (Esc)"
|
||||
},
|
||||
|
||||
"share": {
|
||||
"button_tooltip": "分享笔记",
|
||||
"modal_title": "分享笔记",
|
||||
"not_shared_description": "此笔记当前未分享。创建链接以与任何人分享。",
|
||||
"shared_description": "任何拥有此链接的人都可以查看此笔记:",
|
||||
"create_link": "创建分享链接",
|
||||
"copy": "复制",
|
||||
"stop_sharing": "停止分享",
|
||||
"confirm_revoke": "您确定要停止分享此笔记吗?分享链接将不再有效。",
|
||||
"error_creating": "创建分享链接失败:{{error}}",
|
||||
"error_revoking": "撤销分享链接失败:{{error}}"
|
||||
},
|
||||
|
||||
"tags": {
|
||||
"title": "标签",
|
||||
"no_tags": "未找到标签",
|
||||
|
|
|
|||
Loading…
Reference in New Issue