From f3a3eba0a3d3e0ee4739277fa05cdaa72235a46b Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 4 Nov 2024 11:37:00 +1100 Subject: [PATCH] Add pages function --- data/db.sqlite3 | Bin 204800 -> 204800 bytes links/forms.py | 11 +- links/migrations/0009_page.py | 31 +++ links/migrations/0010_alter_page_title.py | 18 ++ links/models.py | 20 ++ links/serializers.py | 8 + links/templates/links/help.html | 70 ++++++- .../templates/links/page_confirm_delete.html | 29 +++ links/templates/links/page_detail.html | 31 +++ links/templates/links/page_form.html | 177 ++++++++++++++++++ links/templates/links/page_list.html | 81 ++++++++ links/urls.py | 21 ++- links/views.py | 143 +++++++++++++- new_theme/static/css/dist/styles.css | 57 ++++++ requirements.txt | 3 + templates/base.html | 36 +++- url_manager/middleware.py | 14 ++ url_manager/settings.py | 49 ++++- 18 files changed, 785 insertions(+), 14 deletions(-) create mode 100644 links/migrations/0009_page.py create mode 100644 links/migrations/0010_alter_page_title.py create mode 100644 links/serializers.py create mode 100644 links/templates/links/page_confirm_delete.html create mode 100644 links/templates/links/page_detail.html create mode 100644 links/templates/links/page_form.html create mode 100644 links/templates/links/page_list.html create mode 100644 url_manager/middleware.py diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 6d07b08edf22685f529fc05033d732dd0592ce44..b8b2fee96896fbf6ddb8fbed58d1f05e162c912a 100644 GIT binary patch delta 2063 zcmZuyO>7%Q6yA;P#BrQV+ca|9q;%3KiD}~XX8mueYMl^hXlT=y&I`_YM`LjlkufTjiX2l@#;K)2B+NJno1*EHG!wlReIyrF?6kE21LcHvaR6YhRg z>pDH>54;?R2M%;UY7FC#x~oHBpCBF^50rIUaeJB}26iTR0W$G8L`Nn98fBEzR=hcF zBF35swLlC|+o5wRG^Z}&$S5F=js}RPwZic3#^i?m;w_&i9NejOEzJ3Q`+LRUpTQky z?cLxE@`}gAy~e`(z2b=2-pY7$rHy*5&nq4kTU4Ws<5wSAo-i7yb^Sc&N8@N14Wa=k zf4lFH&m)R~use>s54J-*s`k!+U#Mx-Opeb_mbccc>`WKi9+qBb$TI zb%ln9#dkzbmKojKuD!SVJxv_&I&#!E;I>&G#tH#t6c zehHtuG(SIykE^D^sloRG^{Z5z-F`sD6R(2Basv7t{fd4>KcJ7%d*}^RKno}bn?8zA zZ@?>r2HX}L-tC2Z;@1m(zMd;W-#2}8!M}UH=(z%)KG##d*y$HCy}iA;T!HhVosC8- zm5O8+OpO?lZbn=Fk7HTR%JJ+}Ov)&7Dy95?BoP8-$2A*lc@EM)4Q#jI^I}mEasIEH5b{H<2Oy|0)6T^mb zxJiLkuwT(K%eqE!nXrES0Iv*FbD+QJWG=El7ACeho~P z%@W7FK=IvT4C{R|!ur)YOIW7`i!)t&UZWE~LT;6haDlH`viF7>G z7HW&6lw|CN$1mg#KilTTWuoQ5fWY>8ftZdFr=1mRWCTx_bf)1&%1g!nRFud6gl&)+|jj9^lkf3 Icl@>U5A{B7)c^nh delta 666 zcmYL_O=uHQ6oub=lVmda@g_}UOe-`Ev2Cc$j0s8GbYTO6w1zT4x+!QfO~xd4n#4(3 zEef{a$}apFaG(@yK@ce}WW)$6v>O){QKTR$Qgx+>f{3EH@zsrs^DXW@aL&E+>yG(# z$K`gngb<2S?W1C;j$YXx!_uAH4Pe^1g2uYyMrWXhGYb9{%N`B}82AKR@Bn6E5td*Q z1|S1Lqf@Xzd#i`(B^CK}Hmj5?Q}usD*L*EZ4C||t*rSPj5{I-!KHzoX|CaOA>|>}& z+i-X=r)}}Q?QJ$%g@X)&ukZw3!y{OMGMuAXi-tSb4_fA+!Gvt8#tx6renFd|j$So0 zA!kry#hu9uhY>vXL1jYFr?Jm>CU18cecLbRaf@!w;ckLHHX}KF#6)mG-65ad=`k2O@$m z@Cr8JAzX)xa284+(y|F?iqG&}F^ydGCWIpJ2Y$jg`0NZj9@~G|*X%>K`?j17aVwnP zx@mG-~xE&!14F`c$MQ zDT>^v*VO7sIp3&9^3`cxuJEyl5IMu2nkna}>J3S4=#v=|YKaDf!DMV88Sjro6Y*%` zfJtA?lB_$+aL7-q(3oPiT2y3x!^!w`|2VPh_6+g)geZML&q6C@ca -
- {% filter markdown %} +
+
+
+
+ {% filter markdown %} # Frequently Asked Questions (FAQ) ## Link Types @@ -135,7 +137,67 @@ Custom pages allow you to create markdown-based web pages that can serve as docu **Example Use Cases:** Check http://go.junv.cc/custom/today/ - {% endfilter %} + +## API Usage + +GoLinks provides RESTful APIs for programmatic access to pages. Here are the available endpoints and their usage: + +### List Pages + +Retrieve a paginated list of all pages: +``` +GET /api/pages/ +GET /api/pages/?page=2 +GET /api/pages/?page_size=20 +``` +Response examples: +``` +{ + "count": 100, + "next": "http://localhost:8000/api/pages/?page=2", + "previous": null, + "results": [ + { + "id": 1, + "url": "https://example.com", + "title": "Example Page", + "summary": "This is a summary", + "content": "This is the content", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + }, + // ... more pages + ] +} +``` +### Create a new Page + +``` +POST /api/pages/ +Content-Type: application/json + +{ + "url": "https://example.com", + "title": "Example Page", + "summary": "This is a summary", + "content": "This is the content" +} +``` +Response example: +``` +{ + "id": 1, + "url": "https://example.com", + "title": "Example Page", + "summary": "This is a summary", + "content": "This is the content", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" +} +``` +{% endfilter %} +
+
{% endblock %} diff --git a/links/templates/links/page_confirm_delete.html b/links/templates/links/page_confirm_delete.html new file mode 100644 index 0000000..9eb427c --- /dev/null +++ b/links/templates/links/page_confirm_delete.html @@ -0,0 +1,29 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} +
+
+
+

