mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 06:29:21 +02:00
Compare commits
24 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
9e0114ea49 | ||
![]() |
84508e07cd | ||
![]() |
496c5badbf | ||
![]() |
1c5d92dc73 | ||
![]() |
b11444f98e | ||
![]() |
ad070e7019 | ||
![]() |
6880c9ee56 | ||
![]() |
e773ad1dc4 | ||
![]() |
a02338cdec | ||
![]() |
8c161ba119 | ||
![]() |
5644dae14e | ||
![]() |
58836c3c76 | ||
![]() |
b7a8f9e53d | ||
![]() |
afe081d3b5 | ||
![]() |
7a14c6e2d1 | ||
![]() |
f7e6fbc588 | ||
![]() |
778f1b2ff3 | ||
![]() |
79dd4179d2 | ||
![]() |
0980e6a2b2 | ||
![]() |
83ccf5279f | ||
![]() |
3bab7db023 | ||
![]() |
b6b7d3f662 | ||
![]() |
9c51487d3b | ||
![]() |
c61e8ee2cd |
@@ -5,13 +5,22 @@
|
||||
/node_modules
|
||||
/tmp
|
||||
/docs
|
||||
/static
|
||||
/build
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
/build-*.sh
|
||||
/Dockerfile
|
||||
/docker-compose.yml
|
||||
/*.sh
|
||||
/*.iml
|
||||
/package*.json
|
||||
/*.patch
|
||||
/*.md
|
||||
/*.js
|
||||
|
||||
# Whitelist files needed in build or prod image
|
||||
!/rollup.config.js
|
||||
!/bootstrap.sh
|
||||
|
||||
# Remove development settings
|
||||
/siteroot/settings/dev.py
|
||||
|
32
CHANGELOG.md
32
CHANGELOG.md
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## v1.4.0 (24/02/2021)
|
||||
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- [**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)
|
||||
|
58
Dockerfile
58
Dockerfile
@@ -1,22 +1,52 @@
|
||||
FROM python:3.7-slim-stretch
|
||||
|
||||
# Install packages required for uswgi
|
||||
RUN apt-get update
|
||||
RUN apt-get -y install build-essential
|
||||
RUN apt-get -y install mime-support
|
||||
|
||||
# Install requirements and uwsgi server for running python web apps
|
||||
FROM node:current-alpine AS node-build
|
||||
WORKDIR /etc/linkding
|
||||
COPY requirements.prod.txt ./requirements.txt
|
||||
RUN pip install -U pip
|
||||
RUN pip install -Ur requirements.txt
|
||||
|
||||
# Copy application
|
||||
# install build dependencies
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install -g npm && \
|
||||
npm install
|
||||
# compile JS components
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM python:3.9-slim AS python-base
|
||||
RUN apt-get update && apt-get -y install build-essential
|
||||
WORKDIR /etc/linkding
|
||||
|
||||
|
||||
FROM python-base AS python-build
|
||||
# install build dependencies
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN pip install -U pip && pip install -Ur requirements.txt
|
||||
# run Django part of the build
|
||||
COPY --from=node-build /etc/linkding .
|
||||
RUN python manage.py compilescss && \
|
||||
python manage.py collectstatic --ignore=*.scss && \
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
|
||||
FROM python-base AS prod-deps
|
||||
COPY requirements.prod.txt ./requirements.txt
|
||||
RUN mkdir /opt/venv && \
|
||||
python -m venv --upgrade-deps --copies /opt/venv && \
|
||||
/opt/venv/bin/pip install --upgrade pip wheel && \
|
||||
/opt/venv/bin/pip install -Ur requirements.txt
|
||||
|
||||
|
||||
FROM python:3.9-slim as final
|
||||
RUN apt-get update && apt-get -y install mime-support
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
COPY --from=prod-deps /opt/venv /opt/venv
|
||||
# copy output from build stage
|
||||
COPY --from=python-build /etc/linkding/static static/
|
||||
# copy application code
|
||||
COPY . .
|
||||
# Expose uwsgi server at port 9090
|
||||
EXPOSE 9090
|
||||
|
||||
# Activate virtual env
|
||||
ENV VIRTUAL_ENV /opt/venv
|
||||
ENV PATH /opt/venv/bin:$PATH
|
||||
# Run bootstrap logic
|
||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
37
README.md
37
README.md
@@ -1,12 +1,29 @@
|
||||
# linkding
|
||||
|
||||
*linkding* is a simple bookmark service that you can host yourself. It supports managing bookmarks, categorizing them with tags and has a search function. It provides a bookmarklet for quickly adding new bookmarks while browsing the web. It also supports import / export of bookmarks in the Netscape HTML format. And that's basically it 🙂.
|
||||
*linkding* is a simple bookmark service that you can host yourself.
|
||||
It's designed be to be minimal, fast and easy to set up using Docker.
|
||||
|
||||
The name comes from:
|
||||
- *link* which is often used as a synonym for URLs and bookmarks in common language
|
||||
- *Ding* which is german for *thing*
|
||||
- ...so basically some thing for managing your links
|
||||
|
||||
**Feature Overview:**
|
||||
- Tags for organizing bookmarks
|
||||
- Search by text or tags
|
||||
- Automatically provides titles and descriptions from linked websites
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Bookmark archive
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
|
||||
- Bookmarklet that should work in most browsers
|
||||
- Easy to set up using Docker
|
||||
- Uses SQLite as database
|
||||
- Works without Javascript
|
||||
- ...but has several UI enhancements when Javascript is enabled
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and bulk operations
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
||||
|
||||
**Screenshot:**
|
||||
@@ -15,7 +32,7 @@ The name comes from:
|
||||
|
||||
## Installation
|
||||
|
||||
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image should be compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
|
||||
|
||||
There is also the option to set up the installation manually which I do not support, but can give some pointers on below.
|
||||
|
||||
@@ -49,7 +66,7 @@ docker-compose up -d
|
||||
|
||||
### User setup
|
||||
|
||||
Finally you need to create a user so that you can access the frontend. Replace the credentials in the following command and run it:
|
||||
Finally you need to create a user so that you can access the application. Replace the credentials in the following command and run it:
|
||||
|
||||
**Docker**
|
||||
```shell
|
||||
@@ -67,10 +84,6 @@ The command will prompt you for a secure password. After the command has complet
|
||||
|
||||
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
|
||||
|
||||
### Options
|
||||
|
||||
Check the [options document](Options.md) on how to configure your linkding installation.
|
||||
|
||||
### Hosting
|
||||
|
||||
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
|
||||
@@ -78,17 +91,17 @@ The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedo
|
||||
- open the port that the application is running on in your servers firewall
|
||||
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
|
||||
|
||||
### Backups
|
||||
## Options
|
||||
|
||||
For backups you have two options: manually or automatic.
|
||||
Check the [options document](docs/Options.md) on how to configure your linkding installation.
|
||||
|
||||
For manual backups you can export your bookmarks from the UI and store them on a backup device or online service.
|
||||
## Backups
|
||||
|
||||
For automatic backups you want to backup the applications database. As described above, for production setups you should [mount](https://stackoverflow.com/questions/23439126/how-to-mount-a-host-directory-in-a-docker-container) the `/etc/linkding/data` directory from the Docker container to a directory on your host system. You can then use a backup tool of your choice to backup the contents of that directory.
|
||||
Check the [backups document](docs/backup.md) for options on how to create backups.
|
||||
|
||||
## API
|
||||
|
||||
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](API.md) for further information.
|
||||
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](docs/API.md) for further information.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
@@ -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.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):
|
||||
list_display = ('title', 'url', 'date_added')
|
||||
search_fields = ('title', 'url', 'tags__name')
|
||||
list_filter = ('tags',)
|
||||
ordering = ('-date_added', )
|
||||
list_display = ('resolved_title', 'url', 'is_archived', 'owner', 'date_added')
|
||||
search_fields = ('title', 'description', 'website_title', 'website_description', 'url', 'tags__name')
|
||||
list_filter = ('owner__username', 'is_archived', 'tags',)
|
||||
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):
|
||||
list_display = ('name', 'date_added', 'owner')
|
||||
search_fields = ('name', 'owner__username')
|
||||
list_filter = ('owner__username', )
|
||||
ordering = ('-date_added', )
|
||||
list_display = ('name', 'bookmarks_count', 'owner', 'date_added')
|
||||
search_fields = ('name', 'owner__username')
|
||||
list_filter = ('owner__username',)
|
||||
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.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -6,6 +7,8 @@ from rest_framework.routers import DefaultRouter
|
||||
from bookmarks import queries
|
||||
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
|
||||
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,
|
||||
@@ -39,6 +42,37 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
data = serializer(page, many=True).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,
|
||||
mixins.ListModelMixin,
|
||||
|
@@ -30,8 +30,11 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
'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
|
||||
tag_names = TagListField()
|
||||
tag_names = TagListField(required=False, default=[])
|
||||
|
||||
def create(self, validated_data):
|
||||
bookmark = Bookmark()
|
||||
|
@@ -52,4 +52,9 @@ h2 {
|
||||
// Remove left padding from first pagination link
|
||||
.pagination .page-item:first-child a {
|
||||
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%);
|
||||
}
|
||||
}
|
||||
|
||||
.actions .btn-link.bm-remove-confirm {
|
||||
color: $error-color;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-pagination {
|
||||
|
@@ -1,6 +1,11 @@
|
||||
.settings-page {
|
||||
section.content-area {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.0rem;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
|
@@ -32,8 +32,7 @@
|
||||
class="btn btn-link btn-sm">Archive</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'bookmarks:remove' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm"
|
||||
onclick="return confirm('Do you really want to delete this bookmark?')">Remove</a>
|
||||
class="btn btn-link btn-sm bm-remove">Remove</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@@ -42,3 +41,38 @@
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</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-subtitle">
|
||||
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.index' %}#bookmarklet">bookmarklet</a>.
|
||||
<a href="{% url 'bookmarks:settings.data' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">browser extension</a> or the <a href="{% url 'bookmarks:settings.integrations' %}">bookmarklet</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
@@ -97,7 +97,7 @@
|
||||
toggleIcon(descriptionInput, true);
|
||||
|
||||
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)
|
||||
.then(response => response.json())
|
||||
.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 %}
|
33
bookmarks/templates/settings/integrations.html
Normal file
33
bookmarks/templates/settings/integrations.html
Normal file
@@ -0,0 +1,33 @@
|
||||
{% extends "bookmarks/layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-page">
|
||||
|
||||
{% include 'settings/nav.html' %}
|
||||
|
||||
{# Integrations section #}
|
||||
<section class="content-area">
|
||||
<h2>Browser Extension</h2>
|
||||
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:</p>
|
||||
<ul>
|
||||
<li><a href="https://addons.mozilla.org/de/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
|
||||
</ul>
|
||||
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||
<h2>Bookmarklet</h2>
|
||||
<p>The bookmarklet is an alternative, cross-browser way to quickly 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.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.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase):
|
||||
|
||||
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
|
||||
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||
bookmark1 = self.setup_bookmark()
|
||||
@@ -41,7 +16,7 @@ class QueriesTestCase(TestCase):
|
||||
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))
|
||||
|
||||
@@ -52,7 +27,7 @@ class QueriesTestCase(TestCase):
|
||||
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))
|
||||
|
||||
@@ -63,7 +38,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark()
|
||||
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))
|
||||
|
||||
@@ -73,7 +48,7 @@ class QueriesTestCase(TestCase):
|
||||
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))
|
||||
|
||||
@@ -84,7 +59,7 @@ class QueriesTestCase(TestCase):
|
||||
self.setup_bookmark()
|
||||
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))
|
||||
|
||||
@@ -94,6 +69,6 @@ class QueriesTestCase(TestCase):
|
||||
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))
|
||||
|
@@ -19,10 +19,12 @@ urlpatterns = [
|
||||
path('bookmarks/<int:bookmark_id>/archive', views.bookmarks.archive, name='archive'),
|
||||
path('bookmarks/<int:bookmark_id>/unarchive', views.bookmarks.unarchive, name='unarchive'),
|
||||
# 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/export', views.settings.bookmark_export, name='settings.export'),
|
||||
# API
|
||||
path('api/check_url', views.api.check_url, name='api.check_url'),
|
||||
path('api/', include(router.urls), name='api')
|
||||
]
|
||||
|
@@ -1,3 +1,2 @@
|
||||
from .api import *
|
||||
from .bookmarks 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
|
||||
def index(request):
|
||||
def data(request):
|
||||
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')
|
||||
application_url = request.build_absolute_uri("/bookmarks/new")
|
||||
api_token = Token.objects.get_or_create(user=request.user)[0]
|
||||
return render(request, 'settings/index.html', {
|
||||
return render(request, 'settings/data.html', {
|
||||
'import_success_message': import_success_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,
|
||||
})
|
||||
|
||||
|
||||
@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
|
||||
})
|
||||
|
||||
@@ -49,7 +61,7 @@ def bookmark_import(request):
|
||||
messages.error(request, 'An error occurred during bookmark import.', 'bookmark_import_errors')
|
||||
pass
|
||||
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.index'))
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.data'))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -65,7 +77,7 @@ def bookmark_export(request):
|
||||
|
||||
return response
|
||||
except:
|
||||
return render(request, 'settings/index.html', {
|
||||
return render(request, 'settings/data.html', {
|
||||
'export_error': 'An error occurred during bookmark export.'
|
||||
})
|
||||
|
||||
|
@@ -2,7 +2,6 @@
|
||||
|
||||
version=$(<version.txt)
|
||||
|
||||
./build-static.sh
|
||||
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
|
||||
-t sissbruecker/linkding:latest \
|
||||
-t sissbruecker/linkding:$version \
|
||||
|
@@ -59,7 +59,7 @@ Example response:
|
||||
}
|
||||
```
|
||||
|
||||
**Archived**
|
||||
**List 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**
|
||||
|
||||
```
|
52
docs/backup.md
Normal file
52
docs/backup.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Backups
|
||||
|
||||
This page describes some options on how to create backups.
|
||||
|
||||
## What to backup
|
||||
|
||||
Linkding stores all data in a SQLite database, so all you need to backup are the contents of that database.
|
||||
|
||||
The location of the database file is `data/db.sqlite3` in the application folder.
|
||||
If you are using Docker then the full path in the Docker container is `/etc/linkding/data/db.sqlite`.
|
||||
As described in the installation docs, you should mount the `/etc/linkding/data` folder to a folder on your host system, from which you then can execute the backup.
|
||||
|
||||
Below, we describe several methods to create a backup of the database:
|
||||
|
||||
- Manual backup using the export function from the UI
|
||||
- Create a copy of the SQLite databse with the SQLite backup function
|
||||
- Create a plain textfile with the contents of the SQLite database with the SQLite dump function
|
||||
|
||||
Choose the method that fits you best.
|
||||
|
||||
## Exporting from the UI
|
||||
|
||||
The least technical option is to use the bookmark export in the UI.
|
||||
Go to the settings page and open the *Data* tab.
|
||||
Then click on the *Download* button to download an HTML file containing all your bookmarks.
|
||||
You can backup this file on a drive, or an online file host.
|
||||
|
||||
## Using the SQLite backup function
|
||||
|
||||
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
|
||||
|
||||
With this method you create a new SQLite database, which is a copy of your linkding database.
|
||||
This method uses the backup command in the [Command Line Shell For SQLite](https://sqlite.org/cli.html).
|
||||
```shell
|
||||
sqlite3 db.sqlite3 ".backup 'backup.sqlite3'"
|
||||
```
|
||||
After you have created the backup database `backup.sqlite` you have to move it to another system, for example with rsync.
|
||||
|
||||
## Using the SQLite dump function
|
||||
|
||||
Requires [SQLite](https://www.sqlite.org/index.html) to be installed on your host system.
|
||||
|
||||
With this method you create a plain text file with the SQL statements to recreate the SQLite database.
|
||||
|
||||
```shell
|
||||
sqlite3 db.sqlite3 .dump > backup.sql
|
||||
```
|
||||
|
||||
As this is a plain text file you can commit it to any revision management system, like git.
|
||||
Using git you can commit the changes, followed by a git push to a remote repository.
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 304 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -1,22 +1,18 @@
|
||||
beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
chardet==3.0.4
|
||||
Django==2.2.13
|
||||
django-appconf==1.0.3
|
||||
django-compressor==2.3
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==2.2.18
|
||||
django-generate-secret-key==1.0.2
|
||||
django-picklefield==2.0
|
||||
django-registration==3.0.1
|
||||
django-sass-processor==0.7.3
|
||||
django-widget-tweaks==1.4.5
|
||||
djangorestframework==3.11.1
|
||||
djangorestframework==3.11.2
|
||||
idna==2.8
|
||||
pyparsing==2.4.7
|
||||
pytz==2019.1
|
||||
rcssmin==1.0.6
|
||||
requests==2.22.0
|
||||
rjsmin==1.1.0
|
||||
six==1.12.0
|
||||
soupsieve==1.9.2
|
||||
sqlparse==0.3.0
|
||||
urllib3==1.25.3
|
||||
|
@@ -2,7 +2,7 @@ beautifulsoup4==4.7.1
|
||||
certifi==2019.6.16
|
||||
chardet==3.0.4
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==2.2.13
|
||||
Django==2.2.18
|
||||
django-appconf==1.0.3
|
||||
django-compressor==2.3
|
||||
django-generate-secret-key==1.0.2
|
||||
@@ -10,7 +10,7 @@ django-picklefield==2.0
|
||||
django-registration==3.0.1
|
||||
django-sass-processor==0.7.3
|
||||
django-widget-tweaks==1.4.5
|
||||
djangorestframework==3.11.1
|
||||
djangorestframework==3.11.2
|
||||
idna==2.8
|
||||
libsass==0.19.2
|
||||
pyparsing==2.4.7
|
||||
|
@@ -41,7 +41,7 @@ INSTALLED_APPS = [
|
||||
'widget_tweaks',
|
||||
'django_generate_secret_key',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken'
|
||||
'rest_framework.authtoken',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
@@ -13,13 +13,14 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
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.urls import path, include
|
||||
|
||||
from bookmarks.admin import linkding_admin_site
|
||||
from .settings import ALLOW_REGISTRATION
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('admin/', linkding_admin_site.urls),
|
||||
path('login/', auth_views.LoginView.as_view(redirect_authenticated_user=True,
|
||||
extra_context=dict(allow_registration=ALLOW_REGISTRATION)),
|
||||
name='login'),
|
||||
|
@@ -1 +1 @@
|
||||
1.3.0
|
||||
1.4.1
|
Reference in New Issue
Block a user