mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-15 14:39:24 +02:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e976fd054c | ||
![]() |
119d8f7efb | ||
![]() |
3e5e825032 | ||
![]() |
dc1f6f9c44 | ||
![]() |
9e0114ea49 | ||
![]() |
84508e07cd | ||
![]() |
496c5badbf | ||
![]() |
1c5d92dc73 | ||
![]() |
b11444f98e | ||
![]() |
ad070e7019 | ||
![]() |
6880c9ee56 |
@@ -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
|
||||
|
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
||||
# 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)
|
||||
|
||||
|
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"]
|
||||
|
40
README.md
40
README.md
@@ -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)
|
||||
|
@@ -7,7 +7,7 @@ from django.utils.translation import ngettext, gettext
|
||||
from rest_framework.authtoken.admin import TokenAdmin
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
|
||||
|
||||
|
||||
@@ -77,8 +77,24 @@ class AdminTag(admin.ModelAdmin):
|
||||
), 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, UserAdmin)
|
||||
linkding_admin_site.register(User, AdminCustomUser)
|
||||
linkding_admin_site.register(Token, TokenAdmin)
|
||||
|
@@ -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>
|
@@ -124,10 +124,4 @@
|
||||
.menu.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* TODO: Should be read from theme */
|
||||
.menu-item.selected > a {
|
||||
background: #f1f1fc;
|
||||
color: #5755d9;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
43
bookmarks/migrations/0007_userprofile.py
Normal file
43
bookmarks/migrations/0007_userprofile.py
Normal 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),
|
||||
]
|
@@ -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
BIN
bookmarks/static/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.9 KiB |
@@ -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
|
||||
@@ -57,4 +68,20 @@ h2 {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
27
bookmarks/styles/dark.scss
Normal file
27
bookmarks/styles/dark.scss
Normal 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;
|
||||
}
|
@@ -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";
|
17
bookmarks/styles/theme-dark.scss
Normal file
17
bookmarks/styles/theme-dark.scss
Normal 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";
|
14
bookmarks/styles/theme-light.scss
Normal file
14
bookmarks/styles/theme-light.scss
Normal 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";
|
28
bookmarks/styles/variables-dark.scss
Normal file
28
bookmarks/styles/variables-dark.scss
Normal 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;
|
4
bookmarks/styles/variables-light.scss
Normal file
4
bookmarks/styles/variables-light.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
$html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
@@ -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.data' %}">importing</a> your existing bookmarks or configuring the
|
||||
<a href="{% url 'bookmarks:settings.integrations' %}">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>
|
||||
|
@@ -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 }}
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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/>
|
||||
|
@@ -1,10 +1,26 @@
|
||||
{% 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">
|
||||
<h2>Import</h2>
|
@@ -7,8 +7,15 @@
|
||||
|
||||
{# 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 a quick way to add new bookmarks without opening the linkding application
|
||||
<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>
|
||||
|
@@ -1,11 +1,11 @@
|
||||
{% url 'bookmarks:settings.index' as index_url %}
|
||||
{% url 'bookmarks:settings.data' as data_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 == data_url%}active{% endif %}">
|
||||
<a href="{% url 'bookmarks:settings.data' %}">Data</a>
|
||||
<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>
|
||||
@@ -16,8 +16,11 @@
|
||||
<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>
|
||||
<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>
|
||||
<br>
|
||||
|
12
bookmarks/tests/test_user_profile_model.py
Normal file
12
bookmarks/tests/test_user_profile_model.py
Normal 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)
|
@@ -19,8 +19,8 @@ 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.data, name='settings.index'),
|
||||
path('settings/data', views.settings.data, name='settings.data'),
|
||||
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'),
|
||||
|
@@ -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,10 +16,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@login_required
|
||||
def data(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')
|
||||
return render(request, 'settings/data.html', {
|
||||
return render(request, 'settings/general.html', {
|
||||
'form': form,
|
||||
'import_success_message': import_success_message,
|
||||
'import_errors_message': import_errors_message,
|
||||
})
|
||||
@@ -61,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.data'))
|
||||
return HttpResponseRedirect(reverse('bookmarks:settings.general'))
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -77,7 +86,7 @@ def bookmark_export(request):
|
||||
|
||||
return response
|
||||
except:
|
||||
return render(request, 'settings/data.html', {
|
||||
return render(request, 'settings/general.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 \
|
||||
|
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,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,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
|
||||
|
@@ -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,
|
||||
|
@@ -17,7 +17,7 @@ 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
|
||||
from .settings import ALLOW_REGISTRATION, DEBUG
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', linkding_admin_site.urls),
|
||||
@@ -28,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')))
|
||||
|
@@ -1 +1 @@
|
||||
1.4.0
|
||||
1.5.0
|
||||
|
Reference in New Issue
Block a user