mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
753 lines
29 KiB
Python
753 lines
29 KiB
Python
from django.shortcuts import render, redirect, get_object_or_404
|
|
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView, TemplateView
|
|
from django.views import View # Add this import
|
|
from django.urls import reverse_lazy, reverse # Add 'reverse' here
|
|
from django.db.models import F, Count, Q, Case, When, Value, IntegerField
|
|
from django.db.models.functions import TruncDate
|
|
from .models import Link, ClickLog, LinkChangeLog, Page, Post
|
|
from .forms import LinkForm, PageForm
|
|
import json
|
|
from django.core.serializers.json import DjangoJSONEncoder
|
|
from django.contrib import messages
|
|
from django.db import IntegrityError
|
|
from django.utils.translation import gettext as _
|
|
from django.core.exceptions import ValidationError
|
|
from django.http import JsonResponse, HttpResponse
|
|
from django.core.serializers import serialize
|
|
from django.utils.dateparse import parse_datetime
|
|
import random
|
|
from django.utils.text import slugify
|
|
import markdown
|
|
from .templatetags import think_markdown, tasklist_markdown
|
|
import logging
|
|
import re
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
from datetime import datetime
|
|
from django.conf import settings
|
|
from django.http import FileResponse
|
|
from wsgiref.util import FileWrapper
|
|
from django.contrib.auth.decorators import user_passes_test
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
import requests
|
|
from django.views.decorators.http import require_http_methods
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from bs4 import BeautifulSoup
|
|
|
|
from .page_views import (
|
|
PageListView, PageDetailView, PageCreateView,
|
|
PageUpdateView, PageDeleteView, fetch_page_info,
|
|
PageViewSet
|
|
)
|
|
from .search_views import SearchView, search_aliases, search
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class LinkListView(ListView):
|
|
model = Link
|
|
template_name = 'links/link_list.html'
|
|
context_object_name = 'links'
|
|
|
|
def get_queryset(self):
|
|
queryset = super().get_queryset()
|
|
sort_by = self.request.GET.get('sort', 'click_count')
|
|
order = self.request.GET.get('order', 'asc')
|
|
|
|
if sort_by == 'alias':
|
|
ordering = F('alias').asc(nulls_last=True) if order == 'asc' else F('alias').desc(nulls_last=True)
|
|
elif sort_by == 'link_type':
|
|
ordering = F('link_type').asc(nulls_last=True) if order == 'asc' else F('link_type').desc(nulls_last=True)
|
|
elif sort_by == 'original_url':
|
|
ordering = F('original_url').asc(nulls_last=True) if order == 'asc' else F('original_url').desc(nulls_last=True)
|
|
elif sort_by == 'clicks':
|
|
ordering = F('click_count').asc(nulls_last=True) if order == 'asc' else F('click_count').desc(nulls_last=True)
|
|
elif sort_by == 'created_at':
|
|
ordering = F('created_at').asc(nulls_last=True) if order == 'asc' else F('created_at').desc(nulls_last=True)
|
|
elif sort_by == 'updated_at':
|
|
ordering = F('updated_at').asc(nulls_last=True) if order == 'asc' else F('updated_at').desc(nulls_last=True)
|
|
else:
|
|
ordering = F('click_count').desc(nulls_last=True)
|
|
|
|
return queryset.order_by(ordering)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['current_sort'] = self.request.GET.get('sort', 'click_count')
|
|
context['current_order'] = self.request.GET.get('order', 'asc')
|
|
|
|
# Modern color palette
|
|
colors = [
|
|
'#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
|
|
'#1abc9c', '#d35400', '#34495e', '#16a085', '#27ae60'
|
|
]
|
|
|
|
# Add top 3 popular links to the context with random colors
|
|
popular_links = Link.objects.order_by('-click_count')[:3]
|
|
context['popular_links'] = [
|
|
{'link': link, 'color': random.choice(colors)}
|
|
for link in popular_links
|
|
]
|
|
|
|
# Add latest 3 posts to the context (most recent first)
|
|
context['latest_posts'] = Post.objects.all().order_by('-created_at')[:3]
|
|
|
|
# Add pinned mini apps with random colors
|
|
mini_apps = [
|
|
{'name': 'Image Gallery', 'description': 'Browse Images', 'url': 'mini-apps-image-gallery'},
|
|
{'name': 'TTS', 'description': 'Convert text to speech', 'url': 'mini-apps-tts'},
|
|
{'name': 'Coming Soon', 'description': 'New App', 'url': '#'}
|
|
]
|
|
context['mini_apps'] = [
|
|
{**app, 'color': random.choice(colors)}
|
|
for app in mini_apps
|
|
]
|
|
|
|
# Add timestamp for random image cache busting
|
|
import time
|
|
context['timestamp'] = int(time.time())
|
|
|
|
return context
|
|
|
|
class LinkCreateView(CreateView):
|
|
model = Link
|
|
form_class = LinkForm
|
|
template_name = 'links/link_form.html' # 确保这里的路径是正确的
|
|
success_url = reverse_lazy('link_list')
|
|
|
|
def get_initial(self):
|
|
initial = super().get_initial()
|
|
alias = self.request.GET.get('alias')
|
|
if alias:
|
|
initial['alias'] = alias
|
|
return initial
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['is_new'] = True
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
form.instance.alias = form.instance.alias.lower()
|
|
alias = form.cleaned_data.get('alias').lower()
|
|
if not self.is_valid_alias(alias):
|
|
form.add_error('alias', _("Alias cannot contain special characters."))
|
|
return self.form_invalid(form)
|
|
if Link.objects.filter(alias=alias).exists():
|
|
form.add_error('alias', _("This alias is already in use. Please choose another one."))
|
|
return self.form_invalid(form)
|
|
|
|
# Set original_url for custom links
|
|
if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
|
|
form.instance.original_url = f'/custom/{alias}'
|
|
|
|
return super().form_valid(form)
|
|
|
|
def is_valid_alias(self, alias):
|
|
return alias.isalnum()
|
|
|
|
class LinkUpdateView(UpdateView):
|
|
model = Link
|
|
form_class = LinkForm
|
|
template_name = 'links/link_form.html'
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
|
response = super().dispatch(request, *args, **kwargs)
|
|
# Prevent caching to ensure fresh markdown content after task toggles
|
|
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
|
response['Pragma'] = 'no-cache'
|
|
response['Expires'] = '0'
|
|
return response
|
|
|
|
def get_object(self, queryset=None):
|
|
# Handle both pk (for regular links) and alias (for custom links)
|
|
pk = self.kwargs.get('pk')
|
|
alias = self.kwargs.get('alias')
|
|
|
|
if pk:
|
|
# Regular link lookup by pk
|
|
return get_object_or_404(Link, pk=pk)
|
|
elif alias:
|
|
# Custom link lookup by alias
|
|
alias = alias.lower()
|
|
return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)
|
|
else:
|
|
# Fallback to default behavior
|
|
return super().get_object(queryset)
|
|
|
|
def get_initial(self):
|
|
initial = super().get_initial()
|
|
self.original_url = self.object.original_url
|
|
return initial
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
link = self.get_object()
|
|
context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
|
|
return context
|
|
|
|
def form_valid(self, form):
|
|
form.instance.alias = form.instance.alias.lower()
|
|
alias = form.cleaned_data.get('alias').lower()
|
|
if not self.is_valid_alias(alias):
|
|
form.add_error('alias', _("Alias cannot contain special characters."))
|
|
return self.form_invalid(form)
|
|
if Link.objects.filter(alias=alias).exclude(pk=self.object.pk).exists():
|
|
form.add_error('alias', _("This alias is already in use. Please choose another one."))
|
|
return self.form_invalid(form)
|
|
|
|
# 更新 custome link type, original_url 字段
|
|
if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
|
|
form.instance.original_url = f'/custom/{alias}'
|
|
|
|
response = super().form_valid(form)
|
|
|
|
new_url = form.cleaned_data['original_url']
|
|
if self.original_url != new_url:
|
|
logger.info(f"URL changed from {self.original_url} to {new_url}")
|
|
try:
|
|
self.object.log_url_change(self.original_url)
|
|
logger.info("LinkChangeLog created successfully")
|
|
except Exception as e:
|
|
logger.error(f"Error creating LinkChangeLog: {str(e)}")
|
|
else:
|
|
logger.info("URL did not change")
|
|
|
|
return response
|
|
|
|
def form_invalid(self, form):
|
|
for field, errors in form.errors.items():
|
|
for error in errors:
|
|
messages.error(self.request, f"{error}")
|
|
return super().form_invalid(form)
|
|
|
|
def get_success_url(self):
|
|
return reverse('link_detail', kwargs={'pk': self.object.pk})
|
|
|
|
def is_valid_alias(self, alias):
|
|
return alias.isalnum()
|
|
|
|
class LinkDeleteView(DeleteView):
|
|
model = Link
|
|
template_name = 'links/link_confirm_delete.html'
|
|
success_url = reverse_lazy('link_list')
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
context['object'] = self.get_object()
|
|
return context
|
|
|
|
def delete_selected(request):
|
|
if request.method == 'POST':
|
|
selected_ids = request.POST.getlist('selected_links')
|
|
Link.objects.filter(id__in=selected_ids).delete()
|
|
return redirect('link_list')
|
|
|
|
def redirect_to_original(request, alias, param=None):
|
|
processed_alias = ''.join(e for e in alias.lower() if e.isalnum())
|
|
|
|
try:
|
|
link = Link.objects.get(alias=processed_alias)
|
|
link.click_count = F('click_count') + 1
|
|
link.save()
|
|
ClickLog.objects.create(link=link)
|
|
|
|
try:
|
|
# 如果是模板 URL 并且提供了参数
|
|
if param:
|
|
# 从 URL 中提取参数名
|
|
pattern = r'\{([^{}]*)\}'
|
|
matches = re.finditer(pattern, link.original_url)
|
|
for match in matches:
|
|
param_str = match.group(1)
|
|
if ',' in param_str:
|
|
param_name = param_str.split(',')[0].strip()
|
|
else:
|
|
param_name = param_str.strip()
|
|
# 使用提供的参数值
|
|
url = link.get_processed_url(**{param_name: param})
|
|
return redirect(url)
|
|
|
|
# 如果没有提供参数,使用默认值
|
|
url = link.get_processed_url()
|
|
if url is None:
|
|
url = link.original_url
|
|
return redirect(url)
|
|
except ValueError as e:
|
|
messages.error(request, str(e))
|
|
return redirect('link_list')
|
|
|
|
except Link.DoesNotExist:
|
|
messages.warning(request, _("The alias '{}' doesn't exist. Do you want to create a new one?").format(processed_alias))
|
|
return redirect(reverse('link_create') + f'?alias={processed_alias}')
|
|
|
|
class LinkDetailView(DetailView):
|
|
model = Link
|
|
template_name = 'links/link_detail.html'
|
|
context_object_name = 'link'
|
|
|
|
def get_object(self, queryset=None):
|
|
if 'pk' in self.kwargs:
|
|
return super().get_object(queryset)
|
|
elif 'alias' in self.kwargs:
|
|
return get_object_or_404(Link, alias=self.kwargs['alias'])
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
from datetime import datetime, timedelta
|
|
import calendar
|
|
|
|
# Get the time period from request (default to 3 months)
|
|
period = self.request.GET.get('period', '3m')
|
|
|
|
# Calculate date range and interval based on period
|
|
end_date = datetime.now().date()
|
|
if period == '3m':
|
|
start_date = end_date - timedelta(days=89) # 90 days total
|
|
period_name = "3 Months"
|
|
interval_days = 1 # Daily
|
|
elif period == '6m':
|
|
start_date = end_date - timedelta(days=179) # 180 days total
|
|
period_name = "6 Months"
|
|
interval_days = 7 # Weekly
|
|
elif period == '1y':
|
|
start_date = end_date - timedelta(days=364) # 365 days total
|
|
period_name = "1 Year"
|
|
interval_days = 7 # Weekly
|
|
elif period == 'all':
|
|
# Get the earliest click date, or default to 1 year ago if no clicks
|
|
earliest_click = ClickLog.objects.filter(link=self.object).order_by('clicked_at').first()
|
|
if earliest_click:
|
|
start_date = earliest_click.clicked_at.date()
|
|
# Determine interval based on data range
|
|
days_range = (end_date - start_date).days
|
|
if days_range <= 90:
|
|
interval_days = 7 # Weekly
|
|
elif days_range <= 365:
|
|
interval_days = 7 # Weekly
|
|
else:
|
|
interval_days = 30 # Monthly
|
|
else:
|
|
start_date = end_date - timedelta(days=364)
|
|
interval_days = 7 # Weekly
|
|
period_name = "All Time"
|
|
else:
|
|
start_date = end_date - timedelta(days=89) # Default to 3 months
|
|
period_name = "3 Months"
|
|
interval_days = 1 # Daily
|
|
|
|
# Get click stats from database
|
|
click_stats = ClickLog.objects.filter(
|
|
link=self.object,
|
|
clicked_at__date__gte=start_date,
|
|
clicked_at__date__lte=end_date
|
|
).annotate(
|
|
date=TruncDate('clicked_at')
|
|
).values('date').annotate(count=Count('id')).order_by('date')
|
|
|
|
# Convert to dictionary for easy lookup
|
|
click_dict = {item['date']: item['count'] for item in click_stats}
|
|
|
|
# Create complete dataset with appropriate intervals
|
|
click_stats_list = []
|
|
current_date = start_date
|
|
|
|
if interval_days == 1:
|
|
# Daily intervals
|
|
while current_date <= end_date:
|
|
click_stats_list.append({
|
|
'date': current_date.strftime('%Y-%m-%d'),
|
|
'count': click_dict.get(current_date, 0)
|
|
})
|
|
current_date += timedelta(days=1)
|
|
else:
|
|
# Weekly, bi-weekly, or monthly intervals
|
|
while current_date <= end_date:
|
|
interval_end = min(current_date + timedelta(days=interval_days - 1), end_date)
|
|
|
|
# Sum clicks for this interval
|
|
interval_count = 0
|
|
temp_date = current_date
|
|
while temp_date <= interval_end:
|
|
interval_count += click_dict.get(temp_date, 0)
|
|
temp_date += timedelta(days=1)
|
|
|
|
# Format label based on interval
|
|
if interval_days == 7: # Weekly
|
|
label = f"{current_date.strftime('%m/%d')}"
|
|
elif interval_days == 14: # Bi-weekly
|
|
label = f"{current_date.strftime('%m/%d')}"
|
|
else: # Monthly
|
|
label = f"{calendar.month_abbr[current_date.month]} {current_date.year}"
|
|
|
|
click_stats_list.append({
|
|
'date': current_date.strftime('%Y-%m-%d'),
|
|
'count': interval_count,
|
|
'label': label
|
|
})
|
|
current_date += timedelta(days=interval_days)
|
|
|
|
context['click_stats'] = json.dumps(click_stats_list, cls=DjangoJSONEncoder)
|
|
context['current_period'] = period
|
|
context['period_name'] = period_name
|
|
context['interval_days'] = interval_days
|
|
|
|
# Convert markdown to HTML if the link is a custom type
|
|
if self.object.link_type == Link.LinkType.CUSTOM:
|
|
task_states = self.object.task_states if self.object.task_states else {}
|
|
context['rendered_text'] = markdown.markdown(
|
|
self.object.text,
|
|
extensions=[
|
|
'markdown.extensions.fenced_code',
|
|
'markdown.extensions.tables',
|
|
'markdown.extensions.nl2br',
|
|
think_markdown.ThinkExtension(),
|
|
tasklist_markdown.TaskListExtension(task_states=task_states)
|
|
]
|
|
)
|
|
|
|
# 获取所有与此链接相关的变更日志
|
|
context['change_logs'] = LinkChangeLog.objects.filter(link=self.object).order_by('-changed_at')
|
|
|
|
return context
|
|
|
|
class ToolsView(View):
|
|
template_name = 'links/tools.html'
|
|
|
|
def get(self, request):
|
|
return render(request, self.template_name)
|
|
|
|
def post(self, request):
|
|
if 'export' in request.POST:
|
|
links = Link.objects.prefetch_related('tags').all()
|
|
|
|
formatted_data = []
|
|
for link in links:
|
|
# Get all tag names for this link
|
|
tag_names = [tag.name for tag in link.tags.all()]
|
|
|
|
# Build the basic link data
|
|
link_data = {
|
|
'alias': link.alias,
|
|
'original_url': link.original_url,
|
|
'link_type': link.link_type,
|
|
'created_at': link.created_at.isoformat() if link.created_at else None,
|
|
'updated_at': link.updated_at.isoformat() if link.updated_at else None,
|
|
'tags': tag_names
|
|
}
|
|
|
|
# For custom types, include the text field (markdown content)
|
|
if link.link_type == Link.LinkType.CUSTOM and link.text:
|
|
link_data['text'] = link.text
|
|
|
|
formatted_data.append(link_data)
|
|
|
|
response = HttpResponse(json.dumps(formatted_data, indent=2), content_type='application/json')
|
|
response['Content-Disposition'] = 'attachment; filename="links_export.json"'
|
|
return response
|
|
elif 'import' in request.POST:
|
|
try:
|
|
json_file = request.FILES['json_file']
|
|
data = json.load(json_file)
|
|
|
|
if not isinstance(data, list):
|
|
raise ValueError(_("Invalid JSON format. Expected a list of objects."))
|
|
|
|
imported_count = 0
|
|
skipped_count = 0
|
|
for item in data:
|
|
if not isinstance(item, dict) or 'alias' not in item:
|
|
raise ValueError(_("Invalid item in JSON. Each item must be an object with an 'alias' field."))
|
|
|
|
if not Link.objects.filter(alias=item['alias']).exists():
|
|
Link.objects.create(
|
|
alias=item['alias'],
|
|
original_url=item.get('original_url', ''),
|
|
created_at=parse_datetime(item.get('created_at')) if item.get('created_at') else None,
|
|
updated_at=parse_datetime(item.get('updated_at')) if item.get('updated_at') else None
|
|
)
|
|
imported_count += 1
|
|
else:
|
|
skipped_count += 1
|
|
|
|
messages.success(request, _(f"{imported_count} aliases have been imported successfully. {skipped_count} were skipped."))
|
|
except json.JSONDecodeError:
|
|
messages.error(request, _("Invalid JSON file. Please check the file format."))
|
|
except ValueError as e:
|
|
messages.error(request, str(e))
|
|
except Exception as e:
|
|
messages.error(request, _(f"An error occurred during import: {str(e)}"))
|
|
|
|
return render(request, self.template_name)
|
|
|
|
class CustomLinkView(DetailView):
|
|
model = Link
|
|
template_name = 'links/custom_link.html'
|
|
context_object_name = 'link'
|
|
|
|
def get_object(self, queryset=None):
|
|
# Convert all uppercases to lowercases
|
|
alias = self.kwargs.get('alias').lower()
|
|
return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
context = super().get_context_data(**kwargs)
|
|
# Render markdown text as HTML with task list support
|
|
task_states = self.object.task_states if self.object.task_states else {}
|
|
context['rendered_text'] = markdown.markdown(
|
|
self.object.text,
|
|
extensions=[
|
|
'markdown.extensions.fenced_code',
|
|
'markdown.extensions.tables',
|
|
'markdown.extensions.nl2br',
|
|
think_markdown.ThinkExtension(),
|
|
tasklist_markdown.TaskListExtension(task_states=task_states)
|
|
]
|
|
)
|
|
return context
|
|
|
|
def export_database(request):
|
|
"""Export the entire SQLite database as a tarball"""
|
|
try:
|
|
# 获取数据库文件路径
|
|
db_path = settings.DATABASES['default']['NAME']
|
|
|
|
# 创建临时目录
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# 创建带时间戳的文件名
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
tar_filename = f'database_backup_{timestamp}.tar.gz'
|
|
tar_filepath = os.path.join(temp_dir, tar_filename)
|
|
|
|
# 创建tar文件
|
|
with tarfile.open(tar_filepath, 'w:gz') as tar:
|
|
# 添加数据库文件到tar
|
|
tar.add(db_path, arcname=os.path.basename(db_path))
|
|
|
|
# 打开文件准备下载
|
|
wrapper = FileWrapper(open(tar_filepath, 'rb'))
|
|
response = FileResponse(wrapper, content_type='application/x-gzip')
|
|
response['Content-Disposition'] = f'attachment; filename="{tar_filename}"'
|
|
response['Content-Length'] = os.path.getsize(tar_filepath)
|
|
|
|
return response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Database export failed: {str(e)}")
|
|
messages.error(request, _("Failed to export database: {}").format(str(e)))
|
|
return redirect('tools')
|
|
|
|
class HelpView(TemplateView):
|
|
template_name = 'links/help.html'
|
|
|
|
@require_http_methods(["POST"])
|
|
def generate_tts(request, post_id):
|
|
"""Generate TTS audio for a post"""
|
|
try:
|
|
post = get_object_or_404(Post, pk=post_id)
|
|
|
|
# Get voice parameter from request body
|
|
voice = 'zh-CN-XiaomengNeural' # Default voice
|
|
if request.content_type == 'application/json' and request.body:
|
|
import json
|
|
data = json.loads(request.body)
|
|
voice = data.get('voice', 'zh-CN-XiaomengNeural')
|
|
|
|
# Clean the post content - remove markdown and HTML
|
|
clean_text = clean_text_for_tts(post.content)
|
|
|
|
if not clean_text.strip():
|
|
return JsonResponse({'error': 'No text content to convert'}, status=400)
|
|
|
|
# Call TTS API with voice parameter
|
|
import urllib.parse
|
|
encoded_text = urllib.parse.quote(clean_text)
|
|
encoded_voice = urllib.parse.quote(voice)
|
|
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"
|
|
|
|
try:
|
|
response = requests.get(tts_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
# Return the audio file
|
|
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
|
|
audio_response['Content-Disposition'] = f'inline; filename="post_{post_id}_tts.mp3"'
|
|
return audio_response
|
|
|
|
except requests.RequestException as e:
|
|
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
|
|
|
|
except Post.DoesNotExist:
|
|
return JsonResponse({'error': 'Post not found'}, status=404)
|
|
except Exception as e:
|
|
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
|
|
|
|
@require_http_methods(["POST"])
|
|
def generate_tts_api(request):
|
|
"""General TTS API that accepts text content and voice parameter"""
|
|
try:
|
|
import json
|
|
|
|
# Get text and voice from request body
|
|
if request.content_type == 'application/json':
|
|
data = json.loads(request.body)
|
|
text_content = data.get('text', '')
|
|
voice = data.get('voice', 'zh-CN-XiaomengNeural') # Default to first Chinese voice
|
|
else:
|
|
text_content = request.POST.get('text', '')
|
|
voice = request.POST.get('voice', 'zh-CN-XiaomengNeural')
|
|
|
|
if not text_content.strip():
|
|
return JsonResponse({'error': 'No text content provided'}, status=400)
|
|
|
|
# Clean the text content - remove markdown and HTML
|
|
clean_text = clean_text_for_tts(text_content)
|
|
|
|
if not clean_text.strip():
|
|
return JsonResponse({'error': 'No valid text content to convert'}, status=400)
|
|
|
|
# Call TTS API with voice parameter
|
|
import urllib.parse
|
|
encoded_text = urllib.parse.quote(clean_text)
|
|
encoded_voice = urllib.parse.quote(voice)
|
|
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"
|
|
|
|
try:
|
|
response = requests.get(tts_url, timeout=30)
|
|
response.raise_for_status()
|
|
|
|
# Return the audio file
|
|
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
|
|
audio_response['Content-Disposition'] = f'inline; filename="tts_audio.mp3"'
|
|
return audio_response
|
|
|
|
except requests.RequestException as e:
|
|
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
|
|
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({'error': 'Invalid JSON data'}, status=400)
|
|
except Exception as e:
|
|
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
|
|
|
|
def clean_text_for_tts(content):
|
|
"""Clean markdown and HTML from text for TTS"""
|
|
# Convert markdown to HTML first
|
|
html = markdown.markdown(content)
|
|
|
|
# Parse HTML and extract text
|
|
soup = BeautifulSoup(html, 'html.parser')
|
|
|
|
# Remove script and style elements
|
|
for script in soup(["script", "style"]):
|
|
script.decompose()
|
|
|
|
# Get text and clean it
|
|
text = soup.get_text()
|
|
|
|
# Clean up whitespace and special characters
|
|
text = re.sub(r'\s+', ' ', text) # Multiple spaces to single
|
|
text = re.sub(r'\n+', '. ', text) # Multiple newlines to period
|
|
text = text.strip()
|
|
|
|
# Limit text length (TTS services often have limits)
|
|
if len(text) > 3000:
|
|
text = text[:3000] + "..."
|
|
|
|
return text
|
|
|
|
|
|
@require_http_methods(["POST"])
|
|
@csrf_exempt
|
|
def toggle_link_task(request, pk):
|
|
"""
|
|
Toggle the checked state of a task in a link's custom content.
|
|
Only updates task_states JSON, keeps markdown unchanged.
|
|
|
|
POST /link/{id}/toggle_task/
|
|
Body: {"task_hash": "abc123"}
|
|
"""
|
|
try:
|
|
link = get_object_or_404(Link, pk=pk)
|
|
|
|
# Parse JSON body
|
|
import json
|
|
data = json.loads(request.body)
|
|
task_hash = data.get('task_hash')
|
|
|
|
if not task_hash:
|
|
return JsonResponse(
|
|
{'error': 'task_hash is required'},
|
|
status=400
|
|
)
|
|
|
|
# Get current task states or initialize empty dict
|
|
task_states = link.task_states if link.task_states else {}
|
|
|
|
# Toggle the state (default to False if not set, then toggle)
|
|
current_state = task_states.get(task_hash, False)
|
|
new_state = not current_state
|
|
task_states[task_hash] = new_state
|
|
|
|
link.task_states = task_states
|
|
|
|
# Save only task_states, leave markdown text unchanged
|
|
link.save(update_fields=['task_states', 'updated_at'])
|
|
|
|
# Extract task text from markdown for logging
|
|
task_text = _extract_task_text_from_hash(link.text, task_hash)
|
|
|
|
# Log the task toggle with metadata
|
|
change_log = LinkChangeLog.objects.create(
|
|
link=link,
|
|
change_type=LinkChangeLog.ChangeType.TASK_TOGGLE,
|
|
metadata={
|
|
'task_hash': task_hash,
|
|
'task_text': task_text,
|
|
'old_state': current_state,
|
|
'new_state': new_state
|
|
}
|
|
)
|
|
|
|
return JsonResponse({
|
|
'task_hash': task_hash,
|
|
'checked': new_state
|
|
})
|
|
except json.JSONDecodeError:
|
|
return JsonResponse({'error': 'Invalid JSON'}, status=400)
|
|
except Exception as e:
|
|
logger.error(f"Error in toggle_link_task: {e}", exc_info=True)
|
|
return JsonResponse({'error': str(e)}, status=500)
|
|
|
|
|
|
def _extract_task_text_from_hash(markdown_text, target_hash):
|
|
"""
|
|
Extract the task text that corresponds to a given hash from markdown content.
|
|
|
|
Args:
|
|
markdown_text: The markdown content containing task lists
|
|
target_hash: The hash of the task to find
|
|
|
|
Returns:
|
|
The task text if found, otherwise the hash itself
|
|
"""
|
|
if not markdown_text:
|
|
return target_hash
|
|
|
|
import hashlib
|
|
|
|
# Pattern to match task list items: - [ ] or - [x] followed by text
|
|
task_pattern = re.compile(r'^[\s]*[-*]\s+\[([ xX])\]\s+(.+)$', re.MULTILINE)
|
|
|
|
for match in task_pattern.finditer(markdown_text):
|
|
task_text = match.group(2).strip()
|
|
|
|
# Compute hash the same way as in tasklist_markdown.py
|
|
normalized = ' '.join(task_text.split())
|
|
task_hash = hashlib.md5(normalized.encode('utf-8')).hexdigest()[:12]
|
|
|
|
if task_hash == target_hash:
|
|
return task_text
|
|
|
|
# If not found, return the hash
|
|
return target_hash
|