To be able to save status

This commit is contained in:
2026-01-18 10:28:00 +11:00
parent 443b51d91b
commit 2d4ba7bd4a
21 changed files with 964 additions and 294 deletions
-260
View File
@@ -1,260 +0,0 @@
# Selenium to Playwright Migration
## Summary
Successfully migrated from Selenium to Playwright for web page screenshot capture and processing.
## Changes Made
### 1. Dependencies (`pyproject.toml`)
```diff
- "selenium>=4.0.0",
+ "playwright>=1.40.0",
```
### 2. Code Changes (`links/tasks.py`)
**Before** (Selenium): ~180 lines
- Complex Chrome options setup (15+ arguments)
- Manual scrolling and waiting
- ChromeDriver management
- Complex error handling
**After** (Playwright): ~70 lines
- Simple async function
- Built-in auto-wait
- No driver management
- Clear timeout handling
**Key improvements**:
- ✅ 60% less code
- ✅ Built-in `full_page=True` screenshot
- ✅ Automatic network idle detection
- ✅ Better timeout handling
- ✅ No manual scrolling needed
### 3. Dockerfile Updates
**Removed**:
```dockerfile
chromium
chromium-driver
```
**Added**:
```dockerfile
# Playwright runtime dependencies
libglib2.0-0, libnss3, libnspr4, etc.
# Install Playwright browsers in builder
RUN uv run playwright install chromium --with-deps
# Copy Playwright cache to production
COPY --from=builder /root/.cache/ms-playwright /home/appuser/.cache/ms-playwright
```
**Result**: ~80 MB smaller Docker image
### 4. View Updates (`links/page_views.py`)
**Removed**:
- `take_screenshot()` function (unused)
- Selenium imports
**Kept**:
- All other functionality unchanged
- Still uses threading for async execution
## New Screenshot Function
```python
async def _capture_screenshot_async(page_url, full_path):
"""Async function to capture screenshot using Playwright"""
async with async_playwright() as p:
browser = await p.chromium.launch(
headless=True,
args=['--no-sandbox', '--disable-setuid-sandbox']
)
context = await browser.new_context(
viewport={'width': 1366, 'height': 768},
locale='zh-CN',
ignore_https_errors=True,
)
page = await context.new_page()
# Navigate and wait for network idle
await page.goto(page_url, wait_until='networkidle', timeout=60000)
# Take full-page screenshot
await page.screenshot(path=full_path, full_page=True)
await context.close()
await browser.close()
```
## Benefits
### Performance
-**40-50% faster** screenshot capture
-**80% fewer timeouts** (auto-wait)
-**25% less memory** usage
### Code Quality
- 📉 **60% less code** (180 → 70 lines)
-**Simpler maintenance**
-**Better error messages**
-**Built-in retry logic**
### Infrastructure
- 📦 **~80 MB smaller** Docker image
- **No driver version matching** needed
- 🚀 **Easier deployment**
### Reliability
-**Network idle detection** (knows when page is loaded)
-**Auto-wait for elements**
-**Better handling of modern SPAs**
-**Clear timeout errors**
## Migration Steps Completed
1. ✅ Updated `pyproject.toml` dependencies
2. ✅ Rewrote `capture_screenshot()` function
3. ✅ Added `_capture_screenshot_async()` helper
4. ✅ Updated Dockerfile to install Playwright
5. ✅ Removed Chromium/ChromeDriver from Dockerfile
6. ✅ Added Playwright runtime dependencies
7. ✅ Removed Selenium imports from `page_views.py`
8. ✅ Removed unused `take_screenshot()` function
## Testing
### Install Dependencies
```bash
source .venv/bin/activate
uv sync
uv run playwright install chromium
```
### Test Locally
```bash
python manage.py runserver
# Navigate to a page and trigger screenshot
```
### Docker Build
```bash
DOCKER_BUILDKIT=1 docker build -t links:playwright .
```
### Expected Results
- ✅ Faster screenshot capture (8-12s vs 15-20s)
- ✅ Fewer timeout errors
- ✅ Cleaner error messages
- ✅ Same visual quality
## Compatibility
### APScheduler
- ✅ Works perfectly with APScheduler
- ✅ Uses `asyncio.run()` to run async code in sync context
- ✅ Threading still works as before
### Django
- ✅ No Django changes needed
- ✅ Models unchanged
- ✅ Views unchanged
- ✅ API unchanged
### Storage
- ✅ R2/S3 storage still works
- ✅ Local storage still works
- ✅ Screenshot format unchanged (PNG)
## Configuration
### Viewport Size
Default: 1366x768 (configurable in code)
```python
viewport={'width': 1366, 'height': 768}
```
### Timeout
Default: 60 seconds
```python
timeout=60000 # milliseconds
```
### Locale
Default: zh-CN (Chinese)
```python
locale='zh-CN'
```
### Wait Strategy
Default: networkidle (waits for network requests to finish)
```python
wait_until='networkidle'
```
## Troubleshooting
### Playwright not found
```bash
uv run playwright install chromium
```
### Missing dependencies in Docker
Already included in Dockerfile:
- libglib2.0-0
- libnss3
- libnspr4
- etc.
### Timeout issues
Increase timeout in code:
```python
await page.goto(url, timeout=120000) # 2 minutes
```
### Screenshot quality
Adjust viewport or use different options:
```python
await page.screenshot(path=path, full_page=True, quality=90)
```
## Rollback Plan
If needed to rollback:
1. Revert `pyproject.toml` (restore Selenium)
2. Revert `links/tasks.py` to Selenium version
3. Revert Dockerfile changes
4. Run `uv sync`
## Future Enhancements
Possible with Playwright (not implemented yet):
- [ ] PDF generation
- [ ] Video recording
- [ ] Network request interception
- [ ] Mobile device emulation
- [ ] Geolocation spoofing
- [ ] Custom JavaScript injection
- [ ] HAR file generation
## Resources
- Playwright Docs: https://playwright.dev/python/
- Playwright API: https://playwright.dev/python/docs/api/class-page
- Migration Guide: https://playwright.dev/python/docs/selenium
## Notes
- Playwright uses its own bundled Chromium
- No need to manage ChromeDriver versions
- Auto-updates with `playwright install`
- Works offline after initial install
- Supports Firefox and WebKit too (if needed)
BIN
View File
Binary file not shown.
@@ -0,0 +1,28 @@
# Generated by Django 5.2.9 on 2026-01-17 22:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0035_remove_webpage_content'),
]
operations = [
migrations.AddField(
model_name='link',
name='task_states',
field=models.JSONField(blank=True, default=dict, help_text='Task checkbox states as {task_hash: bool} mapping'),
),
migrations.AddField(
model_name='page',
name='task_states',
field=models.JSONField(blank=True, default=dict, help_text='Task checkbox states as {task_hash: bool} mapping'),
),
migrations.AddField(
model_name='post',
name='task_states',
field=models.JSONField(blank=True, default=dict, help_text='Task checkbox states as {task_hash: bool} mapping'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.9 on 2026-01-17 22:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0036_link_task_states_page_task_states_post_task_states'),
]
operations = [
migrations.AddField(
model_name='linkchangelog',
name='change_type',
field=models.CharField(choices=[('url_change', 'URL Change'), ('task_toggle', 'Task Toggle'), ('content_edit', 'Content Edit')], default='url_change', max_length=20, verbose_name='Change Type'),
),
migrations.AddField(
model_name='linkchangelog',
name='metadata',
field=models.JSONField(blank=True, default=dict, help_text='Additional change data as JSON', verbose_name='Metadata'),
),
migrations.AlterField(
model_name='linkchangelog',
name='new_url',
field=models.URLField(blank=True, max_length=2000, null=True, verbose_name='New URL'),
),
migrations.AlterField(
model_name='linkchangelog',
name='old_url',
field=models.URLField(blank=True, max_length=2000, null=True, verbose_name='Old URL'),
),
]
+26 -2
View File
@@ -33,6 +33,7 @@ class Link(models.Model):
updated_at = models.DateTimeField(auto_now=True)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
tags = models.ManyToManyField('Tag', blank=True, related_name='links')
task_states = models.JSONField(default=dict, blank=True, help_text=_("Task checkbox states as {task_hash: bool} mapping"))
def get_template_parameters(self):
"""Extract template parameters and their default values from original_url"""
@@ -124,16 +125,37 @@ class ClickLog(models.Model):
ordering = ['-clicked_at']
class LinkChangeLog(models.Model):
class ChangeType(models.TextChoices):
URL_CHANGE = 'url_change', _('URL Change')
TASK_TOGGLE = 'task_toggle', _('Task Toggle')
CONTENT_EDIT = 'content_edit', _('Content Edit')
link = models.ForeignKey(Link, on_delete=models.CASCADE, related_name='change_logs')
old_url = models.URLField(_("Old URL"), max_length=2000)
new_url = models.URLField(_("New URL"), max_length=2000)
change_type = models.CharField(
_("Change Type"),
max_length=20,
choices=ChangeType.choices,
default=ChangeType.URL_CHANGE
)
# For URL changes
old_url = models.URLField(_("Old URL"), max_length=2000, blank=True, null=True)
new_url = models.URLField(_("New URL"), max_length=2000, blank=True, null=True)
# For task toggles and other changes
metadata = models.JSONField(_("Metadata"), default=dict, blank=True, help_text=_("Additional change data as JSON"))
changed_at = models.DateTimeField(_("Changed at"), auto_now_add=True)
class Meta:
ordering = ['-changed_at']
def __str__(self):
if self.change_type == self.ChangeType.URL_CHANGE:
return f"URL changed from {self.old_url} to {self.new_url}"
elif self.change_type == self.ChangeType.TASK_TOGGLE:
task_hash = self.metadata.get('task_hash', 'unknown')
new_state = self.metadata.get('new_state', 'unknown')
return f"Task {task_hash[:8]} toggled to {new_state}"
else:
return f"{self.get_change_type_display()} at {self.changed_at}"
def generate_random_color():
colors = [
@@ -188,6 +210,7 @@ class Page(models.Model):
)
retry_count = models.IntegerField(default=0)
last_retry_at = models.DateTimeField(null=True, blank=True)
task_states = models.JSONField(default=dict, blank=True, help_text=_("Task checkbox states as {task_hash: bool} mapping"))
error_message = models.TextField(blank=True)
# New field for screenshot
@@ -251,6 +274,7 @@ class Post(models.Model):
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
tags = models.ManyToManyField('Tag', blank=True, related_name='posts')
task_states = models.JSONField(default=dict, blank=True, help_text=_("Task checkbox states as {task_hash: bool} mapping"))
class Meta:
ordering = ['-created_at']
+44
View File
@@ -7,7 +7,9 @@ from django.utils.translation import gettext_lazy as _
from django.core.files.base import ContentFile
from django.conf import settings
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .templatetags.tasklist_markdown import update_task_in_markdown
from rest_framework.pagination import PageNumberPagination
from .models import Page, Screenshot, Tag
from .forms import PageForm
@@ -101,6 +103,14 @@ class PageUpdateView(UpdateView):
template_name = 'links/page_form.html'
success_url = reverse_lazy('page-list')
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 form_valid(self, form):
response = super().form_valid(form)
@@ -288,6 +298,40 @@ class PageViewSet(viewsets.ModelViewSet):
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['post'])
def toggle_task(self, request, pk=None):
"""
Toggle the checked state of a task in a page.
POST /api/pages/{id}/toggle_task/
Body: {"task_hash": "abc123"}
"""
page = self.get_object()
task_hash = request.data.get('task_hash')
if not task_hash:
return Response(
{'error': 'task_hash is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get current task states or initialize empty dict
task_states = page.task_states if page.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
# Save only task_states, leave markdown content unchanged
page.task_states = task_states
page.save(update_fields=['task_states', 'updated_at'])
return Response({
'task_hash': task_hash,
'checked': new_state
})
class ScreenshotGalleryView(TemplateView):
template_name = 'links/screenshot_gallery.html'
+44
View File
@@ -3,11 +3,13 @@ from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.http import Http404
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from django.utils import timezone
from datetime import datetime
from .models import Post
from .templatetags.tasklist_markdown import update_task_in_markdown
from .forms import PostForm
from .serializers import PostSerializer
from .templatetags import markdown_extras, think_markdown
@@ -71,6 +73,14 @@ class PostUpdateView(UpdateView):
template_name = 'links/post_form.html'
success_url = reverse_lazy('post-list')
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
class PostDeleteView(DeleteView):
model = Post
template_name = 'links/post_confirm_delete.html'
@@ -120,3 +130,37 @@ class PostViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def toggle_task(self, request, pk=None):
"""
Toggle the checked state of a task in a post.
POST /api/posts/{id}/toggle_task/
Body: {"task_hash": "abc123"}
"""
post = self.get_object()
task_hash = request.data.get('task_hash')
if not task_hash:
return Response(
{'error': 'task_hash is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Get current task states or initialize empty dict
task_states = post.task_states if post.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
# Save only task_states, leave markdown content unchanged
post.task_states = task_states
post.save(update_fields=['task_states', 'content', 'updated_at'])
return Response({
'task_hash': task_hash,
'checked': new_state
})
+56
View File
@@ -8,6 +8,25 @@
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss-typography/0.4.0/typography.min.css" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/markdown.css' %}">
<style>
/* Task List Styles */
.task-item {
list-style: none;
padding-left: 0;
}
.task-checkbox {
margin-right: 0.5rem;
cursor: pointer;
width: 1.1em;
height: 1.1em;
vertical-align: middle;
}
.task-checkbox:hover {
opacity: 0.7;
}
</style>
</head>
<body class="bg-gray-100">
<div class="max-w-6xl mx-auto mt-10 p-4 sm:px-6 bg-white shadow-md rounded-lg">
@@ -15,5 +34,42 @@
{{ rendered_text|safe }}
</div>
</div>
<script>
// Task checkbox functionality
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.task-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', async function(e) {
const taskHash = this.getAttribute('data-hash');
const linkId = {{ link.id }};
console.log('Toggling task:', taskHash, 'for link:', linkId);
try {
const response = await fetch(`/link/${linkId}/toggle_task/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task_hash: taskHash })
});
const data = await response.json();
console.log('Response:', response.status, data);
if (!response.ok) {
this.checked = !this.checked;
console.error('Failed to update task state:', data);
} else {
console.log('Task updated successfully:', data);
}
} catch (error) {
this.checked = !this.checked;
console.error('Error updating task:', error);
}
});
});
});
</script>
</body>
</html>
+117 -7
View File
@@ -132,23 +132,89 @@
<div class="bg-white shadow-md rounded-lg overflow-hidden mt-8">
<div class="px-2 py-5 sm:px-6 bg-gray-50 border-b border-gray-200">
<h2 class="text-xl font-bold text-gray-900">{% trans "URL Change History" %}</h2>
<h2 class="text-xl font-bold text-gray-900">{% trans "Change History" %}</h2>
</div>
<div class="px-2 py-5 sm:p-6">
{% if change_logs %}
<ul class="divide-y divide-gray-200">
{% for log in change_logs %}
<li class="py-2">
<p class="text-sm text-gray-600">
{% trans "URL changed at" %} {{ log.changed_at|date:"Y-m-d H:i" }}
<li class="py-3">
{% if log.change_type == 'url_change' %}
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<p class="text-sm font-medium text-gray-900">
{% trans "URL Changed" %}
</p>
<p class="mt-1 text-sm text-red-600 line-through">{{ log.old_url }}</p>
<p class="mt-1 text-sm text-green-600">{{ log.new_url }}</p>
<span class="ml-2 text-xs text-gray-500">{{ log.changed_at|date:"Y-m-d H:i" }}</span>
</div>
{% if log.old_url %}
<p class="mt-1 text-sm text-red-600 line-through ml-7">{{ log.old_url }}</p>
{% endif %}
{% if log.new_url %}
<p class="mt-1 text-sm text-green-600 ml-7">{{ log.new_url }}</p>
{% endif %}
{% elif log.change_type == 'task_toggle' %}
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-purple-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path>
</svg>
<p class="text-sm font-medium text-gray-900">
{% trans "Task Toggled" %}
</p>
<span class="ml-2 text-xs text-gray-500">{{ log.changed_at|date:"Y-m-d H:i" }}</span>
</div>
{% if log.metadata %}
<div class="ml-7 text-sm">
{% if log.metadata.task_text %}
<p class="text-gray-700 font-medium mb-1">
"{{ log.metadata.task_text }}"
</p>
{% endif %}
<p class="text-gray-600">
<span class="{% if log.metadata.old_state %}text-green-600{% else %}text-gray-400{% endif %}">
{% if log.metadata.old_state %}☑{% else %}☐{% endif %}
</span>
<span class="{% if log.metadata.new_state %}text-green-600{% else %}text-gray-400{% endif %}">
{% if log.metadata.new_state %}☑{% else %}☐{% endif %}
</span>
<span class="ml-1">{% if log.metadata.new_state %}{% trans "checked" %}{% else %}{% trans "unchecked" %}{% endif %}</span>
</p>
</div>
{% endif %}
{% elif log.change_type == 'content_edit' %}
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-green-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
<p class="text-sm font-medium text-gray-900">
{% trans "Content Edited" %}
</p>
<span class="ml-2 text-xs text-gray-500">{{ log.changed_at|date:"Y-m-d H:i" }}</span>
</div>
{% if log.metadata %}
<div class="ml-7 text-sm text-gray-600">
{{ log.metadata|safe }}
</div>
{% endif %}
{% else %}
<div class="flex items-center mb-2">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-sm font-medium text-gray-900">
{% trans "Change" %}
</p>
<span class="ml-2 text-xs text-gray-500">{{ log.changed_at|date:"Y-m-d H:i" }}</span>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-sm text-gray-600">{% trans "No URL changes recorded." %}</p>
<p class="text-sm text-gray-600">{% trans "No changes recorded." %}</p>
{% endif %}
</div>
</div>
@@ -278,6 +344,32 @@
url.searchParams.set('period', period);
window.location.href = url.toString();
}
// Task checkbox functionality
document.querySelectorAll('.task-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', async function(e) {
const taskHash = this.getAttribute('data-hash');
const linkId = {{ link.id }};
try {
const response = await fetch(`/link/${linkId}/toggle_task/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task_hash: taskHash })
});
if (!response.ok) {
this.checked = !this.checked;
console.error('Failed to update task state');
}
} catch (error) {
this.checked = !this.checked;
console.error('Error updating task:', error);
}
});
});
</script>
{% endblock %}
@@ -384,5 +476,23 @@
background-color: #5b21b6;
border-color: #5b21b6;
}
/* Task List Styles */
.task-item {
list-style: none;
padding-left: 0;
}
.task-checkbox {
margin-right: 0.5rem;
cursor: pointer;
width: 1.1em;
height: 1.1em;
vertical-align: middle;
}
.task-checkbox:hover {
opacity: 0.7;
}
</style>
{% endblock %}
+48 -1
View File
@@ -226,7 +226,54 @@
if (!simplemde) {
var textArea = document.getElementById('id_text');
if (textArea) {
simplemde = new SimpleMDE({ element: textArea });
// Clear any SimpleMDE autosave cache to ensure we use the latest DB content
// This is important after task list updates
var linkId = '{{ form.instance.pk|default:"new" }}';
var cacheKey = 'smde_link_text_' + linkId;
localStorage.removeItem(cacheKey);
simplemde = new SimpleMDE({
element: textArea,
toolbar: [
"bold", "italic", "heading", "|",
"quote", "unordered-list", "ordered-list", "|",
{
name: "task-list",
action: function customTaskList(editor) {
var cm = editor.codemirror;
var selection = cm.getSelection();
var lines = selection.split('\n');
if (lines.length > 0 && lines[0].match(/^- \[[ xX]\] /)) {
// Remove task list formatting
var newText = lines.map(function(line) {
return line.replace(/^- \[[ xX]\] /, '- ');
}).join('\n');
cm.replaceSelection(newText);
} else if (selection) {
// Add task list formatting to selection
var newText = lines.map(function(line) {
if (line.trim()) {
return '- [ ] ' + line.replace(/^[-*]\s*/, '');
}
return line;
}).join('\n');
cm.replaceSelection(newText);
} else {
// Insert a single task list item
cm.replaceSelection('- [ ] ');
}
cm.focus();
},
className: "fa fa-check-square",
title: "Task List (- [ ] item)",
},
"|",
"link", "image", "table", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
]
});
}
}
}
+56 -1
View File
@@ -202,7 +202,30 @@
<!-- Select2 JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
// Initialize SimpleMDE
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
// This is especially important after task list checkbox toggles
var pageId = '{{ form.instance.pk|default:"new" }}';
var cacheKey = 'smde_page_content_' + pageId;
if (pageId !== 'new') {
// Check if the cached timestamp is older than the updated_at timestamp
var cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
try {
var parsedCache = JSON.parse(cachedData);
var updatedAt = new Date('{{ form.instance.updated_at.isoformat }}').getTime();
var cacheTime = parsedCache.ts || 0;
// If cache is older than last update, clear it
if (cacheTime < updatedAt) {
localStorage.removeItem(cacheKey);
}
} catch (e) {
// If parsing fails, clear the cache
localStorage.removeItem(cacheKey);
}
}
}
// Initialize SimpleMDE with custom task list button
var simplemde = new SimpleMDE({
element: document.getElementById("{{ form.content.id_for_label }}"),
spellChecker: false,
@@ -213,6 +236,38 @@ var simplemde = new SimpleMDE({
toolbar: [
"bold", "italic", "heading", "|",
"quote", "unordered-list", "ordered-list", "|",
{
name: "task-list",
action: function customTaskList(editor) {
var cm = editor.codemirror;
var selection = cm.getSelection();
var lines = selection.split('\n');
if (lines.length > 0 && lines[0].match(/^- \[[ xX]\] /)) {
// Remove task list formatting
var newText = lines.map(function(line) {
return line.replace(/^- \[[ xX]\] /, '- ');
}).join('\n');
cm.replaceSelection(newText);
} else if (selection) {
// Add task list formatting to selection
var newText = lines.map(function(line) {
if (line.trim()) {
return '- [ ] ' + line.replace(/^[-*]\s*/, '');
}
return line;
}).join('\n');
cm.replaceSelection(newText);
} else {
// Insert a single task list item
cm.replaceSelection('- [ ] ');
}
cm.focus();
},
className: "fa fa-check-square",
title: "Task List (- [ ] item)",
},
"|",
"link", "image", "table", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
+47 -1
View File
@@ -78,6 +78,24 @@
transform: rotate(180deg);
}
/* Task List Styles */
.task-item {
list-style: none;
padding-left: 0;
}
.task-checkbox {
margin-right: 0.5rem;
cursor: pointer;
width: 1.1em;
height: 1.1em;
vertical-align: middle;
}
.task-checkbox:hover {
opacity: 0.7;
}
/* Enhanced Audio Player Styles */
.audio-player {
background: linear-gradient(145deg, #f8fafc 0%, #e2e8f0 100%);
@@ -521,7 +539,7 @@
<!-- Content Section - Full width, no containers -->
<article class="prose max-w-none prose-lg">
{{ post.content|markdown|safe }}
{% markdown_with_tasks post.content post.task_states %}
</article>
</div>
{% endblock %}
@@ -565,6 +583,34 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
// Task checkbox functionality
document.querySelectorAll('.task-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', async function(e) {
const taskHash = this.getAttribute('data-hash');
const postId = {{ post.id }};
try {
const response = await fetch(`/api/posts/${postId}/toggle_task/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task_hash: taskHash })
});
if (!response.ok) {
// Revert checkbox if request failed
this.checked = !this.checked;
console.error('Failed to update task state');
}
} catch (error) {
// Revert checkbox if request failed
this.checked = !this.checked;
console.error('Error updating task:', error);
}
});
});
// Initialize voice selection event listeners
const customVoiceInput = document.getElementById('post-custom-voice-input');
const regenerateBtn = document.getElementById('regenerate-btn');
+56 -1
View File
@@ -136,7 +136,30 @@
<!-- Select2 JavaScript -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
// Initialize SimpleMDE
// Clear SimpleMDE autosave cache to prevent old cached content from overriding DB updates
// This is especially important after task list checkbox toggles
var postId = '{{ form.instance.pk|default:"new" }}';
var cacheKey = 'smde_post_content_' + postId;
if (postId !== 'new') {
// Check if the cached timestamp is older than the updated_at timestamp
var cachedData = localStorage.getItem(cacheKey);
if (cachedData) {
try {
var parsedCache = JSON.parse(cachedData);
var updatedAt = new Date('{{ form.instance.updated_at.isoformat }}').getTime();
var cacheTime = parsedCache.ts || 0;
// If cache is older than last update, clear it
if (cacheTime < updatedAt) {
localStorage.removeItem(cacheKey);
}
} catch (e) {
// If parsing fails, clear the cache
localStorage.removeItem(cacheKey);
}
}
}
// Initialize SimpleMDE with custom task list button
var simplemde = new SimpleMDE({
element: document.getElementById("{{ form.content.id_for_label }}"),
spellChecker: false,
@@ -147,6 +170,38 @@ var simplemde = new SimpleMDE({
toolbar: [
"bold", "italic", "heading", "|",
"quote", "unordered-list", "ordered-list", "|",
{
name: "task-list",
action: function customTaskList(editor) {
var cm = editor.codemirror;
var selection = cm.getSelection();
var lines = selection.split('\n');
if (lines.length > 0 && lines[0].match(/^- \[[ xX]\] /)) {
// Remove task list formatting
var newText = lines.map(function(line) {
return line.replace(/^- \[[ xX]\] /, '- ');
}).join('\n');
cm.replaceSelection(newText);
} else if (selection) {
// Add task list formatting to selection
var newText = lines.map(function(line) {
if (line.trim()) {
return '- [ ] ' + line.replace(/^[-*]\s*/, '');
}
return line;
}).join('\n');
cm.replaceSelection(newText);
} else {
// Insert a single task list item
cm.replaceSelection('- [ ] ');
}
cm.focus();
},
className: "fa fa-check-square",
title: "Task List (- [ ] item)",
},
"|",
"link", "image", "table", "|",
"preview", "side-by-side", "fullscreen", "|",
"guide"
+49 -1
View File
@@ -53,6 +53,28 @@
.think-icon.expanded {
transform: rotate(180deg);
}
/* Task List Styles */
.task-item {
list-style: none;
padding-left: 0;
}
.task-checkbox {
margin-right: 0.5rem;
cursor: pointer;
width: 1.1em;
height: 1.1em;
vertical-align: middle;
}
.task-checkbox:hover {
opacity: 0.7;
}
.think-icon.expanded {
transform: rotate(180deg);
}
</style>
</head>
<body class="bg-gray-100">
@@ -80,7 +102,7 @@
<!-- Post Content -->
<div class="prose">
{{ post.content|markdown|safe }}
{% markdown_with_tasks post.content post.task_states %}
</div>
</div>
@@ -97,6 +119,32 @@
icon.classList.toggle('expanded');
});
});
// Task checkbox functionality
document.querySelectorAll('.task-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', async function(e) {
const taskHash = this.getAttribute('data-hash');
const postId = {{ post.id }};
try {
const response = await fetch(`/api/posts/${postId}/toggle_task/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ task_hash: taskHash })
});
if (!response.ok) {
this.checked = !this.checked;
console.error('Failed to update task state');
}
} catch (error) {
this.checked = !this.checked;
console.error('Error updating task:', error);
}
});
});
});
</script>
</body>
+28
View File
@@ -2,13 +2,41 @@ from django import template
from django.template.defaultfilters import stringfilter
import markdown as md
from . import think_markdown
from . import tasklist_markdown
register = template.Library()
@register.filter()
@stringfilter
def markdown(value):
"""
Basic markdown filter without task list support.
Use markdown_with_tasks template tag for content with task states.
"""
return md.markdown(value, extensions=['markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
think_markdown.ThinkExtension()])
@register.simple_tag
def markdown_with_tasks(content, task_states=None):
"""
Render markdown with interactive task list support.
Usage in template:
{% markdown_with_tasks post.content post.task_states %}
"""
if task_states is None:
task_states = {}
return md.markdown(
content,
extensions=[
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
think_markdown.ThinkExtension(),
tasklist_markdown.TaskListExtension(task_states=task_states)
]
)
+188
View File
@@ -0,0 +1,188 @@
"""
Custom Markdown extension for interactive task lists.
Converts - [ ] and - [x] syntax to interactive checkboxes with data-hash attributes.
"""
import re
import hashlib
from markdown.extensions import Extension
from markdown.treeprocessors import Treeprocessor
from xml.etree import ElementTree as etree
def compute_task_hash(task_text):
"""
Compute a stable hash for a task based on its text content.
This allows tracking task state even if content is reordered.
"""
# Normalize whitespace and strip for consistent hashing
normalized = ' '.join(task_text.strip().split())
return hashlib.md5(normalized.encode('utf-8')).hexdigest()[:12]
class TaskListTreeprocessor(Treeprocessor):
"""
Post-process the markdown tree to convert task list items into interactive checkboxes.
Works with the standard markdown list processing.
"""
TASK_PATTERN = re.compile(r'^\[([ xX])\]\s+(.*)$')
def __init__(self, md, task_states=None):
super().__init__(md)
self.task_states = task_states or {}
def run(self, root):
"""Process all list items in the tree."""
self._process_element(root)
return root
def _process_element(self, element):
"""Recursively process elements looking for list items with task syntax."""
for child in list(element):
if child.tag == 'li':
self._process_list_item(child)
self._process_element(child)
def _process_list_item(self, li_element):
"""
Convert list items with [ ] or [x] syntax to interactive checkboxes.
Original: <li>[ ] Task text</li>
Result: <li class="task-item"><input type="checkbox" data-hash="abc123"/> Task text</li>
"""
# Get the text content of the first element (usually a paragraph or direct text)
if len(li_element) == 0:
text = li_element.text or ''
else:
# Handle case where text is in a paragraph
first_child = li_element[0]
if first_child.tag == 'p':
text = first_child.text or ''
else:
text = li_element.text or ''
# Check if this is a task list item
match = self.TASK_PATTERN.match(text.strip())
if not match:
return
# Extract checkbox state and task text
checkbox_char = match.group(1)
task_text = match.group(2)
# Compute hash for this task
task_hash = compute_task_hash(task_text)
# Check if we have stored state for this task (overrides markdown)
if task_hash in self.task_states:
is_checked = self.task_states[task_hash]
else:
# Use the state from markdown
is_checked = checkbox_char.lower() == 'x'
# Add CSS class to list item
li_class = li_element.get('class', '')
li_element.set('class', f'{li_class} task-item'.strip())
# Create checkbox element
checkbox = etree.Element('input')
checkbox.set('type', 'checkbox')
checkbox.set('class', 'task-checkbox')
checkbox.set('data-hash', task_hash)
if is_checked:
checkbox.set('checked', 'checked')
# Clear the list item and rebuild with checkbox
if len(li_element) > 0 and li_element[0].tag == 'p':
# Remove the paragraph and replace with checkbox + text
p_elem = li_element[0]
li_element.remove(p_elem)
# Insert checkbox
li_element.insert(0, checkbox)
# Add the task text
if li_element.text:
li_element.text = ' ' + task_text
else:
checkbox.tail = ' ' + task_text
# Preserve any remaining content from the paragraph
if p_elem.tail:
if len(li_element) > 0:
last = li_element[-1]
last.tail = (last.tail or '') + p_elem.tail
else:
checkbox.tail = (checkbox.tail or '') + p_elem.tail
# Add back any children from the paragraph (like links, emphasis, etc.)
for child in p_elem:
li_element.append(child)
else:
# Direct text in list item
li_element.text = ''
li_element.insert(0, checkbox)
checkbox.tail = ' ' + task_text
class TaskListExtension(Extension):
"""
Markdown extension for interactive task lists.
Usage:
md = markdown.Markdown(extensions=[TaskListExtension(task_states={...})])
Config:
task_states: dict mapping task hashes to boolean checked state
"""
def __init__(self, **kwargs):
self.config = {
'task_states': [{}, 'Dictionary of task states - Default: {}']
}
super().__init__(**kwargs)
def extendMarkdown(self, md):
"""Register the tree processor."""
task_states = self.getConfig('task_states')
processor = TaskListTreeprocessor(md, task_states)
md.treeprocessors.register(processor, 'tasklist', 15)
def makeExtension(**kwargs):
"""Return an instance of the extension."""
return TaskListExtension(**kwargs)
def update_task_in_markdown(markdown_content, task_hash, checked):
"""
Update a specific task's checkbox state in markdown content.
Args:
markdown_content: The markdown text containing task lists
task_hash: The hash of the task to update
checked: Boolean, True for [x], False for [ ]
Returns:
Updated markdown content with the task's checkbox state changed
"""
if not markdown_content:
return markdown_content
lines = markdown_content.split('\n')
task_pattern = re.compile(r'^(\s*[-*]\s+)\[([ xX])\]\s+(.*)$')
for i, line in enumerate(lines):
match = task_pattern.match(line)
if match:
prefix = match.group(1) # "- " or "* " with any leading whitespace
task_text = match.group(3)
# Check if this is the task we're looking for
if compute_task_hash(task_text) == task_hash:
# Update the checkbox state
new_checkbox = 'x' if checked else ' '
lines[i] = f'{prefix}[{new_checkbox}] {task_text}'
break
return '\n'.join(lines)
+3
View File
@@ -33,6 +33,9 @@ urlpatterns = [
path('fetch-page-info/', page_views.fetch_page_info, name='fetch-page-info'),
path('ui/screenshots/', page_views.ScreenshotGalleryView.as_view(), name='screenshot-gallery'),
# Link task toggle API
path('link/<int:pk>/toggle_task/', views.toggle_link_task, name='link-toggle-task'),
# Posts
path('ui/posts/', post_views.PostListView.as_view(), name='post-list'),
path('ui/posts/new/', post_views.PostCreateView.as_view(), name='post-create'),
+128 -3
View File
@@ -18,6 +18,7 @@ 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
@@ -150,6 +151,14 @@ class LinkUpdateView(UpdateView):
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')
@@ -385,7 +394,17 @@ class LinkDetailView(DetailView):
# Convert markdown to HTML if the link is a custom type
if self.object.link_type == Link.LinkType.CUSTOM:
context['rendered_text'] = markdown.markdown(self.object.text)
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')
@@ -473,8 +492,18 @@ class CustomLinkView(DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Render markdown text as HTML
context['rendered_text'] = markdown.markdown(self.object.text)
# 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):
@@ -625,3 +654,99 @@ def clean_text_for_tts(content):
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
-4
View File
@@ -1604,10 +1604,6 @@ msgstr "文本"
msgid "Click Statistics"
msgstr "点击统计"
#: links/templates/links/link_detail.html:84
msgid "URL Change History"
msgstr ""
#: links/templates/links/link_detail.html:92
msgid "URL changed at"
msgstr ""
+1 -1
View File
@@ -2,4 +2,4 @@
set -eu
source .venv/bin/activate
echo "Starting Django development server..."
python manage.py runserver 0.0.0.0:8000
uv run manage.py runserver 0.0.0.0:8000
+1 -1
View File
@@ -3,4 +3,4 @@ set -eu
source .venv/bin/activate
echo "Starting Tailwind CSS compiler..."
python manage.py tailwind start
uv run manage.py tailwind start