{% trans "Delete Page" %}

+ +

+ {% trans "Are you sure you want to delete this page?" %} + {{ page.title }} +

+ +
+ {% csrf_token %} +
+ + {% trans "Cancel" %} + + +
+
+
+
+
+{% endblock %} diff --git a/links/templates/links/page_detail.html b/links/templates/links/page_detail.html new file mode 100644 index 0000000..52ec29c --- /dev/null +++ b/links/templates/links/page_detail.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load markdown_extras %} + +{% block content %} +
+
+
+ + +
+ {{ page.content|markdown|safe }} +
+ +
+ {% trans "Last updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }} +
+
+
+
+{% endblock %} diff --git a/links/templates/links/page_form.html b/links/templates/links/page_form.html new file mode 100644 index 0000000..d0e87ce --- /dev/null +++ b/links/templates/links/page_form.html @@ -0,0 +1,177 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+
+

+ {% if form.instance.pk %} + {% trans "Edit Page" %} + {% else %} + {% trans "New Page" %} + {% endif %} +

+
+ +
+ {% csrf_token %} + +
+ +
+ +
+ +
+
+
+
+ + {% if form.url.errors %} +

{{ form.url.errors.0 }}

+ {% endif %} +
+ + +
+ +
+ +
+ {% if form.title.errors %} +

{{ form.title.errors.0 }}

+ {% endif %} +
+ + +
+ +
+ +
+ {% if form.summary.errors %} +

{{ form.summary.errors.0 }}

+ {% endif %} +
+ + +
+ +
+ +
+ {% if form.content.errors %} +

{{ form.content.errors.0 }}

+ {% endif %} +

{% trans "Supports Markdown formatting" %}

+
+
+ +
+
+ + {% trans "Cancel" %} + + +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/links/templates/links/page_list.html b/links/templates/links/page_list.html new file mode 100644 index 0000000..6add56c --- /dev/null +++ b/links/templates/links/page_list.html @@ -0,0 +1,81 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} +
+
+

