Compare commits

...

18 Commits

Author SHA1 Message Date
Sascha Ißbrücker
e976fd054c Bump version 2021-03-28 12:14:11 +02:00
Sascha Ißbrücker
119d8f7efb Implement dark theme (#49) 2021-03-28 12:11:56 +02:00
Sascha Ißbrücker
3e5e825032 Update CHANGELOG.md 2021-03-20 12:41:47 +01:00
Sascha Ißbrücker
dc1f6f9c44 Update CHANGELOG.md 2021-03-20 12:39:15 +01:00
Sascha Ißbrücker
9e0114ea49 Bump version 2021-03-20 12:36:30 +01:00
Sascha Ißbrücker
84508e07cd Doc improvements (#97)
* Improve docs

* Improve docs
2021-03-20 11:58:20 +01:00
mattofr
496c5badbf Add backup document (#89)
* Added backup document

* Improve and reference backup docs

Co-authored-by: matto <matto@matto.nl>
Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-20 07:01:19 +01:00
dependabot[bot]
1c5d92dc73 Bump djangorestframework from 3.11.1 to 3.11.2 (#96)
Bumps [djangorestframework](https://github.com/encode/django-rest-framework) from 3.11.1 to 3.11.2.
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.11.1...3.11.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 05:58:58 +01:00
dependabot[bot]
b11444f98e Bump django from 2.2.13 to 2.2.18 (#94)
Bumps [django](https://github.com/django/django) from 2.2.13 to 2.2.18.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/2.2.13...2.2.18)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 05:58:35 +01:00
stranger-danger-zamu
ad070e7019 Multistage Dockerfile (#90)
* Multistage Dockerfile

* Fix final stage and improve image size + layer caching

Co-authored-by: Sascha Ißbrücker <sissbruecker@lyska.io>
2021-03-13 11:24:44 +01:00
Sascha Ißbrücker
6880c9ee56 Update CHANGELOG.md 2021-02-24 03:49:43 +01:00
Sascha Ißbrücker
e773ad1dc4 Bump version 2021-02-24 03:37:25 +01:00
Sascha Ißbrücker
a02338cdec Improve and promote admin panel (#76)
* Improve and promote admin panel (#76)

* Customize admin panel texts (#76)

* Improve settings structure (#76)

* Improve admin list consistency (#76)

* Fix redirect URLs (#76)

* Add admin tooltip (#76)
2021-02-24 03:36:27 +01:00
Sascha Ißbrücker
8c161ba119 Implement bookmark API tests 2021-02-20 09:01:38 +01:00
Sascha Ißbrücker
5644dae14e Update CHANGELOG.md 2021-02-18 22:12:02 +01:00
Sascha Ißbrücker
58836c3c76 Bump version 2021-02-18 22:11:09 +01:00
Sascha Ißbrücker
b7a8f9e53d Mark optional fields in bookmark serializer (#78) 2021-02-18 22:02:45 +01:00
Sascha Ißbrücker
afe081d3b5 Update CHANGELOG.md 2021-02-18 07:39:40 +01:00
47 changed files with 879 additions and 210 deletions

View File

@@ -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

View File

@@ -1,7 +1,29 @@
# Changelog
## v1.4.1 (20/03/2021)
- Security patches
- Documentation improvements
---
## 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)
*No changelog for this release.*
[enhancement] Enhance delete links with inline confirmation
---

View File

@@ -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"]

View File

@@ -1,12 +1,30 @@
# 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
- Dark mode
- 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 +33,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 +67,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 +85,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 +92,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
@@ -148,4 +162,4 @@ The frontend is now available under http://localhost:8000
## Community
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)

View File

@@ -1,17 +1,100 @@
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, UserProfile
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
class LinkdingAdminSite(AdminSite):
site_header = 'linkding administration'
site_title = 'linkding Admin'
from bookmarks.models import Bookmark, Tag
@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)
class AdminUserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = 'Profile'
fk_name = 'user'
class AdminCustomUser(UserAdmin):
inlines = (AdminUserProfileInline,)
def get_inline_instances(self, request, obj=None):
if not obj:
return list()
return super(AdminCustomUser, self).get_inline_instances(request, obj)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(Token, TokenAdmin)

View File

@@ -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()

View File

@@ -264,17 +264,4 @@
z-index: 2;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
.group-item, .group-item:hover {
color: #999999;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
</style>

View File

@@ -124,10 +124,4 @@
.menu.open {
display: block;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
</style>
</style>

View File

@@ -0,0 +1,43 @@
# Generated by Django 2.2.18 on 2021-03-26 22:39
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def forwards(apps, schema_editor):
User = apps.get_model('auth', 'User')
UserProfile = apps.get_model('bookmarks', 'UserProfile')
for user in User.objects.all():
try:
if user.profile:
continue
except UserProfile.DoesNotExist:
profile = UserProfile(user=user)
profile.save()
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('bookmarks', '0006_bookmark_is_archived'),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('theme',
models.CharField(choices=[('auto', 'Auto'), ('light', 'Light'), ('dark', 'Dark')], default='auto',
max_length=10)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile',
to=settings.AUTH_USER_MODEL)),
],
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -2,7 +2,10 @@ from typing import List
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from bookmarks.utils import unique
from bookmarks.validators import BookmarkURLValidator
@@ -93,3 +96,33 @@ class BookmarkForm(forms.ModelForm):
class Meta:
model = Bookmark
fields = ['url', 'tag_string', 'title', 'description', 'auto_close', 'return_url']
class UserProfile(models.Model):
THEME_AUTO = 'auto'
THEME_LIGHT = 'light'
THEME_DARK = 'dark'
THEME_CHOICES = [
(THEME_AUTO, 'Auto'),
(THEME_LIGHT, 'Light'),
(THEME_DARK, 'Dark'),
]
user = models.OneToOneField(get_user_model(), related_name='profile', on_delete=models.CASCADE)
theme = models.CharField(max_length=10, choices=THEME_CHOICES, blank=False, default=THEME_AUTO)
class UserProfileForm(forms.ModelForm):
class Meta:
model = UserProfile
fields = ['theme']
@receiver(post_save, sender=get_user_model())
def create_user_profile(sender, instance, created, **kwargs):
if created:
UserProfile.objects.create(user=instance)
@receiver(post_save, sender=get_user_model())
def save_user_profile(sender, instance, **kwargs):
instance.profile.save()

BIN
bookmarks/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -10,20 +10,22 @@ header {
.navbar-brand {
display: flex;
align-items: center;
.logo {
background-color: $primary-color;
color: $light-color;
padding: 14px;
width: 28px;
height: 28px;
}
h1 {
text-transform: uppercase;
display: inline-block;
margin: 0 0 0 8px;
}
}
.dropdown-toggle {
padding: 0;
}
}
@@ -39,9 +41,18 @@ h2 {
color: $gray-color-dark;
}
// Button color should not change for anchor elements
.btn:visited:not(.btn-primary) {
color: $primary-color;
// Fix up visited styles
a:visited {
color: $link-color;
}
a:visited:hover {
color: $link-color-dark;
}
.btn-link:visited:not(.btn-primary) {
color: $link-color;
}
.btn-link:visited:not(.btn-primary):hover {
color: $link-color-dark;
}
// Increase spacing between columns
@@ -52,4 +63,25 @@ 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;
}
// Form auto-complete menu
.form-autocomplete .menu {
.menu-item.selected > a, .menu-item > a:hover {
background: $secondary-color;
color: $primary-color;
}
.group-item, .group-item:hover {
color: $gray-color;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
}

View File

@@ -39,7 +39,7 @@ ul.bookmark-list {
.description {
color: $gray-color-dark;
a {
a, a:visited:hover {
color: $alternative-color;
}
}
@@ -71,7 +71,7 @@ ul.bookmark-list {
.tag-cloud {
a {
a, a:visited:hover {
color: $alternative-color;
}

View File

@@ -0,0 +1,27 @@
/* Dark theme overrides */
/* Buttons */
.btn.btn-primary {
background: $dt-primary-button-color;
border-color: darken($dt-primary-button-color, 5%);
&:hover, &:active, &:focus {
background: darken($dt-primary-button-color, 5%);
border-color: darken($dt-primary-button-color, 10%);
}
}
/* Focus ring*/
a:focus, .btn:focus {
box-shadow: 0 0 0 .1rem rgba($primary-color, .5);
}
/* Forms */
.has-error .form-input, .form-input.is-error, .has-error .form-select, .form-select.is-error {
background: darken($error-color, 40%);
}
/* Pagination */
.pagination .page-item.active a {
background: $dt-primary-button-color;
}

View File

@@ -1,27 +0,0 @@
// Font sizes
$html-font-size: 18px !default;
//$alternative-color: #c84e00;
//$alternative-color: #FF84E8;
//$alternative-color: #98C1D9;
//$alternative-color: #7B287D;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import Spectre icons
@import "../../node_modules/spectre.css/src/icons/icons-core";
@import "../../node_modules/spectre.css/src/icons/icons-navigation";
@import "../../node_modules/spectre.css/src/icons/icons-action";
@import "../../node_modules/spectre.css/src/icons/icons-object";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";

View File

@@ -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] {

View File

@@ -0,0 +1,17 @@
// Import custom variables
@import "variables-dark";
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";
// Dark theme overrides
@import "dark";

View File

@@ -0,0 +1,14 @@
// Import custom variables
@import "variables-light";
// Import Spectre CSS lib
@import "../../node_modules/spectre.css/src/spectre";
@import "../../node_modules/spectre.css/src/autocomplete";
// Import style modules
@import "base";
@import "util";
@import "shared";
@import "bookmarks";
@import "settings";
@import "auth";

View File

@@ -0,0 +1,28 @@
$html-font-size: 18px !default;
$body-bg: #161822 !default;
$bg-color: lighten($body-bg, 5%) !default;
$bg-color-light: lighten($body-bg, 5%) !default;
$border-color: #4C4E53 !default;
$border-color-dark: $border-color !default;
$body-font-color: #b5bec8 !default;
$light-color: #fafafa !default;
$gray-color: #7f879b !default;
$gray-color-dark: lighten($gray-color, 20%) !default;
$primary-color: #a8b1ff !default;
$primary-color-dark: saturate($primary-color, 5%) !default;
$secondary-color: lighten($body-bg, 10%) !default;
$link-color: $primary-color !default;
$link-color-dark: darken($link-color, 5%) !default;
$link-color-light: $link-color !default;
$alternative-color: #59bdb9;
$alternative-color-dark: #73f1eb;
/* Dark theme specific */
$dt-primary-button-color: #5761cb !default;

View File

@@ -0,0 +1,4 @@
$html-font-size: 18px !default;
$alternative-color: #05a6a3;
$alternative-color-dark: darken($alternative-color, 5%);

View File

@@ -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.general' %}">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>

View File

@@ -7,7 +7,7 @@
{{ form.return_url|attr:"type:hidden" }}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
{{ form.url|add_class:"form-input"|attr:"autofocus"|attr:"placeholder: " }}
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}

View File

@@ -12,14 +12,24 @@
<meta name="author" content="Sascha Ißbrücker">
<title>linkding</title>
{# Include SASS styles, files are resolved from bookmarks/styles #}
<link href="{% sass_src 'index.scss' %}" rel="stylesheet" type="text/css"/>
{# Include specific theme variant based on user profile setting #}
{% if request.user.profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"/>
{% elif request.user.profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"/>
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
{% endif %}
</head>
<body>
<header class="navbar container grid-lg">
<section class="navbar-section">
<a href="/" class="navbar-brand text-bold">
<i class="logo icon icon-link s-circle"></i>
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
<h1>linkding</h1>
</a>
</section>

View File

@@ -7,12 +7,16 @@
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">
<i class="icon icon-plus"></i>
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</a>
<div class="dropdown dropdown-right">
<a href="#" id="mobile-nav-menu-trigger" class="btn btn-link dropdown-toggle" tabindex="0">
<i class="icon icon-menu icon-2x"></i>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<!-- menu component -->
<ul class="menu">
@@ -46,4 +50,4 @@
mobileNavMenuTrigger.addEventListener('blur', function () {
document.removeEventListener('click', mobileNavMenuOutsideClickHandler);
})
</script>
</script>

View File

@@ -20,11 +20,11 @@
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input' }}
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input' }}
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
</div>
<br/>

View 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 %}

View File

@@ -1,13 +1,29 @@
{% extends "bookmarks/layout.html" %}
{% load widget_tweaks %}
{% block content %}
<div class="settings-page">
{% include 'settings/nav.html' %}
{# Profile section #}
<section class="content-area">
<h2>Profile</h2>
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
{% csrf_token %}
<div class="form-group">
<label for="{{ form.theme.id_for_label }}" class="form-label">Theme</label>
{{ form.theme|add_class:"form-select col-2 col-sm-12" }}
</div>
<div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2">
</div>
</form>
</section>
{# Import section #}
<section class="content-area">
<div class="content-area-header">
<h2>Import</h2>
</div>
<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' %}">
@@ -37,9 +53,7 @@
{# Export section #}
<section class="content-area">
<div class="content-area-header">
<h2>Export</h2>
</div>
<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 %}
@@ -51,41 +65,6 @@
{% 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 %}

View 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 %}

View File

@@ -0,0 +1,26 @@
{% url 'bookmarks:settings.index' as index_url %}
{% url 'bookmarks:settings.general' as general_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 == general_url%}active{% endif %}">
<a href="{% url 'bookmarks:settings.general' %}">General</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 &#010; such as user management and bulk operations.">
<a href="{% url 'admin:index' %}" target="_blank">
<span>Admin</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="ml-1" style="width: 1.2em; height: 1.2em; vertical-align: -0.2em;">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</a>
</li>
</ul>
<br>

View 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

View 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)

View File

@@ -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))

View File

@@ -0,0 +1,12 @@
from django.test import TestCase
from django.contrib.auth.models import User
from bookmarks.models import UserProfile
class UserProfileTestCase(TestCase):
def test_create_user_should_init_profile(self):
user = User.objects.create_user('testuser', 'test@example.com', 'password123')
profile = UserProfile.objects.all().filter(user_id=user.id).first()
self.assertIsNotNone(profile)

View File

@@ -19,7 +19,10 @@ 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.general, name='settings.index'),
path('settings/general', views.settings.general, name='settings.general'),
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

View File

@@ -7,6 +7,7 @@ from django.shortcuts import render
from django.urls import reverse
from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm
from bookmarks.queries import query_bookmarks
from bookmarks.services.exporter import export_netscape_html
from bookmarks.services.importer import import_netscape_html
@@ -15,15 +16,35 @@ logger = logging.getLogger(__name__)
@login_required
def index(request):
def general(request):
if request.method == 'POST':
form = UserProfileForm(request.POST, instance=request.user.profile)
if form.is_valid():
form.save()
else:
form = UserProfileForm(instance=request.user.profile)
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/general.html', {
'form': form,
'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 +70,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.general'))
@login_required
@@ -65,7 +86,7 @@ def bookmark_export(request):
return response
except:
return render(request, 'settings/index.html', {
return render(request, 'settings/general.html', {
'export_error': 'An error occurred during bookmark export.'
})

View File

@@ -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 \

View File

52
docs/backup.md Normal file
View 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

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.3.2",
"version": "1.4.0",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -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

View File

@@ -2,15 +2,16 @@ 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-debug-toolbar==3.2
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
libsass==0.19.2
pyparsing==2.4.7

View File

@@ -41,7 +41,7 @@ INSTALLED_APPS = [
'widget_tweaks',
'django_generate_secret_key',
'rest_framework',
'rest_framework.authtoken'
'rest_framework.authtoken',
]
MIDDLEWARE = [

View File

@@ -11,6 +11,14 @@ DEBUG = True
# Turn on SASS compilation
SASS_PROCESSOR_ENABLED = True
# Enable debug toolbar
INSTALLED_APPS.append('debug_toolbar')
MIDDLEWARE.append('debug_toolbar.middleware.DebugToolbarMiddleware')
INTERNAL_IPS = [
'127.0.0.1',
]
# Enable debug logging
LOGGING = {
'version': 1,

View File

@@ -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 .settings import ALLOW_REGISTRATION
from bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION, DEBUG
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'),
@@ -27,5 +28,9 @@ urlpatterns = [
path('', include('bookmarks.urls')),
]
if DEBUG:
import debug_toolbar
urlpatterns.append(path('__debug__/', include(debug_toolbar.urls)))
if ALLOW_REGISTRATION:
urlpatterns.append(path('', include('django_registration.backends.one_step.urls')))

View File

@@ -1 +1 @@
1.3.2
1.5.0