diff --git a/data/db.sqlite3 b/data/db.sqlite3
index ffedfdd..0471407 100644
Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ
diff --git a/links/apps.py b/links/apps.py
index 702d4fc..e4581f1 100644
--- a/links/apps.py
+++ b/links/apps.py
@@ -180,3 +180,76 @@ class LinksConfig(AppConfig):
'delete': 'bulk_delete_image_imports',
},
})
+
+ # ── Knowledge Graph Snapshots ────────────────────────────────────
+ from .models import KnowledgeGraphSnapshot
+
+ def kg_stats():
+ return {
+ 'total': KnowledgeGraphSnapshot.objects.count(),
+ 'processing': KnowledgeGraphSnapshot.objects.filter(
+ status=KnowledgeGraphSnapshot.Status.BUILDING
+ ).count(),
+ 'completed': KnowledgeGraphSnapshot.objects.filter(
+ status=KnowledgeGraphSnapshot.Status.READY
+ ).count(),
+ 'failed': KnowledgeGraphSnapshot.objects.filter(
+ status=KnowledgeGraphSnapshot.Status.FAILED
+ ).count(),
+ }
+
+ def kg_queryset(sf):
+ qs = KnowledgeGraphSnapshot.objects.order_by('-created_at')
+ if sf == 'processing':
+ return qs.filter(status=KnowledgeGraphSnapshot.Status.BUILDING)
+ if sf == 'completed':
+ return qs.filter(status=KnowledgeGraphSnapshot.Status.READY)
+ if sf == 'failed':
+ return qs.filter(status=KnowledgeGraphSnapshot.Status.FAILED)
+ return qs
+
+ def kg_serialize(obj):
+ if obj.status == KnowledgeGraphSnapshot.Status.BUILDING:
+ pct = obj.progress_data.get('pct', 0) if isinstance(obj.progress_data, dict) else 0
+ title = f'Building… {pct}%'
+ else:
+ title = f'{obj.node_count} nodes / {obj.edge_count} edges'
+ if obj.used_llm:
+ title += ' (LLM)'
+ return {
+ 'id': str(obj.id),
+ 'title': title,
+ 'detail_url': reverse('knowledge-graph'),
+ 'status': obj.status,
+ 'retry': None,
+ 'retry_max': None,
+ 'error': obj.error_message or '',
+ 'updated_at': obj.completed_at or obj.created_at,
+ 'extra': {
+ 'duration': (
+ f'{obj.build_duration_ms // 1000}s' if obj.build_duration_ms else ''
+ ),
+ },
+ }
+
+ job_registry.register({
+ 'id': 'knowledge_graph',
+ 'label': 'Knowledge Graph',
+ 'icon_color': 'text-pink-500',
+ 'icon_path': (
+ 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101'
+ 'm-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
+ ),
+ 'title_label': 'Snapshot',
+ 'status_choices': [
+ ('all', 'All'), ('processing', 'Building'), ('completed', 'Ready'),
+ ('failed', 'Failed'),
+ ],
+ 'columns': ['id', 'title', 'status', 'error', 'updated'],
+ 'get_stats': kg_stats,
+ 'get_queryset': kg_queryset,
+ 'serialize': kg_serialize,
+ 'bulk_actions': {
+ 'delete': 'bulk_delete_knowledge_graph_snapshots',
+ },
+ })
diff --git a/links/migrations/0047_add_is_public_to_post.py b/links/migrations/0047_add_is_public_to_post.py
new file mode 100644
index 0000000..667819a
--- /dev/null
+++ b/links/migrations/0047_add_is_public_to_post.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.12 on 2026-03-30 09:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('links', '0046_kg_progress_data'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='post',
+ name='is_public',
+ field=models.BooleanField(db_index=True, default=False, verbose_name='Is Public'),
+ ),
+ ]
diff --git a/links/models.py b/links/models.py
index 4d8b632..36b0537 100644
--- a/links/models.py
+++ b/links/models.py
@@ -272,6 +272,7 @@ class Post(models.Model):
title = models.CharField(_('Title'), max_length=200)
summary = models.TextField(_('Summary'), blank=True, help_text=_('A brief summary of the post'))
content = models.TextField(_('Content'))
+ is_public = models.BooleanField(_('Is Public'), default=False, db_index=True)
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')
diff --git a/links/post_views.py b/links/post_views.py
index f6ed2e1..469ed5a 100644
--- a/links/post_views.py
+++ b/links/post_views.py
@@ -1,12 +1,15 @@
-from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
-from django.urls import reverse_lazy
+import json
+
+from django.http import Http404, JsonResponse
+from django.urls import reverse, reverse_lazy
+from django.utils import timezone
from django.utils.translation import gettext_lazy as _
-from django.http import Http404
+from django.views import View
+from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
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
@@ -105,10 +108,35 @@ class PublicPostView(DetailView):
return context
def get_object(self, queryset=None):
- try:
- return super().get_object(queryset)
- except Http404:
+ obj = super().get_object(queryset)
+ if not obj.is_public:
raise Http404("Post not found")
+ return obj
+
+
+class PostShareView(View):
+ """Toggle public sharing for a post. POST body: {"enable": true|false}"""
+
+ def post(self, request, pk):
+ try:
+ post = Post.objects.get(pk=pk)
+ except Post.DoesNotExist:
+ return JsonResponse({'error': 'Not found'}, status=404)
+
+ try:
+ body = json.loads(request.body or '{}')
+ enable = bool(body.get('enable', True))
+ except (json.JSONDecodeError, AttributeError):
+ enable = True
+
+ post.is_public = enable
+ post.save(update_fields=['is_public'])
+
+ public_url = (
+ request.build_absolute_uri(reverse('public-post', args=[post.pk]))
+ if post.is_public else None
+ )
+ return JsonResponse({'is_public': post.is_public, 'url': public_url})
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
diff --git a/links/serializers.py b/links/serializers.py
index b41098c..7bf48b7 100644
--- a/links/serializers.py
+++ b/links/serializers.py
@@ -25,24 +25,24 @@ class PageSerializer(serializers.ModelSerializer):
class PostSerializer(serializers.ModelSerializer):
tags = serializers.ListField(child=serializers.CharField(), required=False, write_only=True, help_text="List of tag slugs")
tag_details = serializers.SerializerMethodField(read_only=True)
-
+
class Meta:
model = Post
- fields = ['id', 'title', 'summary', 'content', 'tags', 'tag_details', 'created_at', 'updated_at']
+ fields = ['id', 'title', 'summary', 'content', 'is_public', 'tags', 'tag_details', 'created_at', 'updated_at']
read_only_fields = ['id', 'created_at', 'updated_at']
extra_kwargs = {
'title': {'required': True},
'content': {'required': True},
'summary': {'required': False}
}
-
+
def get_tag_details(self, obj):
return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()]
def create(self, validated_data):
tag_slugs = validated_data.pop('tags', [])
post = Post.objects.create(**validated_data)
-
+
for tag_slug in tag_slugs:
try:
tag = Tag.objects.get(slug=tag_slug)
@@ -50,14 +50,14 @@ class PostSerializer(serializers.ModelSerializer):
# If tag doesn't exist, create it with the slug as both name and slug
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
post.tags.add(tag)
-
+
return post
def update(self, instance, validated_data):
tag_slugs = validated_data.pop('tags', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
-
+
if tag_slugs is not None:
instance.tags.clear()
for tag_slug in tag_slugs:
@@ -67,7 +67,7 @@ class PostSerializer(serializers.ModelSerializer):
# If tag doesn't exist, create it with the slug as both name and slug
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
instance.tags.add(tag)
-
+
instance.save()
return instance
diff --git a/links/templates/links/knowledge_graph.html b/links/templates/links/knowledge_graph.html
index b925cc0..a7a08b1 100644
--- a/links/templates/links/knowledge_graph.html
+++ b/links/templates/links/knowledge_graph.html
@@ -81,6 +81,17 @@
+
+ {% if snapshot %}
+ {% with built_at=snapshot.completed_at|default:snapshot.created_at %}
+
+ {% trans "Last built" %}: {{ built_at|timesince }} {% trans "ago" %}
+
+ {% endwith %}
+ {% endif %}
+
{% trans "Edit" %}
-
+
+
+
+
+
+
+