{% trans "Bookmark Pages" %}

+ + {% trans "Add Page" %} + +
+ +
+ +
+ + {% if is_paginated %} +
+ +
+ {% endif %} +
+{% endblock %} diff --git a/links/urls.py b/links/urls.py index af8f5fb..d2789a9 100644 --- a/links/urls.py +++ b/links/urls.py @@ -1,7 +1,12 @@ -from django.urls import path +from django.urls import path, include +from rest_framework.routers import DefaultRouter from . import views +router = DefaultRouter(trailing_slash=False) +router.register(r'pages', views.PageViewSet, basename='api-page') + urlpatterns = [ + # Regular UI URLs path('', views.LinkListView.as_view(), name='link_list'), path('create/', views.LinkCreateView.as_view(), name='link_create'), path('update//', views.LinkUpdateView.as_view(), name='link_update'), @@ -16,9 +21,19 @@ urlpatterns = [ path('ui/help/', views.HelpView.as_view(), name='help'), path('export/', views.export_links, name='export_links'), - # Aliases + # Pages UI + path('ui/pages/', views.PageListView.as_view(), name='page-list'), + path('ui/pages/new/', views.PageCreateView.as_view(), name='page-create'), + path('ui/pages//', views.PageDetailView.as_view(), name='page-detail'), + path('ui/pages//edit/', views.PageUpdateView.as_view(), name='page-update'), + path('ui/pages//delete/', views.PageDeleteView.as_view(), name='page-delete'), + path('fetch-page-info/', views.fetch_page_info, name='fetch-page-info'), + + # API URLs - place before catch-all alias routes + path('api/', include(router.urls)), + + # Aliases - these should always be last as they're catch-all routes path('/', views.redirect_to_original, name='redirect_to_original'), path('//', views.redirect_to_original, name='redirect_to_original_with_param'), path('alias//', views.LinkDetailView.as_view(), name='link_detail_by_alias'), - ] diff --git a/links/views.py b/links/views.py index f73997b..cf88936 100644 --- a/links/views.py +++ b/links/views.py @@ -4,8 +4,8 @@ from django.views import View # Add this import from django.urls import reverse_lazy, reverse # Add 'reverse' here from django.db.models import F, Count, Q, Case, When, Value, IntegerField from django.db.models.functions import TruncDate -from .models import Link, ClickLog, LinkChangeLog -from .forms import LinkForm +from .models import Link, ClickLog, LinkChangeLog, Page +from .forms import LinkForm, PageForm import json from django.core.serializers.json import DjangoJSONEncoder from django.contrib import messages @@ -27,6 +27,14 @@ from django.conf import settings from django.http import FileResponse from wsgiref.util import FileWrapper from django.contrib.auth.decorators import user_passes_test +from django.contrib.auth.mixins import LoginRequiredMixin +import requests +from bs4 import BeautifulSoup +from urllib.parse import urlparse +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.pagination import PageNumberPagination +from .serializers import PageSerializer logger = logging.getLogger(__name__) @@ -432,3 +440,134 @@ def export_database(request): class HelpView(TemplateView): template_name = 'links/help.html' + +class PageListView(ListView): + model = Page + template_name = 'links/page_list.html' + context_object_name = 'pages' + paginate_by = 10 + +class PageDetailView(DetailView): + model = Page + template_name = 'links/page_detail.html' + +class PageCreateView(CreateView): + model = Page + form_class = PageForm + template_name = 'links/page_form.html' + success_url = reverse_lazy('page-list') + +class PageUpdateView(UpdateView): + model = Page + form_class = PageForm + template_name = 'links/page_form.html' + success_url = reverse_lazy('page-list') + +class PageDeleteView(DeleteView): + model = Page + template_name = 'links/page_confirm_delete.html' + success_url = reverse_lazy('page-list') + +def fetch_page_info(request): + url = request.GET.get('url') + if not url: + return JsonResponse({'error': 'URL is required'}, status=400) + + # 移除URL开头可能的@符号 + url = url.lstrip('@') + + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + } + + response = requests.get(url, headers=headers, timeout=10, verify=False) + + # 确保使用正确的编码 + if response.encoding == 'ISO-8859-1': + response.encoding = response.apparent_encoding or 'utf-8' + + soup = BeautifulSoup(response.text, 'html.parser') + + # 获取标题 - 尝试多种方式 + title = None + # 1. 尝试获取title标签 + if soup.title: + title = soup.title.string + # 2. 尝试获取第一个h1 + if not title and soup.find('h1'): + title = soup.find('h1').get_text(strip=True) + # 3. 尝试获取og:title + if not title: + og_title = soup.find('meta', property='og:title') + if og_title: + title = og_title.get('content') + + # 清理标题 + if title: + title = re.sub(r'\s+', ' ', title.strip()) + title = title.replace(' | ', ' - ').replace(' :: ', ' - ') + else: + title = urlparse(url).netloc + + # 获取描述 - 尝试多种方式 + description = None + # 1. 尝试meta description + meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'}) + if meta_desc: + description = meta_desc.get('content') + + # 2. 如果没有meta描述,尝试获取正文内容 + if not description: + # 移除script, style等标签 + for tag in soup(['script', 'style', 'nav', 'header', 'footer']): + tag.decompose() + + # 获取所有段落 + paragraphs = soup.find_all(['p', 'div']) + for p in paragraphs: + text = p.get_text(strip=True) + if len(text) > 100: # 确保段落有足够的内容 + description = text + break + + # 如果还是没有描述,使用标题 + if not description: + description = title + + # 清理描述 + description = re.sub(r'\s+', ' ', description.strip()) + description = description[:500] + '...' if len(description) > 500 else description + + return JsonResponse({ + 'title': title, + 'summary': description + }) + + except requests.exceptions.RequestException as e: + return JsonResponse({ + 'error': f'Failed to fetch page: {str(e)}' + }, status=400) + except Exception as e: + return JsonResponse({ + 'error': f'Error processing page: {str(e)}' + }, status=400) + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + +class PageViewSet(viewsets.ModelViewSet): + queryset = Page.objects.all().order_by('-created_at') + serializer_class = PageSerializer + pagination_class = StandardResultsSetPagination + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/new_theme/static/css/dist/styles.css b/new_theme/static/css/dist/styles.css index ed31bb6..8b83f31 100644 --- a/new_theme/static/css/dist/styles.css +++ b/new_theme/static/css/dist/styles.css @@ -653,6 +653,10 @@ video { top: 100%; } +.z-0 { + z-index: 0; +} + .z-10 { z-index: 10; } @@ -754,6 +758,10 @@ video { display: block; } +.inline { + display: inline; +} + .flex { display: flex; } @@ -941,6 +949,12 @@ video { row-gap: 2rem; } +.-space-x-px > :not([hidden]) ~ :not([hidden]) { + --tw-space-x-reverse: 0; + margin-right: calc(-1px * var(--tw-space-x-reverse)); + margin-left: calc(-1px * calc(1 - var(--tw-space-x-reverse))); +} + .space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); @@ -1281,6 +1295,10 @@ video { padding-right: 0.75rem; } +.pt-5 { + padding-top: 1.25rem; +} + .text-left { text-align: left; } @@ -1293,6 +1311,10 @@ video { font-family: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; } +.font-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; +} + .text-2xl { font-size: 1.5rem; line-height: 2rem; @@ -1485,6 +1507,11 @@ video { color: rgb(161 98 7 / var(--tw-text-opacity)); } +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + .underline { text-decoration-line: underline; } @@ -1493,6 +1520,12 @@ video { text-decoration-line: line-through; } +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .shadow-md { --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); @@ -1672,6 +1705,16 @@ video { color: rgb(55 65 81 / var(--tw-text-opacity)); } +.hover\:text-gray-900:hover { + --tw-text-opacity: 1; + color: rgb(17 24 39 / var(--tw-text-opacity)); +} + +.hover\:text-green-800:hover { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity)); +} + .hover\:text-green-900:hover { --tw-text-opacity: 1; color: rgb(20 83 45 / var(--tw-text-opacity)); @@ -1687,6 +1730,11 @@ video { color: rgb(49 46 129 / var(--tw-text-opacity)); } +.hover\:text-red-800:hover { + --tw-text-opacity: 1; + color: rgb(153 27 27 / var(--tw-text-opacity)); +} + .hover\:text-red-900:hover { --tw-text-opacity: 1; color: rgb(127 29 29 / var(--tw-text-opacity)); @@ -1841,6 +1889,10 @@ video { margin-bottom: calc(0px * var(--tw-space-y-reverse)); } + .sm\:rounded-md { + border-radius: 0.375rem; + } + .sm\:p-12 { padding: 3rem; } @@ -1868,6 +1920,11 @@ video { font-size: 1rem; line-height: 1.5rem; } + + .sm\:text-sm { + font-size: 0.875rem; + line-height: 1.25rem; + } } @media (min-width: 768px) { diff --git a/requirements.txt b/requirements.txt index c0652f1..cd15e61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,6 @@ gunicorn==22.0.0 whitenoise==5.3.0 django-simplemde==0.1.4 markdown==3.7 +requests==2.32.3 +beautifulsoup4==4.12.3 +djangorestframework==3.15.2 diff --git a/templates/base.html b/templates/base.html index 6571431..fba7e5e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,9 +68,39 @@ {% trans "Menu" %}
diff --git a/url_manager/middleware.py b/url_manager/middleware.py new file mode 100644 index 0000000..9a75b9f --- /dev/null +++ b/url_manager/middleware.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.urls import resolve +from django.utils import translation +from django.middleware.locale import LocaleMiddleware + +class CustomLocaleMiddleware(LocaleMiddleware): + def process_request(self, request): + url_path = request.path_info.lstrip('/') + + # 检查是否是 API 路径 + if url_path.startswith('api/'): + return None + + return super().process_request(request) diff --git a/url_manager/settings.py b/url_manager/settings.py index a47df01..7835976 100644 --- a/url_manager/settings.py +++ b/url_manager/settings.py @@ -2,6 +2,7 @@ import os from pathlib import Path from django.utils.translation import gettext_lazy as _ import links.patches # 添加这一行在文件最上方 +from django.urls import re_path # 构建路径,如 BASE_DIR / 'subdir' BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,7 +27,7 @@ ROOT_URLCONF = 'url_manager.urls' MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', # Make sure this is here + 'url_manager.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -145,3 +146,49 @@ LOGGING = { }, }, } + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10, + 'UNAUTHENTICATED_USER': None, # 添加这行 +} + +LANGUAGE_URL_MAP = { + 'en': 'en', + 'zh-hans': 'zh', +} + +# 添加这个配置来排除 API URLs 的语言重定向 +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + +PREFIX_DEFAULT_LANGUAGE = True + +# 修改这里,使用简单的字符串模式而不是 re_path +LOCALE_INDEPENDENT_PATHS = [ + 'api/', # 只需要一个简单的字符串前缀 +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'url_manager.middleware.CustomLocaleMiddleware', # 使用自定义中间件 + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +REST_FRAMEWORK = { + 'DEFAULT_RENDERER_CLASSES': [ + 'rest_framework.renderers.JSONRenderer', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 10, + 'UNAUTHENTICATED_USER': None, +}