diff --git a/SELENIUM_TO_PLAYWRIGHT_MIGRATION.md b/SELENIUM_TO_PLAYWRIGHT_MIGRATION.md deleted file mode 100644 index 3fc604b..0000000 --- a/SELENIUM_TO_PLAYWRIGHT_MIGRATION.md +++ /dev/null @@ -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) - diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 3b3aae4..1e57492 100644 Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ diff --git a/links/migrations/0036_link_task_states_page_task_states_post_task_states.py b/links/migrations/0036_link_task_states_page_task_states_post_task_states.py new file mode 100644 index 0000000..64b8c45 --- /dev/null +++ b/links/migrations/0036_link_task_states_page_task_states_post_task_states.py @@ -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'), + ), + ] diff --git a/links/migrations/0037_linkchangelog_change_type_linkchangelog_metadata_and_more.py b/links/migrations/0037_linkchangelog_change_type_linkchangelog_metadata_and_more.py new file mode 100644 index 0000000..838ae93 --- /dev/null +++ b/links/migrations/0037_linkchangelog_change_type_linkchangelog_metadata_and_more.py @@ -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'), + ), + ] diff --git a/links/models.py b/links/models.py index 996a6d6..e79f0b8 100644 --- a/links/models.py +++ b/links/models.py @@ -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): - return f"URL changed from {self.old_url} to {self.new_url}" + 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'] diff --git a/links/page_views.py b/links/page_views.py index 7895037..27b08aa 100644 --- a/links/page_views.py +++ b/links/page_views.py @@ -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 @@ -31,27 +33,27 @@ def trigger_page_processing(page): 1. Screenshot capture 2. Crawl4AI content extraction 3. Page metadata processing - + This is called whenever a page is created, regardless of the source (UI or API). """ from .tasks import capture_screenshot, fetch_webpage_content_from_crawl4ai, process_page - + # Create screenshot record and trigger capture screenshot = Screenshot.objects.create( page=page, status=Screenshot.Status.PENDING ) - + # Start screenshot capture thread thread_screenshot = Thread(target=capture_screenshot, args=(page.id, screenshot.id)) thread_screenshot.daemon = True thread_screenshot.start() - + # Start Crawl4AI content extraction thread thread_crawl = Thread(target=fetch_webpage_content_from_crawl4ai, args=(page.id,)) thread_crawl.daemon = True thread_crawl.start() - + # Start page metadata processing thread (if needed) if not page.title or not page.summary: thread_process = Thread(target=process_page, args=(page.id,)) @@ -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) @@ -220,7 +230,7 @@ class PageViewSet(viewsets.ModelViewSet): else: instance.process_status = Page.ProcessStatus.PENDING instance.save() - + # Trigger all background processing tasks trigger_page_processing(instance) @@ -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' diff --git a/links/post_views.py b/links/post_views.py index b6a87c7..5ee6d20 100644 --- a/links/post_views.py +++ b/links/post_views.py @@ -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 @@ -20,7 +22,7 @@ class PostListView(ListView): def get_queryset(self): queryset = Post.objects.all().order_by('-created_at') - + # Date range filter date_from = self.request.GET.get('date_from') date_to = self.request.GET.get('date_to') @@ -43,7 +45,7 @@ class PostListView(ListView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - + context['date_from'] = self.request.GET.get('date_from', '') context['date_to'] = self.request.GET.get('date_to', '') @@ -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 + }) diff --git a/links/templates/links/custom_link.html b/links/templates/links/custom_link.html index ae9d361..bec902d 100644 --- a/links/templates/links/custom_link.html +++ b/links/templates/links/custom_link.html @@ -8,6 +8,25 @@ +
@@ -15,5 +34,42 @@ {{ rendered_text|safe }}
+ + diff --git a/links/templates/links/link_detail.html b/links/templates/links/link_detail.html index a460494..5579d11 100644 --- a/links/templates/links/link_detail.html +++ b/links/templates/links/link_detail.html @@ -132,23 +132,89 @@
-

{% trans "URL Change History" %}

+

{% trans "Change History" %}

{% if change_logs %} {% else %} -

{% trans "No URL changes recorded." %}

+

{% trans "No changes recorded." %}

{% endif %}
@@ -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); + } + }); + }); {% 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; + } {% endblock %} diff --git a/links/templates/links/link_form.html b/links/templates/links/link_form.html index 6b64317..eff42eb 100644 --- a/links/templates/links/link_form.html +++ b/links/templates/links/link_form.html @@ -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" + ] + }); } } } diff --git a/links/templates/links/page_form.html b/links/templates/links/page_form.html index b025285..befbe16 100644 --- a/links/templates/links/page_form.html +++ b/links/templates/links/page_form.html @@ -202,7 +202,30 @@ diff --git a/links/templatetags/markdown_extras.py b/links/templatetags/markdown_extras.py index 933df22..4e88146 100644 --- a/links/templatetags/markdown_extras.py +++ b/links/templatetags/markdown_extras.py @@ -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): - return md.markdown(value, extensions=['markdown.extensions.fenced_code', + """ + 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) + ] + ) diff --git a/links/templatetags/tasklist_markdown.py b/links/templatetags/tasklist_markdown.py new file mode 100644 index 0000000..733fb2d --- /dev/null +++ b/links/templatetags/tasklist_markdown.py @@ -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:
  • [ ] Task text
  • + Result:
  • Task text
  • + """ + # 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) diff --git a/links/urls.py b/links/urls.py index ec33940..162ccb9 100644 --- a/links/urls.py +++ b/links/urls.py @@ -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//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'), diff --git a/links/views.py b/links/views.py index 7d07eb6..542574c 100644 --- a/links/views.py +++ b/links/views.py @@ -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 diff --git a/locale/zh_Hans/LC_MESSAGES/django.po b/locale/zh_Hans/LC_MESSAGES/django.po index f33affb..bb7c331 100644 --- a/locale/zh_Hans/LC_MESSAGES/django.po +++ b/locale/zh_Hans/LC_MESSAGES/django.po @@ -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 "" diff --git a/run_server.sh b/run_server.sh index 00b16f2..9339fe9 100755 --- a/run_server.sh +++ b/run_server.sh @@ -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 diff --git a/run_tailwind.sh b/run_tailwind.sh index 40126ed..c70a34d 100755 --- a/run_tailwind.sh +++ b/run_tailwind.sh @@ -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