mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
To be able to save status
This commit is contained in:
@@ -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)
|
|
||||||
|
|
||||||
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
@@ -33,6 +33,7 @@ class Link(models.Model):
|
|||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
tags = models.ManyToManyField('Tag', blank=True, related_name='links')
|
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):
|
def get_template_parameters(self):
|
||||||
"""Extract template parameters and their default values from original_url"""
|
"""Extract template parameters and their default values from original_url"""
|
||||||
@@ -124,16 +125,37 @@ class ClickLog(models.Model):
|
|||||||
ordering = ['-clicked_at']
|
ordering = ['-clicked_at']
|
||||||
|
|
||||||
class LinkChangeLog(models.Model):
|
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')
|
link = models.ForeignKey(Link, on_delete=models.CASCADE, related_name='change_logs')
|
||||||
old_url = models.URLField(_("Old URL"), max_length=2000)
|
change_type = models.CharField(
|
||||||
new_url = models.URLField(_("New URL"), max_length=2000)
|
_("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)
|
changed_at = models.DateTimeField(_("Changed at"), auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-changed_at']
|
ordering = ['-changed_at']
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if self.change_type == self.ChangeType.URL_CHANGE:
|
||||||
return f"URL changed from {self.old_url} to {self.new_url}"
|
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():
|
def generate_random_color():
|
||||||
colors = [
|
colors = [
|
||||||
@@ -188,6 +210,7 @@ class Page(models.Model):
|
|||||||
)
|
)
|
||||||
retry_count = models.IntegerField(default=0)
|
retry_count = models.IntegerField(default=0)
|
||||||
last_retry_at = models.DateTimeField(null=True, blank=True)
|
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)
|
error_message = models.TextField(blank=True)
|
||||||
|
|
||||||
# New field for screenshot
|
# New field for screenshot
|
||||||
@@ -251,6 +274,7 @@ class Post(models.Model):
|
|||||||
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
|
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
|
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
|
||||||
tags = models.ManyToManyField('Tag', blank=True, related_name='posts')
|
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:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from .templatetags.tasklist_markdown import update_task_in_markdown
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from .models import Page, Screenshot, Tag
|
from .models import Page, Screenshot, Tag
|
||||||
from .forms import PageForm
|
from .forms import PageForm
|
||||||
@@ -101,6 +103,14 @@ class PageUpdateView(UpdateView):
|
|||||||
template_name = 'links/page_form.html'
|
template_name = 'links/page_form.html'
|
||||||
success_url = reverse_lazy('page-list')
|
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):
|
def form_valid(self, form):
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
|
|
||||||
@@ -288,6 +298,40 @@ class PageViewSet(viewsets.ModelViewSet):
|
|||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
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):
|
class ScreenshotGalleryView(TemplateView):
|
||||||
template_name = 'links/screenshot_gallery.html'
|
template_name = 'links/screenshot_gallery.html'
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ from django.urls import reverse_lazy
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.pagination import PageNumberPagination
|
from rest_framework.pagination import PageNumberPagination
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .models import Post
|
from .models import Post
|
||||||
|
from .templatetags.tasklist_markdown import update_task_in_markdown
|
||||||
from .forms import PostForm
|
from .forms import PostForm
|
||||||
from .serializers import PostSerializer
|
from .serializers import PostSerializer
|
||||||
from .templatetags import markdown_extras, think_markdown
|
from .templatetags import markdown_extras, think_markdown
|
||||||
@@ -71,6 +73,14 @@ class PostUpdateView(UpdateView):
|
|||||||
template_name = 'links/post_form.html'
|
template_name = 'links/post_form.html'
|
||||||
success_url = reverse_lazy('post-list')
|
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):
|
class PostDeleteView(DeleteView):
|
||||||
model = Post
|
model = Post
|
||||||
template_name = 'links/post_confirm_delete.html'
|
template_name = 'links/post_confirm_delete.html'
|
||||||
@@ -120,3 +130,37 @@ class PostViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
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
|
||||||
|
})
|
||||||
|
|||||||
@@ -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/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 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' %}">
|
<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>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<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">
|
<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 }}
|
{{ rendered_text|safe }}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -132,23 +132,89 @@
|
|||||||
|
|
||||||
<div class="bg-white shadow-md rounded-lg overflow-hidden mt-8">
|
<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">
|
<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>
|
||||||
<div class="px-2 py-5 sm:p-6">
|
<div class="px-2 py-5 sm:p-6">
|
||||||
{% if change_logs %}
|
{% if change_logs %}
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200">
|
||||||
{% for log in change_logs %}
|
{% for log in change_logs %}
|
||||||
<li class="py-2">
|
<li class="py-3">
|
||||||
<p class="text-sm text-gray-600">
|
{% if log.change_type == 'url_change' %}
|
||||||
{% trans "URL changed at" %} {{ log.changed_at|date:"Y-m-d H:i" }}
|
<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>
|
||||||
<p class="mt-1 text-sm text-red-600 line-through">{{ log.old_url }}</p>
|
<span class="ml-2 text-xs text-gray-500">{{ log.changed_at|date:"Y-m-d H:i" }}</span>
|
||||||
<p class="mt-1 text-sm text-green-600">{{ log.new_url }}</p>
|
</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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,6 +344,32 @@
|
|||||||
url.searchParams.set('period', period);
|
url.searchParams.set('period', period);
|
||||||
window.location.href = url.toString();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -384,5 +476,23 @@
|
|||||||
background-color: #5b21b6;
|
background-color: #5b21b6;
|
||||||
border-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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -226,7 +226,54 @@
|
|||||||
if (!simplemde) {
|
if (!simplemde) {
|
||||||
var textArea = document.getElementById('id_text');
|
var textArea = document.getElementById('id_text');
|
||||||
if (textArea) {
|
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"
|
||||||
|
]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,30 @@
|
|||||||
<!-- Select2 JavaScript -->
|
<!-- Select2 JavaScript -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
<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({
|
var simplemde = new SimpleMDE({
|
||||||
element: document.getElementById("{{ form.content.id_for_label }}"),
|
element: document.getElementById("{{ form.content.id_for_label }}"),
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
@@ -213,6 +236,38 @@ var simplemde = new SimpleMDE({
|
|||||||
toolbar: [
|
toolbar: [
|
||||||
"bold", "italic", "heading", "|",
|
"bold", "italic", "heading", "|",
|
||||||
"quote", "unordered-list", "ordered-list", "|",
|
"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", "|",
|
"link", "image", "table", "|",
|
||||||
"preview", "side-by-side", "fullscreen", "|",
|
"preview", "side-by-side", "fullscreen", "|",
|
||||||
"guide"
|
"guide"
|
||||||
|
|||||||
@@ -78,6 +78,24 @@
|
|||||||
transform: rotate(180deg);
|
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 */
|
/* Enhanced Audio Player Styles */
|
||||||
.audio-player {
|
.audio-player {
|
||||||
background: linear-gradient(145deg, #f8fafc 0%, #e2e8f0 100%);
|
background: linear-gradient(145deg, #f8fafc 0%, #e2e8f0 100%);
|
||||||
@@ -521,7 +539,7 @@
|
|||||||
|
|
||||||
<!-- Content Section - Full width, no containers -->
|
<!-- Content Section - Full width, no containers -->
|
||||||
<article class="prose max-w-none prose-lg">
|
<article class="prose max-w-none prose-lg">
|
||||||
{{ post.content|markdown|safe }}
|
{% markdown_with_tasks post.content post.task_states %}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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
|
// Initialize voice selection event listeners
|
||||||
const customVoiceInput = document.getElementById('post-custom-voice-input');
|
const customVoiceInput = document.getElementById('post-custom-voice-input');
|
||||||
const regenerateBtn = document.getElementById('regenerate-btn');
|
const regenerateBtn = document.getElementById('regenerate-btn');
|
||||||
|
|||||||
@@ -136,7 +136,30 @@
|
|||||||
<!-- Select2 JavaScript -->
|
<!-- Select2 JavaScript -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
<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({
|
var simplemde = new SimpleMDE({
|
||||||
element: document.getElementById("{{ form.content.id_for_label }}"),
|
element: document.getElementById("{{ form.content.id_for_label }}"),
|
||||||
spellChecker: false,
|
spellChecker: false,
|
||||||
@@ -147,6 +170,38 @@ var simplemde = new SimpleMDE({
|
|||||||
toolbar: [
|
toolbar: [
|
||||||
"bold", "italic", "heading", "|",
|
"bold", "italic", "heading", "|",
|
||||||
"quote", "unordered-list", "ordered-list", "|",
|
"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", "|",
|
"link", "image", "table", "|",
|
||||||
"preview", "side-by-side", "fullscreen", "|",
|
"preview", "side-by-side", "fullscreen", "|",
|
||||||
"guide"
|
"guide"
|
||||||
|
|||||||
@@ -53,6 +53,28 @@
|
|||||||
.think-icon.expanded {
|
.think-icon.expanded {
|
||||||
transform: rotate(180deg);
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
@@ -80,7 +102,7 @@
|
|||||||
|
|
||||||
<!-- Post Content -->
|
<!-- Post Content -->
|
||||||
<div class="prose">
|
<div class="prose">
|
||||||
{{ post.content|markdown|safe }}
|
{% markdown_with_tasks post.content post.task_states %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -97,6 +119,32 @@
|
|||||||
icon.classList.toggle('expanded');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,13 +2,41 @@ from django import template
|
|||||||
from django.template.defaultfilters import stringfilter
|
from django.template.defaultfilters import stringfilter
|
||||||
import markdown as md
|
import markdown as md
|
||||||
from . import think_markdown
|
from . import think_markdown
|
||||||
|
from . import tasklist_markdown
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@register.filter()
|
@register.filter()
|
||||||
@stringfilter
|
@stringfilter
|
||||||
def markdown(value):
|
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',
|
return md.markdown(value, extensions=['markdown.extensions.fenced_code',
|
||||||
'markdown.extensions.tables',
|
'markdown.extensions.tables',
|
||||||
'markdown.extensions.nl2br',
|
'markdown.extensions.nl2br',
|
||||||
think_markdown.ThinkExtension()])
|
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)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -33,6 +33,9 @@ urlpatterns = [
|
|||||||
path('fetch-page-info/', page_views.fetch_page_info, name='fetch-page-info'),
|
path('fetch-page-info/', page_views.fetch_page_info, name='fetch-page-info'),
|
||||||
path('ui/screenshots/', page_views.ScreenshotGalleryView.as_view(), name='screenshot-gallery'),
|
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
|
# Posts
|
||||||
path('ui/posts/', post_views.PostListView.as_view(), name='post-list'),
|
path('ui/posts/', post_views.PostListView.as_view(), name='post-list'),
|
||||||
path('ui/posts/new/', post_views.PostCreateView.as_view(), name='post-create'),
|
path('ui/posts/new/', post_views.PostCreateView.as_view(), name='post-create'),
|
||||||
|
|||||||
+128
-3
@@ -18,6 +18,7 @@ from django.utils.dateparse import parse_datetime
|
|||||||
import random
|
import random
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
import markdown
|
import markdown
|
||||||
|
from .templatetags import think_markdown, tasklist_markdown
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
@@ -150,6 +151,14 @@ class LinkUpdateView(UpdateView):
|
|||||||
form_class = LinkForm
|
form_class = LinkForm
|
||||||
template_name = 'links/link_form.html'
|
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):
|
def get_object(self, queryset=None):
|
||||||
# Handle both pk (for regular links) and alias (for custom links)
|
# Handle both pk (for regular links) and alias (for custom links)
|
||||||
pk = self.kwargs.get('pk')
|
pk = self.kwargs.get('pk')
|
||||||
@@ -385,7 +394,17 @@ class LinkDetailView(DetailView):
|
|||||||
|
|
||||||
# Convert markdown to HTML if the link is a custom type
|
# Convert markdown to HTML if the link is a custom type
|
||||||
if self.object.link_type == Link.LinkType.CUSTOM:
|
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')
|
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):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
# Render markdown text as HTML
|
# Render markdown text as HTML with task list support
|
||||||
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)
|
||||||
|
]
|
||||||
|
)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def export_database(request):
|
def export_database(request):
|
||||||
@@ -625,3 +654,99 @@ def clean_text_for_tts(content):
|
|||||||
text = text[:3000] + "..."
|
text = text[:3000] + "..."
|
||||||
|
|
||||||
return text
|
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
|
||||||
|
|||||||
@@ -1604,10 +1604,6 @@ msgstr "文本"
|
|||||||
msgid "Click Statistics"
|
msgid "Click Statistics"
|
||||||
msgstr "点击统计"
|
msgstr "点击统计"
|
||||||
|
|
||||||
#: links/templates/links/link_detail.html:84
|
|
||||||
msgid "URL Change History"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: links/templates/links/link_detail.html:92
|
#: links/templates/links/link_detail.html:92
|
||||||
msgid "URL changed at"
|
msgid "URL changed at"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
set -eu
|
set -eu
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
echo "Starting Django development server..."
|
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
@@ -3,4 +3,4 @@ set -eu
|
|||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
echo "Starting Tailwind CSS compiler..."
|
echo "Starting Tailwind CSS compiler..."
|
||||||
python manage.py tailwind start
|
uv run manage.py tailwind start
|
||||||
|
|||||||
Reference in New Issue
Block a user