mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-09-08 10:16:57 +02:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e773ad1dc4 | ||
![]() |
a02338cdec | ||
![]() |
8c161ba119 | ||
![]() |
5644dae14e | ||
![]() |
58836c3c76 | ||
![]() |
b7a8f9e53d | ||
![]() |
afe081d3b5 | ||
![]() |
7a14c6e2d1 | ||
![]() |
f7e6fbc588 | ||
![]() |
778f1b2ff3 | ||
![]() |
79dd4179d2 | ||
![]() |
0980e6a2b2 | ||
![]() |
83ccf5279f | ||
![]() |
3bab7db023 | ||
![]() |
b6b7d3f662 | ||
![]() |
9c51487d3b | ||
![]() |
c61e8ee2cd |
18
API.md
18
API.md
@@ -59,7 +59,7 @@ Example response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Archived**
|
**List Archived**
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/bookmarks/archived/
|
GET /api/bookmarks/archived/
|
||||||
@@ -121,6 +121,22 @@ Example payload:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Archive**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bookmarks/<id>/archive/
|
||||||
|
```
|
||||||
|
|
||||||
|
Archives a bookmark.
|
||||||
|
|
||||||
|
**Unarchive**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/bookmarks/<id>/unarchive/
|
||||||
|
```
|
||||||
|
|
||||||
|
Unarchives a bookmark.
|
||||||
|
|
||||||
**Delete**
|
**Delete**
|
||||||
|
|
||||||
```
|
```
|
||||||
|
27
CHANGELOG.md
27
CHANGELOG.md
@@ -1,5 +1,32 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.3.3 (18/02/2021)
|
||||||
|
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.2 (18/02/2021)
|
||||||
|
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
|
||||||
|
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.1 (15/02/2021)
|
||||||
|
[enhancement] Enhance delete links with inline confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.3.0 (14/02/2021)
|
||||||
|
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
|
||||||
|
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
|
||||||
|
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
|
||||||
|
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
|
||||||
|
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
|
||||||
|
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
|
||||||
|
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.2.1 (12/01/2021)
|
## v1.2.1 (12/01/2021)
|
||||||
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
|
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
|
||||||
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
|
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
|
||||||
|
@@ -1,17 +1,84 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin, messages
|
||||||
|
from django.contrib.admin import AdminSite
|
||||||
|
from django.contrib.auth.admin import UserAdmin
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Count, QuerySet
|
||||||
|
from django.utils.translation import ngettext, gettext
|
||||||
|
from rest_framework.authtoken.admin import TokenAdmin
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingAdminSite(AdminSite):
|
||||||
|
site_header = 'linkding administration'
|
||||||
|
site_title = 'linkding Admin'
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Bookmark)
|
|
||||||
class AdminBookmark(admin.ModelAdmin):
|
class AdminBookmark(admin.ModelAdmin):
|
||||||
list_display = ('title', 'url', 'date_added')
|
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
||||||
search_fields = ('title', 'url', 'tags__name')
|
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
||||||
list_filter = ('tags',)
|
list_filter = ('owner__username', 'is_archived', 'tags',)
|
||||||
ordering = ('-date_added', )
|
ordering = ('-date_added',)
|
||||||
|
actions = ['archive_selected_bookmarks', 'unarchive_selected_bookmarks']
|
||||||
|
|
||||||
|
def archive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||||
|
for bookmark in queryset:
|
||||||
|
archive_bookmark(bookmark)
|
||||||
|
bookmarks_count = queryset.count()
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d bookmark was successfully archived.',
|
||||||
|
'%d bookmarks were successfully archived.',
|
||||||
|
bookmarks_count,
|
||||||
|
) % bookmarks_count, messages.SUCCESS)
|
||||||
|
|
||||||
|
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
|
||||||
|
for bookmark in queryset:
|
||||||
|
unarchive_bookmark(bookmark)
|
||||||
|
bookmarks_count = queryset.count()
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d bookmark was successfully unarchived.',
|
||||||
|
'%d bookmarks were successfully unarchived.',
|
||||||
|
bookmarks_count,
|
||||||
|
) % bookmarks_count, messages.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Tag)
|
|
||||||
class AdminTag(admin.ModelAdmin):
|
class AdminTag(admin.ModelAdmin):
|
||||||
list_display = ('name', 'date_added', 'owner')
|
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
|
||||||
search_fields = ('name', 'owner__username')
|
search_fields = ('name', 'owner__username')
|
||||||
list_filter = ('owner__username', )
|
list_filter = ('owner__username',)
|
||||||
ordering = ('-date_added', )
|
ordering = ('-date_added',)
|
||||||
|
actions = ['delete_unused_tags']
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
queryset = super().get_queryset(request)
|
||||||
|
queryset = queryset.annotate(bookmarks_count=Count("bookmark"))
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def bookmarks_count(self, obj):
|
||||||
|
return obj.bookmarks_count
|
||||||
|
|
||||||
|
def delete_unused_tags(self, request, queryset: QuerySet):
|
||||||
|
unused_tags = queryset.filter(bookmark__isnull=True)
|
||||||
|
unused_tags_count = unused_tags.count()
|
||||||
|
for tag in unused_tags:
|
||||||
|
tag.delete()
|
||||||
|
|
||||||
|
if unused_tags_count > 0:
|
||||||
|
self.message_user(request, ngettext(
|
||||||
|
'%d unused tag was successfully deleted.',
|
||||||
|
'%d unused tags were successfully deleted.',
|
||||||
|
unused_tags_count,
|
||||||
|
) % unused_tags_count, messages.SUCCESS)
|
||||||
|
else:
|
||||||
|
self.message_user(request, gettext(
|
||||||
|
'There were no unused tags in the selection',
|
||||||
|
), messages.SUCCESS)
|
||||||
|
|
||||||
|
|
||||||
|
linkding_admin_site = LinkdingAdminSite()
|
||||||
|
linkding_admin_site.register(Bookmark, AdminBookmark)
|
||||||
|
linkding_admin_site.register(Tag, AdminTag)
|
||||||
|
linkding_admin_site.register(User, UserAdmin)
|
||||||
|
linkding_admin_site.register(Token, TokenAdmin)
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
from django.urls import reverse
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -6,6 +7,8 @@ from rest_framework.routers import DefaultRouter
|
|||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
||||||
from bookmarks.models import Bookmark, Tag
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||||
|
from bookmarks.services.website_loader import load_website_metadata
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(viewsets.GenericViewSet,
|
class BookmarkViewSet(viewsets.GenericViewSet,
|
||||||
@@ -39,6 +42,37 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
|||||||
data = serializer(page, many=True).data
|
data = serializer(page, many=True).data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
|
@action(methods=['post'], detail=True)
|
||||||
|
def archive(self, request, pk):
|
||||||
|
bookmark = self.get_object()
|
||||||
|
archive_bookmark(bookmark)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(methods=['post'], detail=True)
|
||||||
|
def unarchive(self, request, pk):
|
||||||
|
bookmark = self.get_object()
|
||||||
|
unarchive_bookmark(bookmark)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
@action(methods=['get'], detail=False)
|
||||||
|
def check(self, request):
|
||||||
|
url = request.GET.get('url')
|
||||||
|
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
||||||
|
existing_bookmark_data = None
|
||||||
|
|
||||||
|
if bookmark is not None:
|
||||||
|
existing_bookmark_data = {
|
||||||
|
'id': bookmark.id,
|
||||||
|
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = load_website_metadata(url)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'bookmark': existing_bookmark_data,
|
||||||
|
'metadata': metadata.to_dict()
|
||||||
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
class TagViewSet(viewsets.GenericViewSet,
|
class TagViewSet(viewsets.GenericViewSet,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
@@ -30,8 +30,11 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
'date_modified'
|
'date_modified'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Override optional char fields to provide default value
|
||||||
|
title = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField()
|
tag_names = TagListField(required=False, default=[])
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bookmark = Bookmark()
|
bookmark = Bookmark()
|
||||||
|
@@ -52,4 +52,9 @@ h2 {
|
|||||||
// Remove left padding from first pagination link
|
// Remove left padding from first pagination link
|
||||||
.pagination .page-item:first-child a {
|
.pagination .page-item:first-child a {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override border color for tab block
|
||||||
|
.tab-block {
|
||||||
|
border-bottom: solid 1px $border-color;
|
||||||
}
|
}
|
@@ -56,6 +56,13 @@ ul.bookmark-list {
|
|||||||
color: darken($gray-color, 10%);
|
color: darken($gray-color, 10%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions .btn-link.bm-remove-confirm {
|
||||||
|
color: $error-color;
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bookmark-pagination {
|
.bookmark-pagination {
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
.settings-page {
|
.settings-page {
|
||||||
section.content-area {
|
section.content-area {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.0rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group > input[type=submit] {
|
.input-group > input[type=submit] {
|
||||||
|
@@ -32,8 +32,7 @@
|
|||||||
class="btn btn-link btn-sm">Archive</a>
|
class="btn btn-link btn-sm">Archive</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||||
class="btn btn-link btn-sm"
|
class="btn btn-link btn-sm bm-remove">Remove</a>
|
||||||
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -42,3 +41,38 @@
|
|||||||
<div class="bookmark-pagination">
|
<div class="bookmark-pagination">
|
||||||
{% pagination bookmarks %}
|
{% pagination bookmarks %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Enhance delete links to show inline confirmation #}
|
||||||
|
<script type="application/javascript">
|
||||||
|
window.addEventListener("load", function () {
|
||||||
|
const linkEls = document.querySelectorAll('.bookmark-list a.bm-remove');
|
||||||
|
|
||||||
|
function showConfirmation(linkEl) {
|
||||||
|
const cancelEl = document.createElement('span');
|
||||||
|
cancelEl.innerText = 'Cancel';
|
||||||
|
cancelEl.className = 'btn btn-link btn-sm bm-remove-confirm mr-1';
|
||||||
|
cancelEl.addEventListener('click', function() {
|
||||||
|
container.remove();
|
||||||
|
linkEl.style = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmEl = document.createElement('a');
|
||||||
|
confirmEl.innerText = 'Confirm';
|
||||||
|
confirmEl.className = 'btn btn-link btn-delete btn-sm bm-remove-confirm';
|
||||||
|
confirmEl.href = linkEl.href;
|
||||||
|
|
||||||
|
const container = document.createElement('span');
|
||||||
|
container.appendChild(cancelEl);
|
||||||
|
container.appendChild(confirmEl);
|
||||||
|
linkEl.parentElement.appendChild(container);
|
||||||
|
linkEl.style = 'display: none';
|
||||||
|
}
|
||||||
|
|
||||||
|
linkEls.forEach(function (linkEl) {
|
||||||
|
linkEl.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
showConfirmation(linkEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
@@ -2,7 +2,7 @@
|
|||||||
<p class="empty-title h5">You have no bookmarks yet</p>
|
<p class="empty-title h5">You have no bookmarks yet</p>
|
||||||
<p class="empty-subtitle">
|
<p class="empty-subtitle">
|
||||||
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
You can get started by <a href="{% url 'bookmarks:new' %}">adding</a> bookmarks,
|
||||||
<a href="{% url 'bookmarks:settings.index' %}">importing</a> your existing bookmarks or configuring the
|
<a href="{% url 'bookmarks:settings.data' %}">importing</a> your existing bookmarks or configuring the
|
||||||
<a href="{% url 'bookmarks:settings.index' %}#bookmarklet">bookmarklet</a>.
|
<a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -97,7 +97,7 @@
|
|||||||
toggleIcon(descriptionInput, true);
|
toggleIcon(descriptionInput, true);
|
||||||
|
|
||||||
const websiteUrl = encodeURIComponent(urlInput.value);
|
const websiteUrl = encodeURIComponent(urlInput.value);
|
||||||
const requestUrl = `{% url 'bookmarks:api.check_url' %}?url=${websiteUrl}`;
|
const requestUrl = `{% url 'bookmarks:api-root' %}bookmarks/check?url=${websiteUrl}`;
|
||||||
fetch(requestUrl)
|
fetch(requestUrl)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
25
bookmarks/templates/settings/api.html
Normal file
25
bookmarks/templates/settings/api.html
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-page">
|
||||||
|
|
||||||
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>API Token</h2>
|
||||||
|
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-6 col-md-12">
|
||||||
|
<input class="form-input" value="{{ api_token }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this
|
||||||
|
token can access and manage all your bookmarks.</p>
|
||||||
|
<p>If you think that a token was compromised you can revoke (delete) it in the <a href="{% url 'admin:authtoken_token_changelist' %}">admin panel</a>. After deleting the token, a new one will be generated when you reload this settings page.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
54
bookmarks/templates/settings/data.html
Normal file
54
bookmarks/templates/settings/data.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-page">
|
||||||
|
|
||||||
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
|
{# Import section #}
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>Import</h2>
|
||||||
|
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
|
||||||
|
added and existing ones are updated.</p>
|
||||||
|
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="input-group col-8 col-md-12">
|
||||||
|
<input class="form-input" type="file" name="import_file">
|
||||||
|
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
|
||||||
|
</div>
|
||||||
|
{% if import_success_message %}
|
||||||
|
<div class="has-success">
|
||||||
|
<p class="form-input-hint">
|
||||||
|
{{ import_success_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if import_errors_message %}
|
||||||
|
<div class="has-error">
|
||||||
|
<p class="form-input-hint">
|
||||||
|
{{ import_errors_message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Export section #}
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>Export</h2>
|
||||||
|
<p>Export all bookmarks in Netscape HTML format.</p>
|
||||||
|
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
||||||
|
{% if export_error %}
|
||||||
|
<div class="has-error">
|
||||||
|
<p class="form-input-hint">
|
||||||
|
{{ export_error }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
@@ -1,91 +0,0 @@
|
|||||||
{% extends "bookmarks/layout.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="settings-page">
|
|
||||||
|
|
||||||
{# Import section #}
|
|
||||||
<section class="content-area">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<h2>Import</h2>
|
|
||||||
</div>
|
|
||||||
<p>Import bookmarks and tags in the Netscape HTML format. This will execute a sync where new bookmarks are
|
|
||||||
added and existing ones are updated.</p>
|
|
||||||
<form method="post" enctype="multipart/form-data" action="{% url 'bookmarks:settings.import' %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="input-group col-8 col-md-12">
|
|
||||||
<input class="form-input" type="file" name="import_file">
|
|
||||||
<input type="submit" class="input-group-btn col-2 btn btn-primary" value="Upload">
|
|
||||||
</div>
|
|
||||||
{% if import_success_message %}
|
|
||||||
<div class="has-success">
|
|
||||||
<p class="form-input-hint">
|
|
||||||
{{ import_success_message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if import_errors_message %}
|
|
||||||
<div class="has-error">
|
|
||||||
<p class="form-input-hint">
|
|
||||||
{{ import_errors_message }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# Export section #}
|
|
||||||
<section class="content-area">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<h2>Export</h2>
|
|
||||||
</div>
|
|
||||||
<p>Export all bookmarks in Netscape HTML format.</p>
|
|
||||||
<a class="btn btn-primary" href="{% url 'bookmarks:settings.export' %}">Download (.html)</a>
|
|
||||||
{% if export_error %}
|
|
||||||
<div class="has-error">
|
|
||||||
<p class="form-input-hint">
|
|
||||||
{{ export_error }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# Integrations section #}
|
|
||||||
<section class="content-area">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<a id="bookmarklet"><h2>Bookmarklet</h2></a>
|
|
||||||
</div>
|
|
||||||
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
|
|
||||||
first. Here's how it works:</p>
|
|
||||||
<ul>
|
|
||||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
|
||||||
<li>Open the website that you want to bookmark</li>
|
|
||||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
|
||||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
|
||||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
|
||||||
</ul>
|
|
||||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
|
||||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
|
||||||
class="btn btn-primary">📎 Add bookmark</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{# API token section #}
|
|
||||||
<section class="content-area">
|
|
||||||
<div class="content-area-header">
|
|
||||||
<h2>API Token</h2>
|
|
||||||
</div>
|
|
||||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column col-6 col-md-12">
|
|
||||||
<input class="form-input" value="{{ api_token }}" disabled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p><strong>Please treat this token as you would any other credential.</strong> Any party with access to this token can access and manage all your bookmarks.</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
26
bookmarks/templates/settings/integrations.html
Normal file
26
bookmarks/templates/settings/integrations.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="settings-page">
|
||||||
|
|
||||||
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
|
{# Integrations section #}
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>Bookmarklet</h2>
|
||||||
|
<p>The bookmarklet is a quick way to add new bookmarks without opening the linkding application
|
||||||
|
first. Here's how it works:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||||
|
<li>Open the website that you want to bookmark</li>
|
||||||
|
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||||
|
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||||
|
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||||
|
</ul>
|
||||||
|
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
||||||
|
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
||||||
|
class="btn btn-primary">📎 Add bookmark</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
23
bookmarks/templates/settings/nav.html
Normal file
23
bookmarks/templates/settings/nav.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% url 'bookmarks:settings.index' as index_url %}
|
||||||
|
{% url 'bookmarks:settings.data' as data_url %}
|
||||||
|
{% url 'bookmarks:settings.integrations' as integrations_url %}
|
||||||
|
{% url 'bookmarks:settings.api' as api_url %}
|
||||||
|
|
||||||
|
<ul class="tab tab-block">
|
||||||
|
<li class="tab-item {% if request.get_full_path == index_url or request.get_full_path == data_url%}active{% endif %}">
|
||||||
|
<a href="{% url 'bookmarks:settings.data' %}">Data</a>
|
||||||
|
</li>
|
||||||
|
<li class="tab-item {% if request.get_full_path == integrations_url %}active{% endif %}">
|
||||||
|
<a href="{% url 'bookmarks:settings.integrations' %}">Integrations</a>
|
||||||
|
</li>
|
||||||
|
<li class="tab-item {% if request.get_full_path == api_url %}active{% endif %}">
|
||||||
|
<a href="{% url 'bookmarks:settings.api' %}">API</a>
|
||||||
|
</li>
|
||||||
|
<li class="tab-item tooltip tooltip-bottom" data-tooltip="The admin panel provides additional features 
 such as user management and bulk operations.">
|
||||||
|
<a href="{% url 'admin:index' %}" target="_blank">
|
||||||
|
<span>Admin</span>
|
||||||
|
<i class="icon icon-share ml-1" style="font-size: 12px"></i>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<br>
|
64
bookmarks/tests/helpers.py
Normal file
64
bookmarks/tests/helpers.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark, Tag
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarkFactoryMixin:
|
||||||
|
user = None
|
||||||
|
|
||||||
|
def get_or_create_test_user(self):
|
||||||
|
if self.user is None:
|
||||||
|
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
||||||
|
|
||||||
|
return self.user
|
||||||
|
|
||||||
|
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = [], user: User = None):
|
||||||
|
if user is None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
unique_id = get_random_string(length=32)
|
||||||
|
bookmark = Bookmark(
|
||||||
|
url='https://example.com/' + unique_id,
|
||||||
|
date_added=timezone.now(),
|
||||||
|
date_modified=timezone.now(),
|
||||||
|
owner=user,
|
||||||
|
is_archived=is_archived
|
||||||
|
)
|
||||||
|
bookmark.save()
|
||||||
|
for tag in tags:
|
||||||
|
bookmark.tags.add(tag)
|
||||||
|
bookmark.save()
|
||||||
|
return bookmark
|
||||||
|
|
||||||
|
def setup_tag(self, user: User = None):
|
||||||
|
if user is None:
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
name = get_random_string(length=32)
|
||||||
|
tag = Tag(name=name, date_added=timezone.now(), owner=user)
|
||||||
|
tag.save()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
class LinkdingApiTestCase(APITestCase):
|
||||||
|
def get(self, url, expected_status_code=status.HTTP_200_OK):
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, expected_status_code)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def post(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||||
|
response = self.client.post(url, data, format='json')
|
||||||
|
self.assertEqual(response.status_code, expected_status_code)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def put(self, url, data=None, expected_status_code=status.HTTP_200_OK):
|
||||||
|
response = self.client.put(url, data, format='json')
|
||||||
|
self.assertEqual(response.status_code, expected_status_code)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def delete(self, url, expected_status_code=status.HTTP_200_OK):
|
||||||
|
response = self.client.delete(url)
|
||||||
|
self.assertEqual(response.status_code, expected_status_code)
|
||||||
|
return response
|
138
bookmarks/tests/test_bookmarks_api.py
Normal file
138
bookmarks/tests/test_bookmarks_api.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
|
from bookmarks.models import Bookmark
|
||||||
|
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.api_token = Token.objects.get_or_create(user=self.get_or_create_test_user())[0]
|
||||||
|
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.api_token.key)
|
||||||
|
self.tag1 = self.setup_tag()
|
||||||
|
self.tag2 = self.setup_tag()
|
||||||
|
self.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2])
|
||||||
|
self.bookmark2 = self.setup_bookmark()
|
||||||
|
self.bookmark3 = self.setup_bookmark(tags=[self.tag2])
|
||||||
|
self.archived_bookmark1 = self.setup_bookmark(is_archived=True, tags=[self.tag1, self.tag2])
|
||||||
|
self.archived_bookmark2 = self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
|
def assertBookmarkListEqual(self, data_list, bookmarks):
|
||||||
|
expectations = []
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
tag_names = [tag.name for tag in bookmark.tags.all()]
|
||||||
|
tag_names.sort(key=str.lower)
|
||||||
|
expectation = OrderedDict()
|
||||||
|
expectation['id'] = bookmark.id
|
||||||
|
expectation['url'] = bookmark.url
|
||||||
|
expectation['title'] = bookmark.title
|
||||||
|
expectation['description'] = bookmark.description
|
||||||
|
expectation['website_title'] = bookmark.website_title
|
||||||
|
expectation['website_description'] = bookmark.website_description
|
||||||
|
expectation['tag_names'] = tag_names
|
||||||
|
expectation['date_added'] = bookmark.date_added.isoformat().replace('+00:00', 'Z')
|
||||||
|
expectation['date_modified'] = bookmark.date_modified.isoformat().replace('+00:00', 'Z')
|
||||||
|
expectations.append(expectation)
|
||||||
|
|
||||||
|
for data in data_list:
|
||||||
|
data['tag_names'].sort(key=str.lower)
|
||||||
|
|
||||||
|
self.assertCountEqual(data_list, expectations)
|
||||||
|
|
||||||
|
def test_create_bookmark(self):
|
||||||
|
data = {
|
||||||
|
'url': 'https://example.com/',
|
||||||
|
'title': 'Test title',
|
||||||
|
'description': 'Test description',
|
||||||
|
'tag_names': ['tag1', 'tag2']
|
||||||
|
}
|
||||||
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
|
bookmark = Bookmark.objects.get(url=data['url'])
|
||||||
|
self.assertEqual(bookmark.url, data['url'])
|
||||||
|
self.assertEqual(bookmark.title, data['title'])
|
||||||
|
self.assertEqual(bookmark.description, data['description'])
|
||||||
|
self.assertEqual(bookmark.tags.count(), 2)
|
||||||
|
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][0]).count(), 1)
|
||||||
|
self.assertEqual(bookmark.tags.filter(name=data['tag_names'][1]).count(), 1)
|
||||||
|
|
||||||
|
def test_create_bookmark_minimal_payload(self):
|
||||||
|
data = {'url': 'https://example.com/'}
|
||||||
|
self.post(reverse('bookmarks:bookmark-list'), data, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_list_bookmarks(self):
|
||||||
|
response = self.get(reverse('bookmarks:bookmark-list'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1, self.bookmark2, self.bookmark3])
|
||||||
|
|
||||||
|
def test_list_bookmarks_should_filter_by_query(self):
|
||||||
|
response = self.get(reverse('bookmarks:bookmark-list') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual(response.data['results'], [self.bookmark1])
|
||||||
|
|
||||||
|
def test_list_archived_bookmarks_does_not_return_unarchived_bookmarks(self):
|
||||||
|
response = self.get(reverse('bookmarks:bookmark-archived'), expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1, self.archived_bookmark2])
|
||||||
|
|
||||||
|
def test_list_archived_bookmarks_should_filter_by_query(self):
|
||||||
|
response = self.get(reverse('bookmarks:bookmark-archived') + '?q=#' + self.tag1.name, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual(response.data['results'], [self.archived_bookmark1])
|
||||||
|
|
||||||
|
def test_get_bookmark(self):
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertBookmarkListEqual([response.data], [self.bookmark1])
|
||||||
|
|
||||||
|
def test_update_bookmark(self):
|
||||||
|
data = {'url': 'https://example.com/'}
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
|
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||||
|
updated_bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||||
|
self.assertEqual(updated_bookmark.url, data['url'])
|
||||||
|
|
||||||
|
def test_delete_bookmark(self):
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||||
|
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
self.assertEqual(len(Bookmark.objects.filter(id=self.bookmark1.id)), 0)
|
||||||
|
|
||||||
|
def test_archive(self):
|
||||||
|
url = reverse('bookmarks:bookmark-archive', args=[self.bookmark1.id])
|
||||||
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
bookmark = Bookmark.objects.get(id=self.bookmark1.id)
|
||||||
|
self.assertTrue(bookmark.is_archived)
|
||||||
|
|
||||||
|
def test_unarchive(self):
|
||||||
|
url = reverse('bookmarks:bookmark-unarchive', args=[self.archived_bookmark1.id])
|
||||||
|
self.post(url, expected_status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
bookmark = Bookmark.objects.get(id=self.archived_bookmark1.id)
|
||||||
|
self.assertFalse(bookmark.is_archived)
|
||||||
|
|
||||||
|
def test_can_only_access_own_bookmarks(self):
|
||||||
|
other_user = User.objects.create_user('otheruser', 'otheruser@example.com', 'password123')
|
||||||
|
inaccessible_bookmark = self.setup_bookmark(user=other_user)
|
||||||
|
self.setup_bookmark(user=other_user, is_archived=True)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-list')
|
||||||
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['results']), 3)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-archived')
|
||||||
|
response = self.get(url, expected_status_code=status.HTTP_200_OK)
|
||||||
|
self.assertEqual(len(response.data['results']), 2)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
|
||||||
|
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
|
||||||
|
self.put(url, {url: 'https://example.com/'}, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-detail', args=[inaccessible_bookmark.id])
|
||||||
|
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-archive', args=[inaccessible_bookmark.id])
|
||||||
|
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
url = reverse('bookmarks:bookmark-unarchive', args=[inaccessible_bookmark.id])
|
||||||
|
self.post(url, expected_status_code=status.HTTP_404_NOT_FOUND)
|
@@ -1,38 +1,13 @@
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.utils import timezone
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
from bookmarks.models import Bookmark, Tag
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
class QueriesTestCase(TestCase):
|
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self.user = User.objects.create_user('testuser', 'test@example.com', 'password123')
|
|
||||||
|
|
||||||
def setup_bookmark(self, is_archived: bool = False, tags: [Tag] = []):
|
|
||||||
unique_id = get_random_string(length=32)
|
|
||||||
bookmark = Bookmark(
|
|
||||||
url='https://example.com/' + unique_id,
|
|
||||||
date_added=timezone.now(),
|
|
||||||
date_modified=timezone.now(),
|
|
||||||
owner=self.user,
|
|
||||||
is_archived=is_archived
|
|
||||||
)
|
|
||||||
bookmark.save()
|
|
||||||
for tag in tags:
|
|
||||||
bookmark.tags.add(tag)
|
|
||||||
bookmark.save()
|
|
||||||
return bookmark
|
|
||||||
|
|
||||||
def setup_tag(self):
|
|
||||||
name = get_random_string(length=32)
|
|
||||||
tag = Tag(name=name, date_added=timezone.now(), owner=self.user)
|
|
||||||
tag.save()
|
|
||||||
return tag
|
|
||||||
|
|
||||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||||
bookmark1 = self.setup_bookmark()
|
bookmark1 = self.setup_bookmark()
|
||||||
@@ -41,7 +16,7 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark(is_archived=True)
|
self.setup_bookmark(is_archived=True)
|
||||||
self.setup_bookmark(is_archived=True)
|
self.setup_bookmark(is_archived=True)
|
||||||
|
|
||||||
query = queries.query_bookmarks(self.user, '')
|
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||||
|
|
||||||
@@ -52,7 +27,7 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark()
|
self.setup_bookmark()
|
||||||
self.setup_bookmark()
|
self.setup_bookmark()
|
||||||
|
|
||||||
query = queries.query_archived_bookmarks(self.user, '')
|
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
self.assertCountEqual([bookmark1, bookmark2], list(query))
|
||||||
|
|
||||||
@@ -63,7 +38,7 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark()
|
self.setup_bookmark()
|
||||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||||
|
|
||||||
query = queries.query_bookmark_tags(self.user, '')
|
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([tag1], list(query))
|
self.assertCountEqual([tag1], list(query))
|
||||||
|
|
||||||
@@ -73,7 +48,7 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark(tags=[tag])
|
self.setup_bookmark(tags=[tag])
|
||||||
self.setup_bookmark(tags=[tag])
|
self.setup_bookmark(tags=[tag])
|
||||||
|
|
||||||
query = queries.query_bookmark_tags(self.user, '')
|
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([tag], list(query))
|
self.assertCountEqual([tag], list(query))
|
||||||
|
|
||||||
@@ -84,7 +59,7 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark()
|
self.setup_bookmark()
|
||||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||||
|
|
||||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([tag2], list(query))
|
self.assertCountEqual([tag2], list(query))
|
||||||
|
|
||||||
@@ -94,6 +69,6 @@ class QueriesTestCase(TestCase):
|
|||||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||||
|
|
||||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||||
|
|
||||||
self.assertCountEqual([tag], list(query))
|
self.assertCountEqual([tag], list(query))
|
||||||
|
@@ -19,10 +19,12 @@ urlpatterns = [
|
|||||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||||
# Settings
|
# Settings
|
||||||
path('settings', views.settings.index, name='settings.index'),
|
path('settings', views.settings.data, name='settings.index'),
|
||||||
|
path('settings/data', views.settings.data, name='settings.data'),
|
||||||
|
path('settings/integrations', views.settings.integrations, name='settings.integrations'),
|
||||||
|
path('settings/api', views.settings.api, name='settings.api'),
|
||||||
path('settings/import', views.settings.bookmark_import, name='settings.import'),
|
path('settings/import', views.settings.bookmark_import, name='settings.import'),
|
||||||
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
path('settings/export', views.settings.bookmark_export, name='settings.export'),
|
||||||
# API
|
# API
|
||||||
path('api/check_url', views.api.check_url, name='api.check_url'),
|
|
||||||
path('api/', include(router.urls), name='api')
|
path('api/', include(router.urls), name='api')
|
||||||
]
|
]
|
||||||
|
@@ -1,3 +1,2 @@
|
|||||||
from .api import *
|
|
||||||
from .bookmarks import *
|
from .bookmarks import *
|
||||||
from .settings import *
|
from .settings import *
|
||||||
|
@@ -1,27 +0,0 @@
|
|||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.forms import model_to_dict
|
|
||||||
from django.http import JsonResponse
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from bookmarks.services.website_loader import load_website_metadata
|
|
||||||
from bookmarks.models import Bookmark
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def check_url(request):
|
|
||||||
url = request.GET.get('url')
|
|
||||||
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
|
|
||||||
existing_bookmark_data = None
|
|
||||||
|
|
||||||
if bookmark is not None:
|
|
||||||
existing_bookmark_data = {
|
|
||||||
'id': bookmark.id,
|
|
||||||
'edit_url': reverse('bookmarks:edit', args=[bookmark.id])
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata = load_website_metadata(url)
|
|
||||||
|
|
||||||
return JsonResponse({
|
|
||||||
'bookmark': existing_bookmark_data,
|
|
||||||
'metadata': metadata.to_dict()
|
|
||||||
})
|
|
@@ -15,15 +15,27 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def index(request):
|
def data(request):
|
||||||
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
|
||||||
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
|
||||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
return render(request, 'settings/data.html', {
|
||||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
|
||||||
return render(request, 'settings/index.html', {
|
|
||||||
'import_success_message': import_success_message,
|
'import_success_message': import_success_message,
|
||||||
'import_errors_message': import_errors_message,
|
'import_errors_message': import_errors_message,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def integrations(request):
|
||||||
|
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||||
|
return render(request, 'settings/integrations.html', {
|
||||||
'application_url': application_url,
|
'application_url': application_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api(request):
|
||||||
|
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||||
|
return render(request, 'settings/api.html', {
|
||||||
'api_token': api_token.key
|
'api_token': api_token.key
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -49,7 +61,7 @@ def bookmark_import(request):
|
|||||||
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
|
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
|
return HttpResponseRedirect(reverse('bookmarks:settings.data'))
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -65,7 +77,7 @@ def bookmark_export(request):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
except:
|
except:
|
||||||
return render(request, 'settings/index.html', {
|
return render(request, 'settings/data.html', {
|
||||||
'export_error': 'An error occurred during bookmark export.'
|
'export_error': 'An error occurred during bookmark export.'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@@ -41,7 +41,7 @@ INSTALLED_APPS = [
|
|||||||
'widget_tweaks',
|
'widget_tweaks',
|
||||||
'django_generate_secret_key',
|
'django_generate_secret_key',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken'
|
'rest_framework.authtoken',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
@@ -13,13 +13,14 @@ Including another URLconf
|
|||||||
1. Import the include() function: from django.urls import include, path
|
1. Import the include() function: from django.urls import include, path
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
|
||||||
from django.contrib.auth import views as auth_views
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
|
|
||||||
|
from bookmarks.admin import linkding_admin_site
|
||||||
from .settings import ALLOW_REGISTRATION
|
from .settings import ALLOW_REGISTRATION
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', linkding_admin_site.urls),
|
||||||
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
|
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
|
||||||
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
|
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
|
||||||
name='login'),
|
name='login'),
|
||||||
|
@@ -1 +1 @@
|
|||||||
1.3.0
|
1.4.0
|
Reference in New Issue
Block a user