mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-17 15:39:24 +02:00
Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
22e8750c24 | ||
![]() |
ac75fd2ebd | ||
![]() |
b05bf2534c | ||
![]() |
86a39e0433 | ||
![]() |
4220ea0b4c | ||
![]() |
5d48c64b2b | ||
![]() |
424df155d8 | ||
![]() |
d87611dbcb | ||
![]() |
cd66dcee7b | ||
![]() |
84f13dd792 | ||
![]() |
417dce785a | ||
![]() |
b28fc05d06 | ||
![]() |
17ab203f4f | ||
![]() |
a06f9035cf | ||
![]() |
5f28e87877 | ||
![]() |
f2ad826b11 | ||
![]() |
047d3be1b5 | ||
![]() |
43115fd8f2 | ||
![]() |
67ee896a46 | ||
![]() |
fd3070c6f3 | ||
![]() |
bc374e90a2 | ||
![]() |
a94eb5f85a | ||
![]() |
d1819c6503 | ||
![]() |
353ba433f0 | ||
![]() |
3af4e07eb6 | ||
![]() |
e9061f373a | ||
![]() |
f87398742a | ||
![]() |
81dc19958c | ||
![]() |
5049ff14cf | ||
![]() |
f9ab3d1f44 | ||
![]() |
b89e150088 | ||
![]() |
d17801ba84 | ||
![]() |
7b52663383 | ||
![]() |
0c86587b5d | ||
![]() |
74134d3896 | ||
![]() |
89a9271c71 |
29
.devcontainer/devcontainer.json
Normal file
29
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,29 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/python
|
||||
{
|
||||
"name": "Python 3",
|
||||
"image": "mcr.microsoft.com/devcontainers/python:0-3.10",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {}
|
||||
},
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [8000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "pip3 install --user -r requirements.txt && npm install && mkdir -p data && python3 manage.py migrate",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
"remoteUser": "vscode"
|
||||
}
|
@@ -6,12 +6,15 @@
|
||||
/tmp
|
||||
/docs
|
||||
/static
|
||||
/scripts
|
||||
/build
|
||||
/out
|
||||
/.git
|
||||
/.devcontainer
|
||||
|
||||
/.dockerignore
|
||||
/.gitignore
|
||||
/.gitattributes
|
||||
/Dockerfile
|
||||
/docker-compose.yml
|
||||
/*.sh
|
||||
|
@@ -45,3 +45,5 @@ LD_DB_HOST=
|
||||
# Port use to connect to the database server
|
||||
# Should use the default port if not set
|
||||
LD_DB_PORT=
|
||||
# Any additional options to pass to the database (default: {})
|
||||
LD_DB_OPTIONS=
|
||||
|
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
2
.github/workflows/main.yaml
vendored
2
.github/workflows/main.yaml
vendored
@@ -44,4 +44,4 @@ jobs:
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
- name: Run tests
|
||||
run: python manage.py test bookmarks.e2e
|
||||
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
||||
|
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
ignoreIssuesWith: [
|
||||
"wontfix",
|
||||
"duplicate"
|
||||
]
|
||||
}
|
60
CHANGELOG.md
60
CHANGELOG.md
@@ -1,5 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## v1.19.0 (20/05/2023)
|
||||
|
||||
### What's Changed
|
||||
* Add notes to bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/472
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.18.0...v1.19.0
|
||||
|
||||
---
|
||||
|
||||
## v1.18.0 (18/05/2023)
|
||||
|
||||
### What's Changed
|
||||
* Make search case-insensitive on Postgres by @sissbruecker in https://github.com/sissbruecker/linkding/pull/432
|
||||
* Allow searching for tags without hash character by @sissbruecker in https://github.com/sissbruecker/linkding/pull/449
|
||||
* Prevent zoom-in after focusing an input on small viewports on iOS devices by @puresick in https://github.com/sissbruecker/linkding/pull/440
|
||||
* Add database options by @plockaby in https://github.com/sissbruecker/linkding/pull/406
|
||||
* Allow to log real client ip in logs when using a reverse proxy by @fmenabe in https://github.com/sissbruecker/linkding/pull/398
|
||||
* Add option to display URL below title by @bah0 in https://github.com/sissbruecker/linkding/pull/365
|
||||
* Add LinkThing iOS app to community section by @amoscardino in https://github.com/sissbruecker/linkding/pull/446
|
||||
* Bump django from 4.1.7 to 4.1.9 by @dependabot in https://github.com/sissbruecker/linkding/pull/466
|
||||
* Bump sqlparse from 0.4.2 to 0.4.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/455
|
||||
|
||||
### New Contributors
|
||||
* @amoscardino made their first contribution in https://github.com/sissbruecker/linkding/pull/446
|
||||
* @puresick made their first contribution in https://github.com/sissbruecker/linkding/pull/440
|
||||
* @plockaby made their first contribution in https://github.com/sissbruecker/linkding/pull/406
|
||||
* @fmenabe made their first contribution in https://github.com/sissbruecker/linkding/pull/398
|
||||
* @bah0 made their first contribution in https://github.com/sissbruecker/linkding/pull/365
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.2...v1.18.0
|
||||
|
||||
---
|
||||
|
||||
## v1.17.2 (18/02/2023)
|
||||
|
||||
### What's Changed
|
||||
* Escape texts in exported HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/429
|
||||
* Bump django from 4.1.2 to 4.1.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/427
|
||||
* Make health check in Dockerfile honor context path setting by @mrex in https://github.com/sissbruecker/linkding/pull/407
|
||||
* Disable autocapitalization for tag input form by @joshdick in https://github.com/sissbruecker/linkding/pull/395
|
||||
|
||||
### New Contributors
|
||||
* @mrex made their first contribution in https://github.com/sissbruecker/linkding/pull/407
|
||||
* @joshdick made their first contribution in https://github.com/sissbruecker/linkding/pull/395
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.1...v1.17.2
|
||||
|
||||
---
|
||||
|
||||
## v1.17.1 (22/01/2023)
|
||||
|
||||
### What's Changed
|
||||
* Fix favicon being cleared by web archive snapshot task by @sissbruecker in https://github.com/sissbruecker/linkding/pull/405
|
||||
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.17.0...v1.17.1
|
||||
|
||||
---
|
||||
|
||||
## v1.17.0 (21/01/2023)
|
||||
|
||||
### What's Changed
|
||||
|
@@ -53,6 +53,6 @@ RUN ["chmod", "g+w", "."]
|
||||
RUN ["chmod", "+x", "./bootstrap.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/health || exit 1
|
||||
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
|
||||
|
||||
CMD ["./bootstrap.sh"]
|
||||
|
53
README.md
53
README.md
@@ -17,11 +17,12 @@
|
||||
- [Documentation](#documentation)
|
||||
- [Browser Extension](#browser-extension)
|
||||
- [Community](#community)
|
||||
- [Acknowledgements](#acknowledgements)
|
||||
- [Development](#development)
|
||||
|
||||
## Introduction
|
||||
|
||||
linkding is a simple bookmark service that you can host yourself.
|
||||
linkding is a bookmark manager that you can host yourself.
|
||||
It's designed be to be minimal, fast, and easy to set up using Docker.
|
||||
|
||||
The name comes from:
|
||||
@@ -30,22 +31,23 @@ The name comes from:
|
||||
- ...so basically something for managing your links
|
||||
|
||||
**Feature Overview:**
|
||||
- Clean UI optimized for readability
|
||||
- Organize bookmarks with tags
|
||||
- Add notes using Markdown
|
||||
- Read it later functionality
|
||||
- Share bookmarks with other users
|
||||
- Bulk editing
|
||||
- Bookmark archive
|
||||
- Automatically provides titles and descriptions of bookmarked websites
|
||||
- Automatically provides titles, descriptions and icons of bookmarked websites
|
||||
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
|
||||
- Import and export bookmarks in Netscape HTML format
|
||||
- Extensions for [Firefox](https://addons.mozilla.org/de/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
|
||||
- Light and dark themes
|
||||
- REST API for developing 3rd party apps
|
||||
- Admin panel for user self-service and raw data access
|
||||
- Easy setup using Docker, uses SQLite as database
|
||||
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
|
||||
|
||||
|
||||
**Demo:** https://demo.linkding.link/ (configured with open registration)
|
||||
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
|
||||
|
||||
**Screenshot:**
|
||||
|
||||
@@ -63,15 +65,13 @@ Alternatively linkding supports PostgreSQL, see the [database options](docs/Opti
|
||||
|
||||
To install linkding using Docker you can just run the [latest image](https://hub.docker.com/repository/docker/sissbruecker/linkding) from Docker Hub:
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
|
||||
```
|
||||
By default, the application runs on port `9090`, you can map it to a different host port by modifying the port mapping in the command above. If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090, provided you did not change the port mapping.
|
||||
|
||||
Note that the command above will store the linkding SQLite database in the container, which means that deleting the container, for example when upgrading the installation, will also remove the database. For hosting an actual installation you usually want to store the database on the host system, rather than in the container. To do so, run the following command, and replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database:
|
||||
```shell
|
||||
docker run --name linkding -p 9090:9090 -v {host-data-folder}:/etc/linkding/data -d sissbruecker/linkding:latest
|
||||
```
|
||||
|
||||
In the command above, replace the `{host-data-folder}` placeholder with an absolute path to a folder on your host system where you want to store the linkding database.
|
||||
|
||||
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
|
||||
|
||||
To upgrade the installation to a new version, remove the existing container, pull the latest version of the linkding Docker image, and then start a new container using the same command that you used above. There is a [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) available to automate these steps. The script can be configured using environment variables, or you can just modify it.
|
||||
|
||||
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
|
||||
@@ -101,6 +101,8 @@ docker-compose exec linkding python manage.py createsuperuser --username=joe --e
|
||||
|
||||
The command will prompt you for a secure password. After the command has completed you can start using the application by logging into the UI with your credentials.
|
||||
|
||||
Alternatively you can automatically create an initial superuser on startup using the [`LD_SUPERUSER_NAME` option](docs/Options.md#LD_SUPERUSER_NAME).
|
||||
|
||||
### Reverse Proxy Setup
|
||||
|
||||
When using a reverse proxy, such as Nginx or Apache, you may need to configure your proxy to correctly forward the `Host` header to linkding, otherwise certain requests, such as login, might fail.
|
||||
@@ -158,7 +160,7 @@ Instead of configuring header forwarding in your proxy, you can also configure t
|
||||
|
||||
### Managed Hosting Options
|
||||
|
||||
Self-hosting web applications on your own hardware (unfortunately) still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting linkding, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a (usually monthly) fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||
Self-hosting web applications still requires a lot of technical know-how, and commitment to maintenance, with regard to keeping everything up-to-date and secure. This can be a huge entry barrier for people who are interested in self-hosting, but lack the technical knowledge to do so. This section is intended to provide alternatives in form of managed hosting solutions. Note that these options are usually commercial offerings, that require paying a fee for the convenience of being managed by another party. The technical knowledge required to make use of individual options is going to vary, and no guarantees can be made that every option is accessible for everyone. That being said, I hope this section helps in making the application accessible to a wider audience.
|
||||
|
||||
- [linkding on fly.io](https://github.com/fspoettel/linkding-on-fly) - Guide for hosting a linkding installation on [fly.io](https://fly.io). By [fspoettel](https://github.com/fspoettel)
|
||||
- [PikaPods.com](https://www.pikapods.com/) - Managed hosting for linkding, EU and US regions available. [1-click setup link](https://www.pikapods.com/pods?run=linkding)
|
||||
@@ -171,6 +173,7 @@ Self-hosting web applications on your own hardware (unfortunately) still require
|
||||
| [Backups](https://github.com/sissbruecker/linkding/blob/master/docs/backup.md) | How to backup the linkding database |
|
||||
| [Troubleshooting](https://github.com/sissbruecker/linkding/blob/master/docs/troubleshooting.md) | Advice for troubleshooting common problems |
|
||||
| [How To](https://github.com/sissbruecker/linkding/blob/master/docs/how-to.md) | Tips and tricks around using linking |
|
||||
| [Keyboard shortcuts](https://github.com/sissbruecker/linkding/blob/master/docs/shortcuts.md) | List of available keyboard shortcuts |
|
||||
| [Admin documentation](https://github.com/sissbruecker/linkding/blob/master/docs/Admin.md) | User documentation for the Admin UI |
|
||||
| [API documentation](https://github.com/sissbruecker/linkding/blob/master/docs/API.md) | Documentation for the REST API |
|
||||
|
||||
@@ -186,13 +189,15 @@ The extension is open-source as well, and can be found [here](https://github.com
|
||||
|
||||
This section lists community projects around using linkding, in alphabetical order. If you have a project that you want to share with the linkding community, feel free to submit a PR to add your project to this section.
|
||||
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
|
||||
- [Linka!](https://github.com/cmsax/linka) Web app (also a PWA) for quickly searching & opening bookmarks in linkding, support multi keywords, exclude mode and other advance options. By [cmsax](https://github.com/cmsax)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [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-injector](https://github.com/Fivefold/linkding-injector) Injects search results from linkding into the sidebar of search pages like google and duckduckgo. Tested with Firefox and Chrome. By [Fivefold](https://github.com/Fivefold)
|
||||
- [aiolinkding](https://github.com/bachya/aiolinkding) A Python3, async library to interact with the linkding REST API. By [bachya](https://github.com/bachya)
|
||||
- [linkding-cli](https://github.com/bachya/linkding-cli) A command-line interface (CLI) to interact with the linkding REST API. Powered by [aiolinkding](https://github.com/bachya/aiolinkding). By [bachya](https://github.com/bachya)
|
||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
@@ -242,3 +247,23 @@ Start the Django development server with:
|
||||
python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
||||
### DevContainers
|
||||
|
||||
This repository also supports DevContainers: [](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=git@github.com:sissbruecker/linkding.git)
|
||||
|
||||
Once checked out, only the following commands are required to get started:
|
||||
|
||||
Create a user for the frontend:
|
||||
```
|
||||
python3 manage.py createsuperuser --username=joe --email=joe@example.com
|
||||
```
|
||||
Start the Node.js development server (used for compiling JavaScript components like tag auto-completion) with:
|
||||
```
|
||||
npm run dev
|
||||
```
|
||||
Start the Django development server with:
|
||||
```
|
||||
python3 manage.py runserver
|
||||
```
|
||||
The frontend is now available under http://localhost:8000
|
||||
|
@@ -23,7 +23,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
# For list action, use query set that applies search and tag projections
|
||||
if self.action == 'list':
|
||||
query_string = self.request.GET.get('q')
|
||||
return queries.query_bookmarks(user, query_string)
|
||||
return queries.query_bookmarks(user, user.profile, query_string)
|
||||
|
||||
# For single entity actions use default query set without projections
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
@@ -35,7 +35,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def archived(self, request):
|
||||
user = request.user
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_archived_bookmarks(user, query_string)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, query_string)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
@@ -45,7 +45,7 @@ class BookmarkViewSet(viewsets.GenericViewSet,
|
||||
def shared(self, request):
|
||||
filters = BookmarkFilters(request)
|
||||
user = User.objects.filter(username=filters.user).first()
|
||||
query_set = queries.query_shared_bookmarks(user, filters.query)
|
||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
|
@@ -27,6 +27,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
'url',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'is_archived',
|
||||
@@ -47,6 +48,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
# 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='')
|
||||
notes = serializers.CharField(required=False, allow_blank=True, default='')
|
||||
is_archived = serializers.BooleanField(required=False, default=False)
|
||||
unread = serializers.BooleanField(required=False, default=False)
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
@@ -58,6 +60,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
bookmark.url = validated_data['url']
|
||||
bookmark.title = validated_data['title']
|
||||
bookmark.description = validated_data['description']
|
||||
bookmark.notes = validated_data['notes']
|
||||
bookmark.is_archived = validated_data['is_archived']
|
||||
bookmark.unread = validated_data['unread']
|
||||
bookmark.shared = validated_data['shared']
|
||||
@@ -66,7 +69,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
|
||||
def update(self, instance: Bookmark, validated_data):
|
||||
# Update fields if they were provided in the payload
|
||||
for key in ['url', 'title', 'description', 'unread', 'shared']:
|
||||
for key in ['url', 'title', 'description', 'notes', 'unread', 'shared']:
|
||||
if key in validated_data:
|
||||
setattr(instance, key, validated_data[key])
|
||||
|
||||
|
@@ -119,7 +119,7 @@
|
||||
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
|
||||
<!-- autocomplete real input box -->
|
||||
<input id="{id}" name="{name}" value="{value ||''}" placeholder=" "
|
||||
class="form-input" type="text" autocomplete="off"
|
||||
class="form-input" type="text" autocomplete="off" autocapitalize="off"
|
||||
on:input={handleInput} on:keydown={handleKeyDown}
|
||||
on:focus={handleFocus} on:blur={handleBlur}>
|
||||
</div>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
@@ -8,6 +8,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_create_should_check_for_existing_bookmark(self):
|
||||
existing_bookmark = self.setup_bookmark(title='Existing title',
|
||||
description='Existing description',
|
||||
notes='Existing notes',
|
||||
tags=[self.setup_tag(name='tag1'), self.setup_tag(name='tag2')],
|
||||
website_title='Existing website title',
|
||||
website_description='Existing website description',
|
||||
@@ -26,6 +27,7 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
# Form should be pre-filled with data from existing bookmark
|
||||
self.assertEqual(existing_bookmark.title, page.get_by_label('Title').input_value())
|
||||
self.assertEqual(existing_bookmark.description, page.get_by_label('Description').input_value())
|
||||
self.assertEqual(existing_bookmark.notes, page.get_by_label('Notes').input_value())
|
||||
self.assertEqual(existing_bookmark.website_title, page.get_by_label('Title').get_attribute('placeholder'))
|
||||
self.assertEqual(existing_bookmark.website_description,
|
||||
page.get_by_label('Description').get_attribute('placeholder'))
|
||||
@@ -49,3 +51,17 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
page.wait_for_timeout(timeout=1000)
|
||||
page.get_by_text('This URL is already bookmarked.').wait_for(state='hidden')
|
||||
|
||||
def test_enter_url_of_existing_bookmark_should_show_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='Existing notes', description='Existing description')
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:new'))
|
||||
|
||||
details = page.locator('details.notes')
|
||||
expect(details).not_to_have_attribute('open', value='')
|
||||
|
||||
page.get_by_label('URL').fill(bookmark.url)
|
||||
expect(details).to_have_attribute('open', value='')
|
27
bookmarks/e2e/e2e_test_bookmark_list.py
Normal file
27
bookmarks/e2e/e2e_test_bookmark_list.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from unittest import skip
|
||||
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
@skip("Fails in CI, needs investigation")
|
||||
class BookmarkListE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_toggle_notes_should_show_hide_notes(self):
|
||||
self.setup_bookmark(notes='Test notes')
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
page.goto(self.live_server_url + reverse('bookmarks:index'))
|
||||
|
||||
notes = page.locator('li .notes')
|
||||
expect(notes).to_be_hidden()
|
||||
|
||||
toggle_notes = page.locator('li button.toggle-notes')
|
||||
toggle_notes.click()
|
||||
expect(notes).to_be_visible()
|
||||
|
||||
toggle_notes.click()
|
||||
expect(notes).to_be_hidden()
|
@@ -18,7 +18,7 @@ class BaseBookmarksFeed(Feed):
|
||||
def get_object(self, request, feed_key: str):
|
||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
||||
query_string = request.GET.get('q')
|
||||
query_set = queries.query_bookmarks(feed_token.user, query_string)
|
||||
query_set = queries.query_bookmarks(feed_token.user, feed_token.user.profile, query_string)
|
||||
return FeedContext(feed_token, query_set)
|
||||
|
||||
def item_title(self, item: Bookmark):
|
||||
|
24
bookmarks/management/commands/enable_wal.py
Normal file
24
bookmarks/management/commands/enable_wal.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Enable WAL journal mode when using an SQLite database"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
return
|
||||
|
||||
connection = connections['default']
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("PRAGMA journal_mode")
|
||||
current_mode = cursor.fetchone()[0]
|
||||
logger.info(f'Current journal mode: {current_mode}')
|
||||
if current_mode != 'wal':
|
||||
cursor.execute("PRAGMA journal_mode=wal;")
|
||||
logger.info('Switched to WAL journal mode')
|
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
18
bookmarks/migrations/0020_userprofile_tag_search.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-10 01:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0019_userprofile_enable_favicons'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='tag_search',
|
||||
field=models.CharField(choices=[('strict', 'Strict'), ('lax', 'Lax')], default='strict', max_length=10),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
18
bookmarks/migrations/0021_userprofile_display_url.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-05-18 07:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0020_userprofile_tag_search'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='display_url',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0022_bookmark_notes.py
Normal file
18
bookmarks/migrations/0022_bookmark_notes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-05-19 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0021_userprofile_display_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bookmark',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
18
bookmarks/migrations/0023_userprofile_permanent_notes.py
Normal file
18
bookmarks/migrations/0023_userprofile_permanent_notes.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.9 on 2023-05-20 08:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookmarks', '0022_bookmark_notes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userprofile',
|
||||
name='permanent_notes',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@@ -50,6 +50,7 @@ class Bookmark(models.Model):
|
||||
url = models.CharField(max_length=2048, validators=[BookmarkURLValidator()])
|
||||
title = models.CharField(max_length=512, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
notes = models.TextField(blank=True)
|
||||
website_title = models.CharField(max_length=512, blank=True, null=True)
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
@@ -110,6 +111,7 @@ class BookmarkForm(forms.ModelForm):
|
||||
'tag_string',
|
||||
'title',
|
||||
'description',
|
||||
'notes',
|
||||
'website_title',
|
||||
'website_description',
|
||||
'unread',
|
||||
@@ -117,6 +119,10 @@ class BookmarkForm(forms.ModelForm):
|
||||
'auto_close',
|
||||
]
|
||||
|
||||
@property
|
||||
def has_notes(self):
|
||||
return self.instance and self.instance.notes
|
||||
|
||||
|
||||
class BookmarkFilters:
|
||||
def __init__(self, request: WSGIRequest):
|
||||
@@ -153,6 +159,12 @@ class UserProfile(models.Model):
|
||||
(WEB_ARCHIVE_INTEGRATION_DISABLED, 'Disabled'),
|
||||
(WEB_ARCHIVE_INTEGRATION_ENABLED, 'Enabled'),
|
||||
]
|
||||
TAG_SEARCH_STRICT = 'strict'
|
||||
TAG_SEARCH_LAX = 'lax'
|
||||
TAG_SEARCH_CHOICES = [
|
||||
(TAG_SEARCH_STRICT, 'Strict'),
|
||||
(TAG_SEARCH_LAX, 'Lax'),
|
||||
]
|
||||
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)
|
||||
bookmark_date_display = models.CharField(max_length=10, choices=BOOKMARK_DATE_DISPLAY_CHOICES, blank=False,
|
||||
@@ -161,14 +173,19 @@ class UserProfile(models.Model):
|
||||
default=BOOKMARK_LINK_TARGET_BLANK)
|
||||
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
|
||||
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
|
||||
tag_search = models.CharField(max_length=10, choices=TAG_SEARCH_CHOICES, blank=False,
|
||||
default=TAG_SEARCH_STRICT)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
display_url = models.BooleanField(default=False, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
|
||||
|
||||
class UserProfileForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserProfile
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
|
||||
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'tag_search',
|
||||
'enable_sharing', 'enable_favicons', 'display_url', 'permanent_notes']
|
||||
|
||||
|
||||
@receiver(post_save, sender=get_user_model())
|
||||
|
@@ -1,29 +1,29 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.models import Q, QuerySet, Exists, OuterRef
|
||||
|
||||
from bookmarks.models import Bookmark, Tag
|
||||
from bookmarks.models import Bookmark, Tag, UserProfile
|
||||
from bookmarks.utils import unique
|
||||
|
||||
|
||||
def query_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(is_archived=False)
|
||||
|
||||
|
||||
def query_archived_bookmarks(user: User, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_archived_bookmarks(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(is_archived=True)
|
||||
|
||||
|
||||
def query_shared_bookmarks(user: Optional[User], query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, query_string) \
|
||||
def query_shared_bookmarks(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
return _base_bookmarks_query(user, profile, query_string) \
|
||||
.filter(shared=True) \
|
||||
.filter(owner__profile__enable_sharing=True)
|
||||
|
||||
|
||||
def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
def _base_bookmarks_query(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
query_set = Bookmark.objects
|
||||
|
||||
# Filter for user
|
||||
@@ -35,13 +35,17 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
|
||||
# Filter for search terms and tags
|
||||
for term in query['search_terms']:
|
||||
query_set = query_set.filter(
|
||||
Q(title__contains=term)
|
||||
| Q(description__contains=term)
|
||||
| Q(website_title__contains=term)
|
||||
| Q(website_description__contains=term)
|
||||
| Q(url__contains=term)
|
||||
)
|
||||
conditions = Q(title__icontains=term) \
|
||||
| Q(description__icontains=term) \
|
||||
| Q(notes__icontains=term) \
|
||||
| Q(website_title__icontains=term) \
|
||||
| Q(website_description__icontains=term) \
|
||||
| Q(url__icontains=term)
|
||||
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
conditions = conditions | Exists(Bookmark.objects.filter(id=OuterRef('id'), tags__name__iexact=term))
|
||||
|
||||
query_set = query_set.filter(conditions)
|
||||
|
||||
for tag_name in query['tag_names']:
|
||||
query_set = query_set.filter(
|
||||
@@ -65,32 +69,32 @@ def _base_bookmarks_query(user: Optional[User], query_string: str) -> QuerySet:
|
||||
return query_set
|
||||
|
||||
|
||||
def query_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_bookmarks(user, query_string)
|
||||
def query_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_archived_bookmark_tags(user: User, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_archived_bookmarks(user, query_string)
|
||||
def query_archived_bookmark_tags(user: User, profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_archived_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_tags(user: Optional[User], query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(user, query_string)
|
||||
def query_shared_bookmark_tags(user: Optional[User], profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(user, profile, query_string)
|
||||
|
||||
query_set = Tag.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
return query_set.distinct()
|
||||
|
||||
|
||||
def query_shared_bookmark_users(query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(None, query_string)
|
||||
def query_shared_bookmark_users(profile: UserProfile, query_string: str) -> QuerySet:
|
||||
bookmarks_query = query_shared_bookmarks(None, profile, query_string)
|
||||
|
||||
query_set = User.objects.filter(bookmark__in=bookmarks_query)
|
||||
|
||||
|
@@ -122,6 +122,7 @@ def untag_bookmarks(bookmark_ids: [Union[int, str]], tag_string: str, current_us
|
||||
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
|
||||
to_bookmark.title = from_bookmark.title
|
||||
to_bookmark.description = from_bookmark.description
|
||||
to_bookmark.notes = from_bookmark.notes
|
||||
to_bookmark.unread = from_bookmark.unread
|
||||
to_bookmark.shared = from_bookmark.shared
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from typing import List
|
||||
|
||||
from bookmarks.models import Bookmark
|
||||
@@ -28,8 +29,8 @@ def append_list_start(doc: BookmarkDocument):
|
||||
|
||||
def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
|
||||
url = bookmark.url
|
||||
title = bookmark.resolved_title
|
||||
desc = bookmark.resolved_description
|
||||
title = html.escape(bookmark.resolved_title or '')
|
||||
desc = html.escape(bookmark.resolved_description or '')
|
||||
tags = ','.join(bookmark.tag_names)
|
||||
toread = '1' if bookmark.unread else '0'
|
||||
added = int(bookmark.date_added.timestamp())
|
||||
|
@@ -71,8 +71,10 @@ def load_page(url: str):
|
||||
logger.debug(f'Loaded chunk (iteration={iteration}, total={size / 1024})')
|
||||
|
||||
# Stop reading if we have parsed end of head tag
|
||||
if '</head>'.encode('utf-8') in content:
|
||||
end_of_head = '</head>'.encode('utf-8')
|
||||
if end_of_head in content:
|
||||
logger.debug(f'Found closing head tag after {size} bytes')
|
||||
content = content.split(end_of_head)[0] + end_of_head
|
||||
break
|
||||
# Stop reading if we exceed limit
|
||||
if size > MAX_CONTENT_LIMIT:
|
||||
|
@@ -1,5 +1,13 @@
|
||||
(function () {
|
||||
function allowBulkEdit() {
|
||||
return !!document.getElementById('bulk-edit-mode');
|
||||
}
|
||||
|
||||
function setupBulkEdit() {
|
||||
if (!allowBulkEdit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bulkEditToggle = document.getElementById('bulk-edit-mode')
|
||||
const bulkEditBar = document.querySelector('.bulk-edit-bar')
|
||||
const singleToggles = document.querySelectorAll('.bulk-edit-toggle input')
|
||||
@@ -64,6 +72,10 @@
|
||||
}
|
||||
|
||||
function setupBulkEditTagAutoComplete() {
|
||||
if (!allowBulkEdit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
const tagInput = document.getElementById('bulk-edit-tags-input');
|
||||
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || '';
|
||||
@@ -121,7 +133,39 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setupNotes() {
|
||||
// Shortcut for toggling all notes
|
||||
document.addEventListener('keydown', function(event) {
|
||||
// Filter for shortcut key
|
||||
if (event.key !== 'e') return;
|
||||
// Skip if event occurred within an input element
|
||||
const targetNodeName = event.target.nodeName;
|
||||
const isInputTarget = targetNodeName === 'INPUT'
|
||||
|| targetNodeName === 'SELECT'
|
||||
|| targetNodeName === 'TEXTAREA';
|
||||
|
||||
if (isInputTarget) return;
|
||||
|
||||
const list = document.querySelector('.bookmark-list');
|
||||
list.classList.toggle('show-notes');
|
||||
});
|
||||
|
||||
// Toggle notes for single bookmark
|
||||
const bookmarks = document.querySelectorAll('.bookmark-list li');
|
||||
bookmarks.forEach(bookmark => {
|
||||
const toggleButton = bookmark.querySelector('.toggle-notes');
|
||||
if (toggleButton) {
|
||||
toggleButton.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
bookmark.classList.toggle('show-notes');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupBulkEdit();
|
||||
setupBulkEditTagAutoComplete();
|
||||
setupListNavigation();
|
||||
setupNotes();
|
||||
})()
|
||||
|
@@ -72,6 +72,12 @@ a:visited:hover {
|
||||
color: $link-color-dark;
|
||||
}
|
||||
|
||||
code {
|
||||
color: $gray-color-dark;
|
||||
background-color: $code-bg-color;
|
||||
box-shadow: 1px 1px 0 $code-shadow-color;
|
||||
}
|
||||
|
||||
// Increase spacing between columns
|
||||
.container > .columns > .column:not(:first-child) {
|
||||
padding-left: 2rem;
|
||||
@@ -102,3 +108,12 @@ a:visited:hover {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Increase input font size on small viewports to prevent zooming on focus the input
|
||||
// on mobile devices. 430px relates to the "normalized" iPhone 14 Pro Max
|
||||
// viewport size
|
||||
@media screen and (max-width: 430px) {
|
||||
.form-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
@@ -43,11 +43,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark list */
|
||||
ul.bookmark-list {
|
||||
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Bookmarks */
|
||||
ul.bookmark-list li {
|
||||
|
||||
.bulk-edit-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title a {
|
||||
display: inline-block;
|
||||
@@ -64,6 +72,10 @@ ul.bookmark-list {
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.url-display {
|
||||
color: $secondary-link-color;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: $gray-color-dark;
|
||||
|
||||
@@ -72,31 +84,44 @@ ul.bookmark-list {
|
||||
}
|
||||
}
|
||||
|
||||
.actions > *:not(:last-child) {
|
||||
margin-right: 0.1rem;
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions .date-label a {
|
||||
color: $gray-color;
|
||||
}
|
||||
|
||||
.actions .btn-link {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
.actions {
|
||||
> *:not(:last-child) {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-edit-toggle {
|
||||
display: none;
|
||||
a, button {
|
||||
color: $gray-color;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
vertical-align: unset;
|
||||
border: none;
|
||||
transition: none;
|
||||
text-decoration: none;
|
||||
|
||||
&:focus,
|
||||
&:hover,
|
||||
&:active,
|
||||
&.active {
|
||||
color: $gray-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toggle-notes {
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +201,69 @@ ul.bookmark-list {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes */
|
||||
ul.bookmark-list {
|
||||
.notes {
|
||||
display: none;
|
||||
max-height: 300px;
|
||||
margin: 4px 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&.show-notes .notes,
|
||||
li.show-notes .notes {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark notes markdown styles */
|
||||
ul.bookmark-list .notes-content {
|
||||
& {
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
|
||||
p, ul, ol, pre, blockquote {
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
|
||||
> *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin-left: 0.8rem;
|
||||
}
|
||||
|
||||
ul li, ol li {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: $code-bg-color;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> pre:first-child:last-child {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bookmark actions / bulk edit */
|
||||
|
@@ -21,8 +21,13 @@ $link-color: $primary-color !default;
|
||||
$link-color-dark: darken($link-color, 5%) !default;
|
||||
$link-color-light: $link-color !default;
|
||||
|
||||
$secondary-link-color: rgba(168, 177, 255, 0.73);
|
||||
|
||||
$alternative-color: #59bdb9;
|
||||
$alternative-color-dark: #73f1eb;
|
||||
|
||||
$code-bg-color: rgba(255, 255, 255, 0.1);
|
||||
$code-shadow-color: rgba(255, 255, 255, 0.2);
|
||||
|
||||
/* Dark theme specific */
|
||||
$dt-primary-button-color: #5761cb !default;
|
||||
|
@@ -2,3 +2,8 @@ $html-font-size: 18px !default;
|
||||
|
||||
$alternative-color: #05a6a3;
|
||||
$alternative-color-dark: darken($alternative-color, 5%);
|
||||
|
||||
$secondary-link-color: rgba(87, 85, 217, 0.64);
|
||||
|
||||
$code-bg-color: rgba(0, 0, 0, 0.05);
|
||||
$code-shadow-color: rgba(0, 0, 0, 0.15);
|
||||
|
@@ -1,102 +1,128 @@
|
||||
{% load static %}
|
||||
{% load shared %}
|
||||
{% load pagination %}
|
||||
{% htmlmin %}
|
||||
<ul class="bookmark-list">
|
||||
{% for bookmark in bookmarks %}
|
||||
<li data-is-bookmark-item>
|
||||
<label class="form-checkbox bulk-edit-toggle">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title">
|
||||
<ul class="bookmark-list{% if request.user.profile.permanent_notes %} show-notes{% endif %}">
|
||||
{% for bookmark in bookmarks %}
|
||||
<li data-is-bookmark-item>
|
||||
<label class="form-checkbox bulk-edit-toggle">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
<div class="title">
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
|
||||
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
{{ bookmark.resolved_title }}
|
||||
</a>
|
||||
</div>
|
||||
{% if request.user.profile.display_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
|
||||
class="{% if bookmark.unread %}text-italic{% endif %}">
|
||||
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
|
||||
<img src="{% static bookmark.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
{{ bookmark.resolved_title }}
|
||||
class="url-display text-sm">
|
||||
{{ bookmark.url }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
<span>
|
||||
{% endif %}
|
||||
<div class="description truncate">
|
||||
{% if bookmark.tag_names %}
|
||||
<span>
|
||||
{% for tag_name in bookmark.tag_names %}
|
||||
<a href="?{% append_to_query_param q=tag_name|hash_tag %}">{{ tag_name|hash_tag }}</a>
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||
{% if bookmark.resolved_description %}
|
||||
<span>{{ bookmark.resolved_description }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark.tag_names and bookmark.resolved_description %} | {% endif %}
|
||||
{% if bookmark.resolved_description %}
|
||||
<span>{{ bookmark.resolved_description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if bookmark.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
{% markdown bookmark.notes %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if request.user.profile.bookmark_date_display == 'relative' %}
|
||||
<span class="date-label text-gray text-sm">
|
||||
{% endif %}
|
||||
<div class="actions text-gray text-sm">
|
||||
{% if request.user.profile.bookmark_date_display == 'relative' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<span>∞</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
{% endif %}
|
||||
{% if request.user.profile.bookmark_date_display == 'absolute' %}
|
||||
<span class="date-label text-gray text-sm">
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<span>∞</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark.owner == request.user %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
<span>{{ bookmark.date_added|humanize_relative_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove
|
||||
</span>
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if request.user.profile.bookmark_date_display == 'absolute' %}
|
||||
<span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{{ link_target }}"
|
||||
rel="noopener">
|
||||
{% endif %}
|
||||
<span>{{ bookmark.date_added|humanize_absolute_date }}</span>
|
||||
{% if bookmark.web_archive_snapshot_url %}
|
||||
∞
|
||||
</a>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="separator">|</span>
|
||||
{% endif %}
|
||||
{% if bookmark.owner == request.user %}
|
||||
{# Bookmark owner actions #}
|
||||
<a href="{% url 'bookmarks:edit' bookmark.id %}?return_url={{ return_url }}">Edit</a>
|
||||
{% if bookmark.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% if bookmark.unread %}
|
||||
<span class="text-gray text-sm">|</span>
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Mark as read
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span class="text-gray text-sm">Shared by
|
||||
<a class="text-gray"
|
||||
href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
|
||||
</span>
|
||||
<button type="submit" name="archive" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<button type="submit" name="remove" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm btn-confirmation">Remove
|
||||
</button>
|
||||
{% if bookmark.unread %}
|
||||
<span class="separator">|</span>
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark.id }}"
|
||||
class="btn btn-link btn-sm">Mark as read
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark.owner.username %}">{{ bookmark.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark.notes and not request.user.profile.permanent_notes %}
|
||||
<span class="separator">|</span>
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
{% endhtmlmin %}
|
||||
<div class="bookmark-pagination">
|
||||
{% pagination bookmarks %}
|
||||
</div>
|
||||
|
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off" }}
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
@@ -67,6 +67,19 @@
|
||||
</div>
|
||||
{{ form.description.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details class="notes"{% if form.has_notes %} open{% endif %}>
|
||||
<summary>
|
||||
<span class="form-label d-inline-block">Notes</span>
|
||||
</summary>
|
||||
<label for="{{ form.notes.id_for_label }}" class="text-assistive">Notes</label>
|
||||
{{ form.notes|add_class:"form-input"|attr:"rows:8" }}
|
||||
<div class="form-input-hint">
|
||||
Additional notes, supports Markdown.
|
||||
</div>
|
||||
{{ form.notes.errors }}
|
||||
</details>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.unread.id_for_label }}" class="form-checkbox">
|
||||
{{ form.unread }}
|
||||
@@ -128,6 +141,8 @@
|
||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
|
||||
const notesDetails = document.querySelector('form details.notes');
|
||||
const notesInput = document.getElementById('{{ form.notes.id_for_label }}');
|
||||
const tagsInput = document.getElementById('{{ form.tag_string.id_for_label }}');
|
||||
const unreadCheckbox = document.getElementById('{{ form.unread.id_for_label }}');
|
||||
const sharedCheckbox = document.getElementById('{{ form.shared.id_for_label }}');
|
||||
@@ -149,11 +164,17 @@
|
||||
}
|
||||
|
||||
function updateInput(input, value) {
|
||||
input.value = value;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.value = value;
|
||||
}
|
||||
|
||||
function updateCheckbox(input, value) {
|
||||
input.checked = value;
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
input.checked = value;
|
||||
}
|
||||
|
||||
function checkUrl() {
|
||||
@@ -179,8 +200,10 @@
|
||||
|
||||
if (existingBookmark && !editedBookmarkId) {
|
||||
bookmarkExistsHint.style['display'] = 'block';
|
||||
notesDetails.open = !!existingBookmark.notes;
|
||||
updateInput(titleInput, existingBookmark.title);
|
||||
updateInput(descriptionInput, existingBookmark.description);
|
||||
updateInput(notesInput, existingBookmark.notes);
|
||||
updateInput(tagsInput, existingBookmark.tag_names.join(" "));
|
||||
updateCheckbox(unreadCheckbox, existingBookmark.unread);
|
||||
updateCheckbox(sharedCheckbox, existingBookmark.shared);
|
||||
@@ -201,6 +224,9 @@
|
||||
});
|
||||
}
|
||||
|
||||
setupEditAutoValueButton(titleInput);
|
||||
setupEditAutoValueButton(descriptionInput);
|
||||
|
||||
// Fetch initial website data if we have a URL, and we are not editing an existing bookmark
|
||||
// For existing bookmarks we get the website metadata through hidden inputs
|
||||
if (urlInput.value && !editedBookmarkId) {
|
||||
@@ -213,9 +239,6 @@
|
||||
updatePlaceholder(titleInput, websiteTitleInput.value);
|
||||
updatePlaceholder(descriptionInput, websiteDescriptionInput.value);
|
||||
}
|
||||
|
||||
setupEditAutoValueButton(titleInput);
|
||||
setupEditAutoValueButton(descriptionInput);
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
@@ -8,6 +8,8 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="{% static 'favicon.png' %}"/>
|
||||
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
|
||||
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
|
||||
<meta name="description" content="Self-hosted bookmark service">
|
||||
<meta name="robots" content="index,follow">
|
||||
|
@@ -45,4 +45,5 @@
|
||||
|
||||
<script src="{% static "bundle.js" %}"></script>
|
||||
<script src="{% static "shared.js" %}"></script>
|
||||
<script src="{% static "bookmark_list.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
@@ -4,7 +4,7 @@
|
||||
{% if has_selected_tags %}
|
||||
<p class="selected-tags">
|
||||
{% for tag in selected_tags %}
|
||||
<a href="?{% remove_from_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% remove_tag_from_query tag.name %}"
|
||||
class="text-bold mr-2">
|
||||
<span>-{{ tag.name }}</span>
|
||||
</a>
|
||||
@@ -17,14 +17,14 @@
|
||||
{% for tag in group.tags %}
|
||||
{# Highlight first char of first tag in group #}
|
||||
{% if forloop.counter == 1 %}
|
||||
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span
|
||||
class="highlight-char">{{ tag.name|first_char }}</span><span>{{ tag.name|remaining_chars:1 }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
{# Render remaining tags normally #}
|
||||
<a href="?{% append_to_query_param q=tag.name|hash_tag %}"
|
||||
<a href="?{% add_tag_to_query tag.name %}"
|
||||
class="mr-2" data-is-tag-item>
|
||||
<span>{{ tag.name }}</span>
|
||||
</a>
|
||||
|
@@ -29,6 +29,25 @@
|
||||
be hidden.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.display_url.id_for_label }}" class="form-checkbox">
|
||||
{{ form.display_url }}
|
||||
<i class="form-icon"></i> Show bookmark URL
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
When enabled, this setting displays the bookmark URL below the title.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.permanent_notes.id_for_label }}" class="form-checkbox">
|
||||
{{ form.permanent_notes }}
|
||||
<i class="form-icon"></i> Show notes permanently
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Whether to show bookmark notes permanently, without having to toggle them individually.
|
||||
Alternatively the keyboard shortcut <code>e</code> can be used to temporarily show all notes.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.bookmark_link_target.id_for_label }}" class="form-label">Open bookmarks in</label>
|
||||
{{ form.bookmark_link_target|add_class:"form-select col-2 col-sm-12" }}
|
||||
@@ -36,6 +55,15 @@
|
||||
Whether to open bookmarks a new page or in the same page.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
|
||||
{{ form.tag_search|add_class:"form-select col-2 col-sm-12" }}
|
||||
<div class="form-input-hint">
|
||||
In strict mode, tags must be prefixed with a hash character (#).
|
||||
In lax mode, tags can also be searched without the hash character.
|
||||
Note that tags without the hash character are indistinguishable from search terms, which means the search result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_favicons }}
|
||||
|
@@ -1,8 +1,13 @@
|
||||
import re
|
||||
|
||||
import bleach
|
||||
import markdown
|
||||
from bleach_allowlist import markdown_tags, markdown_attrs
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from bookmarks import utils
|
||||
from bookmarks.models import UserProfile
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -19,36 +24,39 @@ def update_query_string(context, **kwargs):
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def append_to_query_param(context, **kwargs):
|
||||
query = context.request.GET.copy()
|
||||
def add_tag_to_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
|
||||
# Append to or create query param
|
||||
for key in kwargs:
|
||||
if query.__contains__(key):
|
||||
value = query.__getitem__(key) + ' '
|
||||
else:
|
||||
value = ''
|
||||
value = value + kwargs[key]
|
||||
query.__setitem__(key, value)
|
||||
# Append to or create query string
|
||||
if params.__contains__('q'):
|
||||
query_string = params.__getitem__('q') + ' '
|
||||
else:
|
||||
query_string = ''
|
||||
query_string = query_string + '#' + tag_name
|
||||
params.__setitem__('q', query_string)
|
||||
|
||||
return query.urlencode()
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def remove_from_query_param(context, **kwargs):
|
||||
query = context.request.GET.copy()
|
||||
def remove_tag_from_query(context, tag_name: str):
|
||||
params = context.request.GET.copy()
|
||||
if params.__contains__('q'):
|
||||
# Split query string into parts
|
||||
query_string = params.__getitem__('q')
|
||||
query_parts = query_string.split()
|
||||
# Remove tag with hash
|
||||
tag_name_with_hash = '#' + tag_name
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name_with_hash)]
|
||||
# When using lax tag search, also remove tag without hash
|
||||
profile = context.request.user.profile
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
query_parts = [part for part in query_parts if str.lower(part) != str.lower(tag_name)]
|
||||
# Rebuild query string
|
||||
query_string = ' '.join(query_parts)
|
||||
params.__setitem__('q', query_string)
|
||||
|
||||
# Remove item from query param
|
||||
for key in kwargs:
|
||||
if query.__contains__(key):
|
||||
value = query.__getitem__(key)
|
||||
parts = value.split()
|
||||
part_to_remove = kwargs[key]
|
||||
updated_parts = [part for part in parts if str.lower(part) != str.lower(part_to_remove)]
|
||||
updated_value = ' '.join(updated_parts)
|
||||
query.__setitem__(key, updated_value)
|
||||
|
||||
return query.urlencode()
|
||||
return params.urlencode()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
@@ -109,3 +117,19 @@ class HtmlMinNode(template.Node):
|
||||
output = re.sub(r'\s+', ' ', output)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@register.simple_tag(name="markdown", takes_context=True)
|
||||
def render_markdown(context, markdown_text):
|
||||
# naive approach to reusing the renderer for a single request
|
||||
# works for bookmark list for now
|
||||
if not ('markdown_renderer' in context):
|
||||
renderer = markdown.Markdown(extensions=['fenced_code', 'nl2br'])
|
||||
context['markdown_renderer'] = renderer
|
||||
else:
|
||||
renderer = context['markdown_renderer']
|
||||
|
||||
as_html = renderer.convert(markdown_text)
|
||||
sanitized_html = bleach.clean(as_html, markdown_tags, markdown_attrs)
|
||||
|
||||
return mark_safe(sanitized_html)
|
||||
|
@@ -30,6 +30,7 @@ class BookmarkFactoryMixin:
|
||||
url: str = '',
|
||||
title: str = '',
|
||||
description: str = '',
|
||||
notes: str = '',
|
||||
website_title: str = '',
|
||||
website_description: str = '',
|
||||
web_archive_snapshot_url: str = '',
|
||||
@@ -48,6 +49,7 @@ class BookmarkFactoryMixin:
|
||||
url=url,
|
||||
title=title,
|
||||
description=description,
|
||||
notes=notes,
|
||||
website_title=website_title,
|
||||
website_description=website_description,
|
||||
date_added=timezone.now(),
|
||||
|
@@ -158,6 +158,37 @@ class BookmarkArchivedViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
|
||||
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.user.profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(tags=tags, is_archived=True)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:archived') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_open_bookmarks_in_new_page_by_default(self):
|
||||
visible_bookmarks = [
|
||||
self.setup_bookmark(is_archived=True),
|
||||
|
@@ -20,6 +20,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'tag_string': 'editedtag1 editedtag2',
|
||||
'title': 'edited title',
|
||||
'description': 'edited description',
|
||||
'notes': 'edited notes',
|
||||
'unread': False,
|
||||
'shared': False,
|
||||
}
|
||||
@@ -37,6 +38,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, form_data['url'])
|
||||
self.assertEqual(bookmark.title, form_data['title'])
|
||||
self.assertEqual(bookmark.description, form_data['description'])
|
||||
self.assertEqual(bookmark.notes, form_data['notes'])
|
||||
self.assertEqual(bookmark.unread, form_data['unread'])
|
||||
self.assertEqual(bookmark.shared, form_data['shared'])
|
||||
self.assertEqual(bookmark.tags.count(), 2)
|
||||
@@ -74,7 +76,8 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
bookmark = self.setup_bookmark(tags=[tag1, tag2], title='edited title', description='edited description',
|
||||
website_title='website title', website_description='website description')
|
||||
notes='edited notes', website_title='website title',
|
||||
website_description='website description')
|
||||
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
html = response.content.decode()
|
||||
@@ -87,7 +90,7 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
tag_string = build_tag_string(bookmark.tag_names, ' ')
|
||||
self.assertInHTML(f'''
|
||||
<input type="text" name="tag_string" value="{tag_string}"
|
||||
autocomplete="off" class="form-input" id="id_tag_string">
|
||||
autocomplete="off" autocapitalize="off" class="form-input" id="id_tag_string">
|
||||
''', html)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
@@ -101,6 +104,12 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
</textarea>
|
||||
''', html)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">
|
||||
{bookmark.notes}
|
||||
</textarea>
|
||||
''', html)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<input type="hidden" name="website_title" id="id_website_title"
|
||||
value="{bookmark.website_title}">
|
||||
@@ -184,3 +193,15 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
<span>Share</span>
|
||||
</label>
|
||||
''', html, count=1)
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
||||
def test_should_show_notes_if_there_are_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='test notes')
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes" open>', count=1)
|
||||
|
@@ -155,7 +155,38 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name}')
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q=%23{tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
def test_should_not_display_search_terms_from_query_as_selected_tags_in_strict_mode(self):
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(title=tags[0].name, tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[1]])
|
||||
|
||||
def test_should_display_search_terms_from_query_as_selected_tags_in_lax_mode(self):
|
||||
self.user.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.user.profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
self.setup_tag(),
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
response = self.client.get(reverse('bookmarks:index') + f'?q={tags[0].name}+%23{tags[1].name.upper()}')
|
||||
|
||||
self.assertSelectedTags(response, [tags[0], tags[1]])
|
||||
|
||||
@@ -196,8 +227,7 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
return_url = urllib.parse.quote_plus(url)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
''', html)
|
||||
|
||||
# with query params
|
||||
@@ -208,6 +238,5 @@ class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
return_url = urllib.parse.quote_plus(url)
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url={return_url}"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
<a href="{edit_url}?return_url={return_url}">Edit</a>
|
||||
''', html)
|
||||
|
@@ -19,6 +19,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'tag_string': 'tag1 tag2',
|
||||
'title': 'test title',
|
||||
'description': 'test description',
|
||||
'notes': 'test notes',
|
||||
'unread': False,
|
||||
'shared': False,
|
||||
'auto_close': '',
|
||||
@@ -37,6 +38,7 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, form_data['url'])
|
||||
self.assertEqual(bookmark.title, form_data['title'])
|
||||
self.assertEqual(bookmark.description, form_data['description'])
|
||||
self.assertEqual(bookmark.notes, form_data['notes'])
|
||||
self.assertEqual(bookmark.unread, form_data['unread'])
|
||||
self.assertEqual(bookmark.shared, form_data['shared'])
|
||||
self.assertEqual(bookmark.tags.count(), 2)
|
||||
@@ -73,6 +75,25 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'placeholder=" " autofocus class="form-input" required '
|
||||
'id="id_url">',
|
||||
html)
|
||||
|
||||
def test_should_prefill_title_from_url_parameter(self):
|
||||
response = self.client.get(reverse('bookmarks:new') + '?title=Example%20Title')
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<input type="text" name="title" value="Example Title" '
|
||||
'class="form-input" maxlength="512" autocomplete="off" '
|
||||
'id="id_title">',
|
||||
html)
|
||||
|
||||
def test_should_prefill_description_from_url_parameter(self):
|
||||
response = self.client.get(reverse('bookmarks:new') + '?description=Example%20Site%20Description')
|
||||
html = response.content.decode()
|
||||
|
||||
self.assertInHTML(
|
||||
'<textarea name="description" class="form-input" cols="40" '
|
||||
'rows="2" id="id_description">Example Site Description</textarea>',
|
||||
html)
|
||||
|
||||
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
|
||||
response = self.client.get(
|
||||
@@ -138,3 +159,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
<span>Share</span>
|
||||
</label>
|
||||
''', html, count=1)
|
||||
|
||||
def test_should_hide_notes_if_there_are_no_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
response = self.client.get(reverse('bookmarks:edit', args=[bookmark.id]))
|
||||
|
||||
self.assertContains(response, '<details class="notes">', count=1)
|
||||
|
@@ -20,7 +20,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
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.bookmark1 = self.setup_bookmark(tags=[self.tag1, self.tag2], notes='Test notes')
|
||||
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])
|
||||
@@ -36,6 +36,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
expectation['url'] = bookmark.url
|
||||
expectation['title'] = bookmark.title
|
||||
expectation['description'] = bookmark.description
|
||||
expectation['notes'] = bookmark.notes
|
||||
expectation['website_title'] = bookmark.website_title
|
||||
expectation['website_description'] = bookmark.website_description
|
||||
expectation['is_archived'] = bookmark.is_archived
|
||||
@@ -134,6 +135,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
'url': 'https://example.com/',
|
||||
'title': 'Test title',
|
||||
'description': 'Test description',
|
||||
'notes': 'Test notes',
|
||||
'is_archived': False,
|
||||
'unread': False,
|
||||
'shared': False,
|
||||
@@ -144,6 +146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, data['url'])
|
||||
self.assertEqual(bookmark.title, data['title'])
|
||||
self.assertEqual(bookmark.description, data['description'])
|
||||
self.assertEqual(bookmark.notes, data['notes'])
|
||||
self.assertFalse(bookmark.is_archived, data['is_archived'])
|
||||
self.assertFalse(bookmark.unread, data['unread'])
|
||||
self.assertFalse(bookmark.shared, data['shared'])
|
||||
@@ -157,6 +160,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
'url': original_bookmark.url,
|
||||
'title': 'Updated title',
|
||||
'description': 'Updated description',
|
||||
'notes': 'Updated notes',
|
||||
'unread': True,
|
||||
'shared': True,
|
||||
'is_archived': True,
|
||||
@@ -168,6 +172,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, data['url'])
|
||||
self.assertEqual(bookmark.title, data['title'])
|
||||
self.assertEqual(bookmark.description, data['description'])
|
||||
self.assertEqual(bookmark.notes, data['notes'])
|
||||
# Saving a duplicate bookmark should not modify archive flag - right?
|
||||
self.assertFalse(bookmark.is_archived)
|
||||
self.assertEqual(bookmark.unread, data['unread'])
|
||||
@@ -265,6 +270,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(updated_bookmark.url, data['url'])
|
||||
self.assertEqual(updated_bookmark.title, '')
|
||||
self.assertEqual(updated_bookmark.description, '')
|
||||
self.assertEqual(updated_bookmark.notes, '')
|
||||
self.assertEqual(updated_bookmark.tag_names, [])
|
||||
|
||||
def test_update_bookmark_unread_flag(self):
|
||||
@@ -300,6 +306,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.bookmark1.refresh_from_db()
|
||||
self.assertEqual(self.bookmark1.description, data['description'])
|
||||
|
||||
data = {'notes': 'Updated notes'}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
self.bookmark1.refresh_from_db()
|
||||
self.assertEqual(self.bookmark1.notes, data['notes'])
|
||||
|
||||
data = {'unread': True}
|
||||
url = reverse('bookmarks:bookmark-detail', args=[self.bookmark1.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
|
@@ -24,22 +24,22 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertDateLabel(self, html: str, label_content: str):
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<span>
|
||||
<span>{label_content}</span>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
def assertWebArchiveLink(self, html: str, label_content: str, url: str, link_target: str = '_blank'):
|
||||
self.assertInHTML(f'''
|
||||
<span class="date-label text-gray text-sm">
|
||||
<span>
|
||||
<a href="{url}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine" target="{link_target}" rel="noopener">
|
||||
<span>{label_content}</span>
|
||||
<span>∞</span>
|
||||
∞
|
||||
</a>
|
||||
</span>
|
||||
<span class="text-gray text-sm">|</span>
|
||||
<span class="separator">|</span>
|
||||
''', html)
|
||||
|
||||
def assertBookmarkActions(self, html: str, bookmark: Bookmark):
|
||||
@@ -52,8 +52,7 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
# Edit link
|
||||
edit_url = reverse('bookmarks:edit', args=[bookmark.id])
|
||||
self.assertInHTML(f'''
|
||||
<a href="{edit_url}?return_url=/test"
|
||||
class="btn btn-link btn-sm">Edit</a>
|
||||
<a href="{edit_url}?return_url=/test">Edit</a>
|
||||
''', html, count=count)
|
||||
# Archive link
|
||||
self.assertInHTML(f'''
|
||||
@@ -74,8 +73,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def assertShareInfoCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<span class="text-gray text-sm">Shared by
|
||||
<a class="text-gray" href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||
<span>Shared by
|
||||
<a href="?user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||
</span>
|
||||
''', html, count=count)
|
||||
|
||||
@@ -90,6 +89,47 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
<img src="/static/{bookmark.favicon_file}" alt="">
|
||||
''', html, count=count)
|
||||
|
||||
def assertBookmarkURLCount(self, html: str, bookmark: Bookmark, link_target: str = '_blank', count=0):
|
||||
self.assertInHTML(f'''
|
||||
<div class="url-path truncate">
|
||||
<a href="{bookmark.url}" target="{link_target}" rel="noopener"
|
||||
class="url-display text-sm">
|
||||
{bookmark.url}
|
||||
</a>
|
||||
</div>
|
||||
''', html, count)
|
||||
|
||||
def assertBookmarkURLVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertBookmarkURLCount(html, bookmark, count=1)
|
||||
|
||||
def assertBookmarkURLHidden(self, html: str, bookmark: Bookmark, link_target: str = '_blank'):
|
||||
self.assertBookmarkURLCount(html, bookmark, count=0)
|
||||
|
||||
def assertNotes(self, html: str, notes_html: str, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="notes-content">
|
||||
{notes_html}
|
||||
</div>
|
||||
</div>
|
||||
''', html, count=count)
|
||||
|
||||
def assertNotesToggle(self, html: str, count=1):
|
||||
self.assertInHTML(f'''
|
||||
<button class="btn btn-link btn-sm toggle-notes" title="Toggle notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-notes" width="16" height="16"
|
||||
viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M5 3m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z"></path>
|
||||
<path d="M9 7l6 0"></path>
|
||||
<path d="M9 11l6 0"></path>
|
||||
<path d="M9 15l4 0"></path>
|
||||
</svg>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
''', html, count=count)
|
||||
|
||||
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
@@ -218,8 +258,8 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
html = self.render_default_template([bookmark], url='/test?q=foo')
|
||||
|
||||
self.assertInHTML(f'''
|
||||
<span class="text-gray text-sm">Shared by
|
||||
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||
<span>Shared by
|
||||
<a href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
|
||||
</span>
|
||||
''', html)
|
||||
|
||||
@@ -252,3 +292,113 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertFaviconHidden(html, bookmark)
|
||||
|
||||
def test_bookmark_url_should_be_hidden_by_default(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLHidden(html, bookmark)
|
||||
|
||||
def test_show_bookmark_url_when_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.display_url = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLVisible(html, bookmark)
|
||||
|
||||
def test_hide_bookmark_url_when_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.display_url = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertBookmarkURLHidden(html, bookmark)
|
||||
|
||||
def test_without_notes(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertNotes(html, '', 0)
|
||||
self.assertNotesToggle(html, 0)
|
||||
|
||||
def test_with_notes(self):
|
||||
bookmark = self.setup_bookmark(notes='Test note')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
note_html = '<p>Test note</p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
def test_note_renders_markdown(self):
|
||||
bookmark = self.setup_bookmark(notes='**Example:** `print("Hello world!")`')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
note_html = '<p><strong>Example:</strong> <code>print("Hello world!")</code></p>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
def test_note_cleans_html(self):
|
||||
bookmark = self.setup_bookmark(notes='<script>alert("test")</script>')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
note_html = '<script>alert("test")</script>'
|
||||
self.assertNotes(html, note_html, 1)
|
||||
|
||||
def test_notes_are_hidden_initially_by_default(self):
|
||||
html = self.render_default_template([])
|
||||
|
||||
self.assertInHTML("""
|
||||
<ul class="bookmark-list"></ul>
|
||||
""", html)
|
||||
|
||||
def test_notes_are_hidden_initially_with_permanent_notes_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.permanent_notes = False
|
||||
profile.save()
|
||||
html = self.render_default_template([])
|
||||
|
||||
self.assertInHTML("""
|
||||
<ul class="bookmark-list"></ul>
|
||||
""", html)
|
||||
|
||||
def test_notes_are_visible_initially_with_permanent_notes_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.permanent_notes = True
|
||||
profile.save()
|
||||
html = self.render_default_template([])
|
||||
|
||||
self.assertInHTML("""
|
||||
<ul class="bookmark-list show-notes"></ul>
|
||||
""", html)
|
||||
|
||||
def test_toggle_notes_is_visible_by_default(self):
|
||||
bookmark = self.setup_bookmark(notes='Test note')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertNotesToggle(html, 1)
|
||||
|
||||
def test_toggle_notes_is_visible_with_permanent_notes_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.permanent_notes = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(notes='Test note')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertNotesToggle(html, 1)
|
||||
|
||||
def test_toggle_notes_is_hidden_with_permanent_notes_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.permanent_notes = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(notes='Test note')
|
||||
html = self.render_default_template([bookmark])
|
||||
|
||||
self.assertNotesToggle(html, 0)
|
||||
|
@@ -46,6 +46,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
bookmark_data = Bookmark(url='https://example.com',
|
||||
title='Updated Title',
|
||||
description='Updated description',
|
||||
notes='Updated notes',
|
||||
unread=True,
|
||||
shared=True,
|
||||
is_archived=True)
|
||||
@@ -55,6 +56,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(updated_bookmark.id, original_bookmark.id)
|
||||
self.assertEqual(updated_bookmark.title, bookmark_data.title)
|
||||
self.assertEqual(updated_bookmark.description, bookmark_data.description)
|
||||
self.assertEqual(updated_bookmark.notes, bookmark_data.notes)
|
||||
self.assertEqual(updated_bookmark.unread, bookmark_data.unread)
|
||||
self.assertEqual(updated_bookmark.shared, bookmark_data.shared)
|
||||
# Saving a duplicate bookmark should not modify archive flag - right?
|
||||
|
28
bookmarks/tests/test_exporter.py
Normal file
28
bookmarks/tests/test_exporter.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import exporter
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
|
||||
|
||||
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_escape_html_in_title_and_description(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
title='<style>: The Style Information element',
|
||||
description='The <style> HTML element contains style information for a document, or part of a document.'
|
||||
)
|
||||
html = exporter.export_netscape_html([bookmark])
|
||||
|
||||
self.assertIn('<style>: The Style Information element', html)
|
||||
self.assertIn(
|
||||
'The <style> HTML element contains style information for a document, or part of a document.',
|
||||
html
|
||||
)
|
||||
|
||||
def test_handle_empty_values(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
bookmark.title = ''
|
||||
bookmark.description = ''
|
||||
bookmark.website_title = None
|
||||
bookmark.website_description = None
|
||||
bookmark.save()
|
||||
exporter.export_netscape_html([bookmark])
|
33
bookmarks/tests/test_metadata_view.py
Normal file
33
bookmarks/tests/test_metadata_view.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
|
||||
class MetadataViewTestCase(TestCase):
|
||||
|
||||
def test_default_manifest(self):
|
||||
response = self.client.get("/manifest.json")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_body = response.json()
|
||||
expected_body = {
|
||||
"short_name": "linkding",
|
||||
"start_url": "bookmarks",
|
||||
"display": "standalone",
|
||||
"scope": "/"
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
||||
|
||||
@override_settings(LD_CONTEXT_PATH="linkding/")
|
||||
def test_manifest_respects_context_path(self):
|
||||
response = self.client.get("/manifest.json")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response_body = response.json()
|
||||
expected_body = {
|
||||
"short_name": "linkding",
|
||||
"start_url": "bookmarks",
|
||||
"display": "standalone",
|
||||
"scope": "/linkding/"
|
||||
}
|
||||
self.assertDictEqual(response_body, expected_body)
|
@@ -5,7 +5,7 @@ from django.db.models import QuerySet
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark
|
||||
from bookmarks.models import Bookmark, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, random_sentence
|
||||
from bookmarks.utils import unique
|
||||
|
||||
@@ -13,6 +13,8 @@ User = get_user_model()
|
||||
|
||||
|
||||
class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def setUp(self):
|
||||
self.profile = self.get_or_create_test_user().profile
|
||||
|
||||
def setup_bookmark_search_data(self) -> None:
|
||||
tag1 = self.setup_tag(name='tag1')
|
||||
@@ -27,9 +29,15 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.term1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(title=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(notes=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(notes=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='TERM1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='term1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='TERM1')),
|
||||
]
|
||||
self.term1_term2_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1/term2'),
|
||||
@@ -49,6 +57,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(website_title=random_sentence(), tags=[tag1]),
|
||||
self.setup_bookmark(website_description=random_sentence(), tags=[tag1]),
|
||||
]
|
||||
self.tag1_as_term_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/tag1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
|
||||
]
|
||||
self.term1_tag1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[tag1]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1]),
|
||||
@@ -76,9 +91,15 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.term1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(notes=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(notes=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='term1'), tags=[self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='TERM1'), tags=[self.setup_tag()]),
|
||||
]
|
||||
self.term1_term2_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1/term2', tags=[self.setup_tag()]),
|
||||
@@ -102,6 +123,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(website_title=random_sentence(), tags=[tag1, self.setup_tag()]),
|
||||
self.setup_bookmark(website_description=random_sentence(), tags=[tag1, self.setup_tag()]),
|
||||
]
|
||||
self.tag1_as_term_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/tag1'),
|
||||
self.setup_bookmark(title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(description=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_title=random_sentence(including_word='tag1')),
|
||||
self.setup_bookmark(website_description=random_sentence(including_word='tag1')),
|
||||
]
|
||||
self.term1_tag1_bookmarks = [
|
||||
self.setup_bookmark(url='http://example.com/term1', tags=[tag1, self.setup_tag()]),
|
||||
self.setup_bookmark(title=random_sentence(including_word='term1'), tags=[tag1, self.setup_tag()]),
|
||||
@@ -135,12 +163,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_return_all_for_empty_query(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
self.assertQueryResult(query, [
|
||||
self.other_bookmarks,
|
||||
self.term1_bookmarks,
|
||||
self.term1_term2_bookmarks,
|
||||
self.tag1_bookmarks,
|
||||
self.tag1_as_term_bookmarks,
|
||||
self.term1_tag1_bookmarks,
|
||||
self.tag2_bookmarks,
|
||||
self.tag1_tag2_bookmarks
|
||||
@@ -149,7 +178,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_search_single_term(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.term1_bookmarks,
|
||||
self.term1_term2_bookmarks,
|
||||
@@ -159,63 +188,101 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmarks_should_search_multiple_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term2 term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term2 term1')
|
||||
|
||||
self.assertQueryResult(query, [self.term1_term2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_single_tag(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_bookmarks, self.tag1_tag2_bookmarks, self.term1_tag1_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_multiple_tags(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #tag2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #tag2')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_multiple_tags_ignoring_casing(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#Tag1 #TAG2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#Tag1 #TAG2')
|
||||
|
||||
self.assertQueryResult(query, [self.tag1_tag2_bookmarks])
|
||||
|
||||
def test_query_bookmarks_should_search_terms_and_tags_combined(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag1')
|
||||
|
||||
self.assertQueryResult(query, [self.term1_tag1_bookmarks])
|
||||
|
||||
def test_query_bookmarks_in_strict_mode_should_not_search_tags_as_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [self.tag1_as_term_bookmarks])
|
||||
|
||||
def test_query_bookmarks_in_lax_mode_should_search_tags_as_terms(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_bookmarks,
|
||||
self.tag1_as_term_bookmarks,
|
||||
self.tag1_tag2_bookmarks,
|
||||
self.term1_tag1_bookmarks
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.term1_tag1_bookmarks,
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_tag2_bookmarks,
|
||||
])
|
||||
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'tag1 #tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.tag1_tag2_bookmarks,
|
||||
])
|
||||
|
||||
def test_query_bookmarks_should_return_no_matches(self):
|
||||
self.setup_bookmark_search_data()
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 term3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #tag2')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #tag2')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag3')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with tag that is used
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '#tag1 #unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '#tag1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with term that is used
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), 'term1 #unused_tag1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, 'term1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
def test_query_bookmarks_should_not_return_archived_bookmarks(self):
|
||||
@@ -225,7 +292,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[bookmark1, bookmark2]])
|
||||
|
||||
@@ -236,7 +303,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_archived_bookmarks(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[bookmark1, bookmark2]])
|
||||
|
||||
@@ -251,7 +318,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user)
|
||||
self.setup_bookmark(user=other_user)
|
||||
|
||||
query = queries.query_bookmarks(self.user, '')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [owned_bookmarks])
|
||||
|
||||
@@ -266,7 +333,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, user=other_user)
|
||||
self.setup_bookmark(is_archived=True, user=other_user)
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [owned_bookmarks])
|
||||
|
||||
@@ -276,7 +343,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
@@ -285,7 +352,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(title='term2')
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!untagged term1')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
@@ -294,7 +361,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_return_untagged_bookmarks_only(self):
|
||||
@@ -303,7 +370,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_be_combinable_with_search_terms(self):
|
||||
@@ -312,7 +379,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, title='term2')
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!untagged term1')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [untagged_bookmark])
|
||||
|
||||
def test_query_archived_bookmarks_untagged_should_not_be_combinable_with_tags(self):
|
||||
@@ -321,7 +388,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_bookmarks_unread_should_return_unread_bookmarks_only(self):
|
||||
@@ -334,7 +401,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
|
||||
query = queries.query_bookmarks(self.user, '!unread')
|
||||
query = queries.query_bookmarks(self.user, self.profile, '!unread')
|
||||
self.assertCountEqual(list(query), unread_bookmarks)
|
||||
|
||||
def test_query_archived_bookmarks_unread_should_return_unread_bookmarks_only(self):
|
||||
@@ -347,13 +414,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True)
|
||||
self.setup_bookmark(is_archived=True)
|
||||
|
||||
query = queries.query_archived_bookmarks(self.user, '!unread')
|
||||
query = queries.query_archived_bookmarks(self.user, self.profile, '!unread')
|
||||
self.assertCountEqual(list(query), unread_bookmarks)
|
||||
|
||||
def test_query_bookmark_tags_should_return_all_tags_for_empty_query(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.other_bookmarks),
|
||||
@@ -368,7 +435,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_single_term(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_bookmarks),
|
||||
@@ -379,7 +446,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term2 term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term2 term1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_term2_bookmarks),
|
||||
@@ -388,7 +455,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_single_tag(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_bookmarks),
|
||||
@@ -399,7 +466,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_tags(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#tag1 #tag2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #tag2')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
@@ -408,7 +475,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_multiple_tags_ignoring_casing(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '#Tag1 #TAG2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#Tag1 #TAG2')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
@@ -417,37 +484,75 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
def test_query_bookmark_tags_should_search_term_and_tag_combined(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, 'term1 #tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag1')
|
||||
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
|
||||
])
|
||||
|
||||
def test_query_bookmark_tags_in_strict_mode_should_not_search_tags_as_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_STRICT
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks))
|
||||
|
||||
def test_query_bookmark_tags_in_lax_mode_should_search_tags_as_terms(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
self.profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
self.profile.save()
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.tag1_as_term_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks)
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 term1')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.term1_tag1_bookmarks),
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'tag1 #tag2')
|
||||
self.assertQueryResult(query, [
|
||||
self.get_tags_from_bookmarks(self.tag1_tag2_bookmarks),
|
||||
])
|
||||
|
||||
def test_query_bookmark_tags_should_return_no_matches(self):
|
||||
self.setup_tag_search_data()
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 term3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 term3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #tag2')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #tag2')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag3')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag3')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with tag that is used
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '#tag1 #unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '#tag1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
# Unused tag combined with term that is used
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), 'term1 #unused_tag1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, 'term1 #unused_tag1')
|
||||
self.assertQueryResult(query, [])
|
||||
|
||||
def test_query_bookmark_tags_should_return_tags_for_unarchived_bookmarks_only(self):
|
||||
@@ -457,7 +562,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag1]])
|
||||
|
||||
@@ -467,7 +572,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag]])
|
||||
|
||||
@@ -478,7 +583,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(is_archived=True, tags=[tag2])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag2]])
|
||||
|
||||
@@ -488,7 +593,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.get_or_create_test_user(), '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [[tag]])
|
||||
|
||||
@@ -503,7 +608,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
self.setup_bookmark(user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
|
||||
|
||||
@@ -518,7 +623,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
self.setup_bookmark(is_archived=True, user=other_user, tags=[self.setup_tag(user=other_user)])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query, [self.get_tags_from_bookmarks(owned_bookmarks)])
|
||||
|
||||
@@ -529,13 +634,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(title='term1', tags=[tag])
|
||||
self.setup_bookmark(tags=[tag])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, '!untagged term1')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_archived_bookmark_tags_untagged_should_never_return_any_tags(self):
|
||||
@@ -545,13 +650,13 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(is_archived=True, title='term1', tags=[tag])
|
||||
self.setup_bookmark(is_archived=True, tags=[tag])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, '!untagged term1')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, '!untagged term1')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
query = queries.query_archived_bookmark_tags(self.user, f'!untagged #{tag.name}')
|
||||
query = queries.query_archived_bookmark_tags(self.user, self.profile, f'!untagged #{tag.name}')
|
||||
self.assertCountEqual(list(query), [])
|
||||
|
||||
def test_query_shared_bookmarks(self):
|
||||
@@ -574,14 +679,14 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=user4, shared=True, tags=[tag]),
|
||||
|
||||
# Should return shared bookmarks from all users
|
||||
query_set = queries.query_shared_bookmarks(None, '')
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, '')
|
||||
self.assertQueryResult(query_set, [shared_bookmarks])
|
||||
|
||||
# Should respect search query
|
||||
query_set = queries.query_shared_bookmarks(None, 'test title')
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, 'test title')
|
||||
self.assertQueryResult(query_set, [[shared_bookmarks[0]]])
|
||||
|
||||
query_set = queries.query_shared_bookmarks(None, '#' + tag.name)
|
||||
query_set = queries.query_shared_bookmarks(None, self.profile, '#' + tag.name)
|
||||
self.assertQueryResult(query_set, [[shared_bookmarks[2]]])
|
||||
|
||||
def test_query_shared_bookmark_tags(self):
|
||||
@@ -605,7 +710,7 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=user3, shared=False, tags=[self.setup_tag(user=user3)]),
|
||||
self.setup_bookmark(user=user4, shared=True, tags=[self.setup_tag(user=user4)]),
|
||||
|
||||
query_set = queries.query_shared_bookmark_tags(None, '')
|
||||
query_set = queries.query_shared_bookmark_tags(None, self.profile, '')
|
||||
|
||||
self.assertQueryResult(query_set, [shared_tags])
|
||||
|
||||
@@ -630,9 +735,9 @@ class QueriesTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.setup_bookmark(user=users_without_shared_bookmarks[2], shared=True),
|
||||
|
||||
# Should return users with shared bookmarks
|
||||
query_set = queries.query_shared_bookmark_users('')
|
||||
query_set = queries.query_shared_bookmark_users(self.profile, '')
|
||||
self.assertQueryResult(query_set, [users_with_shared_bookmarks])
|
||||
|
||||
# Should respect search query
|
||||
query_set = queries.query_shared_bookmark_users('test title')
|
||||
query_set = queries.query_shared_bookmark_users(self.profile, 'test title')
|
||||
self.assertQueryResult(query_set, [[users_with_shared_bookmarks[0]]])
|
||||
|
@@ -28,6 +28,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
|
||||
'enable_sharing': False,
|
||||
'enable_favicons': False,
|
||||
'tag_search': UserProfile.TAG_SEARCH_STRICT,
|
||||
'display_url': False,
|
||||
'permanent_notes': False,
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -52,6 +55,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
|
||||
'enable_sharing': True,
|
||||
'enable_favicons': True,
|
||||
'tag_search': UserProfile.TAG_SEARCH_LAX,
|
||||
'display_url': True,
|
||||
'permanent_notes': True,
|
||||
}
|
||||
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
|
||||
html = response.content.decode()
|
||||
@@ -65,6 +71,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
|
||||
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
|
||||
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
|
||||
self.assertEqual(self.user.profile.tag_search, form_data['tag_search'])
|
||||
self.assertEqual(self.user.profile.display_url, form_data['display_url'])
|
||||
self.assertEqual(self.user.profile.permanent_notes, form_data['permanent_notes'])
|
||||
self.assertInHTML('''
|
||||
<p class="form-input-hint">Profile updated</p>
|
||||
''', html)
|
||||
|
@@ -3,7 +3,7 @@ from typing import List
|
||||
from django.template import Template, RequestContext
|
||||
from django.test import TestCase, RequestFactory
|
||||
|
||||
from bookmarks.models import Tag
|
||||
from bookmarks.models import Tag, UserProfile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
rf = RequestFactory()
|
||||
request = rf.get(url)
|
||||
request.user = self.get_or_create_test_user()
|
||||
context = RequestContext(request, {
|
||||
'request': request,
|
||||
'tags': tags,
|
||||
@@ -118,6 +119,36 @@ class TagCloudTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
def test_selected_tags_with_lax_tag_search(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.tag_search = UserProfile.TAG_SEARCH_LAX
|
||||
profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(name='tag1'),
|
||||
self.setup_tag(name='tag2'),
|
||||
]
|
||||
|
||||
# Filter by tag name without hash
|
||||
rendered_template = self.render_template(tags, tags, url='/test?q=tag1 %23tag2')
|
||||
|
||||
self.assertNumSelectedTags(rendered_template, 2)
|
||||
|
||||
# Tag name should still be removed from query string
|
||||
self.assertInHTML('''
|
||||
<a href="?q=%23tag2"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag1</span>
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
self.assertInHTML('''
|
||||
<a href="?q=tag1"
|
||||
class="text-bold mr-2">
|
||||
<span>-tag2</span>
|
||||
</a>
|
||||
''', rendered_template)
|
||||
|
||||
def test_selected_tags_ignore_casing_when_removing_query_part(self):
|
||||
tags = [
|
||||
self.setup_tag(name='TEST'),
|
||||
|
@@ -59,7 +59,7 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
expected_content_size = 6 * 1024 * 1000
|
||||
self.assertEqual(expected_content_size, len(content))
|
||||
|
||||
def test_load_page_stops_reading_at_closing_head_tag(self):
|
||||
def test_load_page_stops_reading_at_end_of_head(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_get.return_value = MockStreamingResponse(num_chunks=10, chunk_size=1024 * 1000,
|
||||
insert_head_after_chunk=0)
|
||||
@@ -69,6 +69,18 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
expected_content_size = 1 * 1024 * 1000 + len('</head>')
|
||||
self.assertEqual(expected_content_size, len(content))
|
||||
|
||||
def test_load_page_removes_bytes_after_end_of_head(self):
|
||||
with mock.patch('requests.get') as mock_get:
|
||||
mock_response = MockStreamingResponse(num_chunks=1, chunk_size=0)
|
||||
mock_response.chunks[0] = '<head>人</head>'.encode('utf-8')
|
||||
# add a single byte that can't be decoded to utf-8
|
||||
mock_response.chunks[0] += 0xff.to_bytes(1, 'big')
|
||||
mock_get.return_value = mock_response
|
||||
content = website_loader.load_page('https://example.com')
|
||||
|
||||
# verify that byte after head was removed, content parsed as utf-8
|
||||
self.assertEqual(content, '<head>人</head>')
|
||||
|
||||
def test_load_website_metadata(self):
|
||||
with mock.patch('bookmarks.services.website_loader.load_page') as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document('test title', 'test description')
|
||||
|
@@ -32,5 +32,7 @@ urlpatterns = [
|
||||
path('feeds/<str:feed_key>/all', AllBookmarksFeed(), name='feeds.all'),
|
||||
path('feeds/<str:feed_key>/unread', UnreadBookmarksFeed(), name='feeds.unread'),
|
||||
# Health check
|
||||
path('health', views.health, name='health')
|
||||
path('health', views.health, name='health'),
|
||||
# Manifest
|
||||
path("manifest.json", views.manifest, name='manifest')
|
||||
]
|
||||
|
@@ -2,3 +2,4 @@ from .bookmarks import *
|
||||
from .settings import *
|
||||
from .toasts import *
|
||||
from .health import health
|
||||
from .manifest import manifest
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
@@ -9,7 +10,7 @@ from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, Tag, build_tag_string
|
||||
from bookmarks.models import Bookmark, BookmarkForm, BookmarkFilters, User, UserProfile, Tag, build_tag_string
|
||||
from bookmarks.services.bookmarks import create_bookmark, update_bookmark, archive_bookmark, archive_bookmarks, \
|
||||
unarchive_bookmark, unarchive_bookmarks, delete_bookmarks, tag_bookmarks, untag_bookmarks
|
||||
from bookmarks.utils import get_safe_return_url
|
||||
@@ -20,8 +21,8 @@ _default_page_size = 30
|
||||
@login_required
|
||||
def index(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query_set = queries.query_bookmarks(request.user, filters.query)
|
||||
tags = queries.query_bookmark_tags(request.user, filters.query)
|
||||
query_set = queries.query_bookmarks(request.user, request.user.profile, filters.query)
|
||||
tags = queries.query_bookmark_tags(request.user, request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:index')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/index.html', context)
|
||||
@@ -30,8 +31,8 @@ def index(request):
|
||||
@login_required
|
||||
def archived(request):
|
||||
filters = BookmarkFilters(request)
|
||||
query_set = queries.query_archived_bookmarks(request.user, filters.query)
|
||||
tags = queries.query_archived_bookmark_tags(request.user, filters.query)
|
||||
query_set = queries.query_archived_bookmarks(request.user, request.user.profile, filters.query)
|
||||
tags = queries.query_archived_bookmark_tags(request.user, request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:archived')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
return render(request, 'bookmarks/archive.html', context)
|
||||
@@ -41,27 +42,23 @@ def archived(request):
|
||||
def shared(request):
|
||||
filters = BookmarkFilters(request)
|
||||
user = User.objects.filter(username=filters.user).first()
|
||||
query_set = queries.query_shared_bookmarks(user, filters.query)
|
||||
tags = queries.query_shared_bookmark_tags(user, filters.query)
|
||||
users = queries.query_shared_bookmark_users(filters.query)
|
||||
query_set = queries.query_shared_bookmarks(user, request.user.profile, filters.query)
|
||||
tags = queries.query_shared_bookmark_tags(user, request.user.profile, filters.query)
|
||||
users = queries.query_shared_bookmark_users(request.user.profile, filters.query)
|
||||
base_url = reverse('bookmarks:shared')
|
||||
context = get_bookmark_view_context(request, filters, query_set, tags, base_url)
|
||||
context['users'] = users
|
||||
return render(request, 'bookmarks/shared.html', context)
|
||||
|
||||
|
||||
def _get_selected_tags(tags: QuerySet[Tag], query_string: str):
|
||||
def _get_selected_tags(tags: List[Tag], query_string: str, profile: UserProfile):
|
||||
parsed_query = queries.parse_query_string(query_string)
|
||||
tag_names = parsed_query['tag_names']
|
||||
if profile.tag_search == UserProfile.TAG_SEARCH_LAX:
|
||||
tag_names = tag_names + parsed_query['search_terms']
|
||||
tag_names = [tag_name.lower() for tag_name in tag_names]
|
||||
|
||||
if len(tag_names) == 0:
|
||||
return []
|
||||
|
||||
condition = Q()
|
||||
for tag_name in parsed_query['tag_names']:
|
||||
condition = condition | Q(name__iexact=tag_name)
|
||||
|
||||
return list(tags.filter(condition))
|
||||
return [tag for tag in tags if tag.name.lower() in tag_names]
|
||||
|
||||
|
||||
def get_bookmark_view_context(request: WSGIRequest,
|
||||
@@ -72,7 +69,8 @@ def get_bookmark_view_context(request: WSGIRequest,
|
||||
page = request.GET.get('page')
|
||||
paginator = Paginator(query_set, _default_page_size)
|
||||
bookmarks = paginator.get_page(page)
|
||||
selected_tags = _get_selected_tags(tags, filters.query)
|
||||
tags = list(tags)
|
||||
selected_tags = _get_selected_tags(tags, filters.query, request.user.profile)
|
||||
# Prefetch related objects, this avoids n+1 queries when accessing fields in templates
|
||||
prefetch_related_objects(bookmarks.object_list, 'owner', 'tags')
|
||||
return_url = generate_return_url(base_url, page, filters)
|
||||
@@ -116,6 +114,8 @@ def convert_tag_string(tag_string: str):
|
||||
@login_required
|
||||
def new(request):
|
||||
initial_url = request.GET.get('url')
|
||||
initial_title = request.GET.get('title')
|
||||
initial_description = request.GET.get('description')
|
||||
initial_auto_close = 'auto_close' in request.GET
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -133,6 +133,10 @@ def new(request):
|
||||
form = BookmarkForm()
|
||||
if initial_url:
|
||||
form.initial['url'] = initial_url
|
||||
if initial_title:
|
||||
form.initial['title'] = initial_title
|
||||
if initial_description:
|
||||
form.initial['description'] = initial_description
|
||||
if initial_auto_close:
|
||||
form.initial['auto_close'] = 'true'
|
||||
|
||||
|
13
bookmarks/views/manifest.py
Normal file
13
bookmarks/views/manifest.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.http import JsonResponse
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def manifest(request):
|
||||
response = {
|
||||
"short_name": "linkding",
|
||||
"start_url": "bookmarks",
|
||||
"display": "standalone",
|
||||
"scope": "/" + settings.LD_CONTEXT_PATH
|
||||
}
|
||||
|
||||
return JsonResponse(response, status=200)
|
@@ -141,7 +141,7 @@ def bookmark_import(request):
|
||||
def bookmark_export(request):
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
bookmarks = list(query_bookmarks(request.user, ''))
|
||||
bookmarks = list(query_bookmarks(request.user, request.user.profile, ''))
|
||||
# Prefetch tags to prevent n+1 queries
|
||||
prefetch_related_objects(bookmarks, 'tags')
|
||||
file_content = exporter.export_netscape_html(bookmarks)
|
||||
|
@@ -10,6 +10,8 @@ mkdir -p data/favicons
|
||||
|
||||
# Run database migration
|
||||
python manage.py migrate
|
||||
# Enable WAL journal mode for SQLite databases
|
||||
python manage.py enable_wal
|
||||
# Generate secret key file if it does not exist
|
||||
python manage.py generate_secret_key
|
||||
# Create initial superuser if defined in options / environment variables
|
||||
|
@@ -1,7 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
rm -rf static
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py compilescss --delete-files
|
@@ -45,6 +45,7 @@ Example response:
|
||||
"url": "https://example.com",
|
||||
"title": "Example title",
|
||||
"description": "Example description",
|
||||
"notes": "Example notes",
|
||||
"website_title": "Website title",
|
||||
"website_description": "Website description",
|
||||
"is_archived": false,
|
||||
@@ -96,6 +97,7 @@ Example payload:
|
||||
"url": "https://example.com",
|
||||
"title": "Example title",
|
||||
"description": "Example description",
|
||||
"notes": "Example notes",
|
||||
"is_archived": false,
|
||||
"unread": false,
|
||||
"shared": false,
|
||||
|
@@ -109,6 +109,12 @@ Multiple origins can be specified by separating them with a comma (`,`).
|
||||
|
||||
This setting is adopted from the Django framework used by linkding, more information on the setting is available in the [Django documentation](https://docs.djangoproject.com/en/4.0/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS).
|
||||
|
||||
### `LD_LOG_X_FORWARDED_FOR`
|
||||
|
||||
Values: `true` or `false` | Default = `false`
|
||||
|
||||
Set uWSGI [log-x-forwarded-for](https://uwsgi-docs.readthedocs.io/en/latest/Options.html?#log-x-forwarded-for) parameter allowing to keep the real IP of clients in logs when using a reverse proxy.
|
||||
|
||||
### `LD_DB_ENGINE`
|
||||
|
||||
Values: `postgres` or `sqlite` | Default = `sqlite`
|
||||
@@ -150,6 +156,12 @@ Values: `Integer` | Default = None
|
||||
The port of the database server.
|
||||
Should use the default port if left empty, for example `5432` for PostgresSQL.
|
||||
|
||||
### `LD_DB_OPTIONS`
|
||||
|
||||
Values: `String` | Default = `{}`
|
||||
|
||||
A json string with additional options for the database. Passed directly to OPTIONS.
|
||||
|
||||
### `LD_FAVICON_PROVIDER`
|
||||
|
||||
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
|
||||
|
Binary file not shown.
BIN
docs/header.png
Normal file
BIN
docs/header.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 55 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 2599 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 2126 591" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g transform="matrix(1.18075,0,0,1.18075,-1265.31,-1395.82)">
|
||||
<circle cx="1314.98" cy="1424.52" r="190.496" style="fill:rgb(88,86,224);"/>
|
||||
</g>
|
||||
@@ -13,29 +13,6 @@
|
||||
</g>
|
||||
</g>
|
||||
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
|
||||
<g transform="matrix(50,0,0,50,770.835,299.13)">
|
||||
<path d="M0.078,-0.714L0.078,-0L0.551,-0L0.551,-0.08L0.173,-0.08L0.173,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,798.635,299.13)">
|
||||
<rect x="0.082" y="-0.714" width="0.095" height="0.714" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,811.585,299.13)">
|
||||
<path d="M0.077,-0.714L0.077,-0L0.167,-0L0.167,-0.573L0.169,-0.573L0.542,-0L0.646,-0L0.646,-0.714L0.556,-0.714L0.556,-0.135L0.554,-0.135L0.178,-0.714L0.077,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,847.685,299.13)">
|
||||
<path d="M0.078,-0.714L0.078,-0L0.173,-0L0.173,-0.25L0.292,-0.361L0.55,-0L0.67,-0L0.357,-0.426L0.658,-0.714L0.535,-0.714L0.173,-0.358L0.173,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,881.035,299.13)">
|
||||
<path d="M0.173,-0.08L0.173,-0.634L0.333,-0.634C0.377,-0.634 0.414,-0.628 0.444,-0.616C0.474,-0.603 0.498,-0.585 0.518,-0.562C0.537,-0.538 0.55,-0.509 0.559,-0.476C0.567,-0.442 0.571,-0.404 0.571,-0.361C0.571,-0.317 0.567,-0.28 0.558,-0.249C0.549,-0.218 0.537,-0.192 0.523,-0.171C0.509,-0.15 0.493,-0.134 0.476,-0.122C0.458,-0.11 0.44,-0.101 0.422,-0.095C0.404,-0.088 0.387,-0.084 0.371,-0.083C0.355,-0.081 0.342,-0.08 0.331,-0.08L0.173,-0.08ZM0.078,-0.714L0.078,-0L0.323,-0C0.382,-0 0.434,-0.008 0.477,-0.025C0.52,-0.042 0.556,-0.066 0.584,-0.098C0.612,-0.129 0.633,-0.168 0.646,-0.215C0.659,-0.261 0.666,-0.314 0.666,-0.374C0.666,-0.489 0.636,-0.574 0.577,-0.63C0.518,-0.686 0.433,-0.714 0.323,-0.714L0.078,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,916.235,299.13)">
|
||||
<rect x="0.082" y="-0.714" width="0.095" height="0.714" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,929.185,299.13)">
|
||||
<path d="M0.077,-0.714L0.077,-0L0.167,-0L0.167,-0.573L0.169,-0.573L0.542,-0L0.646,-0L0.646,-0.714L0.556,-0.714L0.556,-0.135L0.554,-0.135L0.178,-0.714L0.077,-0.714Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(50,0,0,50,965.285,299.13)">
|
||||
<path d="M0.612,-0.089L0.637,-0L0.697,-0L0.697,-0.376L0.384,-0.376L0.384,-0.296L0.612,-0.296C0.613,-0.263 0.609,-0.233 0.599,-0.205C0.589,-0.176 0.574,-0.152 0.555,-0.131C0.535,-0.11 0.511,-0.093 0.482,-0.081C0.453,-0.069 0.42,-0.063 0.383,-0.063C0.343,-0.063 0.308,-0.071 0.278,-0.087C0.247,-0.102 0.222,-0.123 0.201,-0.15C0.18,-0.176 0.165,-0.206 0.154,-0.241C0.143,-0.275 0.138,-0.311 0.138,-0.348C0.138,-0.386 0.143,-0.423 0.152,-0.46C0.161,-0.496 0.176,-0.528 0.196,-0.557C0.215,-0.585 0.241,-0.608 0.272,-0.625C0.303,-0.642 0.34,-0.651 0.383,-0.651C0.41,-0.651 0.435,-0.648 0.459,-0.642C0.482,-0.635 0.503,-0.626 0.522,-0.613C0.541,-0.6 0.556,-0.584 0.569,-0.565C0.582,-0.545 0.59,-0.521 0.595,-0.494L0.69,-0.494C0.683,-0.536 0.671,-0.572 0.653,-0.602C0.634,-0.631 0.612,-0.656 0.585,-0.675C0.558,-0.694 0.527,-0.708 0.493,-0.718C0.458,-0.727 0.422,-0.731 0.383,-0.731C0.326,-0.731 0.277,-0.721 0.235,-0.7C0.192,-0.679 0.157,-0.65 0.129,-0.615C0.1,-0.58 0.079,-0.539 0.065,-0.492C0.05,-0.444 0.043,-0.395 0.043,-0.343C0.043,-0.296 0.051,-0.251 0.066,-0.208C0.081,-0.165 0.104,-0.126 0.133,-0.093C0.162,-0.06 0.198,-0.033 0.24,-0.014C0.282,0.006 0.33,0.016 0.383,0.016C0.425,0.016 0.467,0.008 0.508,-0.009C0.549,-0.025 0.584,-0.052 0.612,-0.089Z" style="fill:rgb(132,132,228);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<text x="770.835px" y="299.13px" style="font-family:'HelveticaNeue', 'Helvetica Neue';font-size:50px;fill:rgb(94,94,219);">l<tspan x="782.685px 794.535px 823.085px 849.785px 880.185px 892.035px 920.585px " y="299.13px 299.13px 299.13px 299.13px 299.13px 299.13px 299.13px ">inkding</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
Before Width: | Height: | Size: 304 KiB After Width: | Height: | Size: 374 KiB |
10
docs/shortcuts.md
Normal file
10
docs/shortcuts.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Keyboard Shortcuts
|
||||
|
||||
The following keyboard shortcuts are currently available:
|
||||
|
||||
| Action | Shortcut |
|
||||
|-------------------------------------------------------------------------------------------|-------------------------------------|
|
||||
| Add new bookmark | <kbd>n</kbd> |
|
||||
| Focus search input | <kbd>s</kbd> |
|
||||
| Navigate bookmarks | <kbd>↑</kbd>, <kbd>↓</kbd> |
|
||||
| Toggle bookmark notes | <kbd>e</kbd> |
|
BIN
docs/social-preview.afdesign
Normal file
BIN
docs/social-preview.afdesign
Normal file
Binary file not shown.
BIN
docs/social-preview.png
Normal file
BIN
docs/social-preview.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.17.1",
|
||||
"version": "1.19.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@@ -1,10 +1,12 @@
|
||||
asgiref==3.5.2
|
||||
beautifulsoup4==4.11.1
|
||||
certifi==2022.12.7
|
||||
bleach==6.0.0
|
||||
bleach-allowlist==1.0.3
|
||||
certifi==2023.7.22
|
||||
charset-normalizer==2.1.1
|
||||
click==8.1.3
|
||||
confusable-homoglyphs==3.2.0
|
||||
Django==4.1.2
|
||||
Django==4.1.10
|
||||
django-generate-secret-key==1.0.2
|
||||
django-registration==3.3
|
||||
django-sass-processor==1.2.1
|
||||
@@ -12,14 +14,16 @@ django-widget-tweaks==1.4.12
|
||||
django4-background-tasks==1.2.7
|
||||
djangorestframework==3.13.1
|
||||
idna==3.3
|
||||
Markdown==3.4.3
|
||||
psycopg2==2.9.5
|
||||
python-dateutil==2.8.2
|
||||
pytz==2022.2.1
|
||||
requests==2.28.1
|
||||
requests==2.31.0
|
||||
soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.2
|
||||
sqlparse==0.4.4
|
||||
supervisor==4.2.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
uWSGI==2.0.20
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
@@ -1,11 +1,13 @@
|
||||
asgiref==3.5.2
|
||||
beautifulsoup4==4.11.1
|
||||
certifi==2022.12.7
|
||||
bleach==6.0.0
|
||||
bleach-allowlist==1.0.3
|
||||
certifi==2023.7.22
|
||||
charset-normalizer==2.1.1
|
||||
click==8.1.3
|
||||
confusable-homoglyphs==3.2.0
|
||||
coverage==5.5
|
||||
Django==4.1.2
|
||||
Django==4.1.10
|
||||
django-appconf==1.0.5
|
||||
django-compressor==4.1
|
||||
django-debug-toolbar==3.6.0
|
||||
@@ -18,17 +20,19 @@ djangorestframework==3.13.1
|
||||
greenlet==2.0.1
|
||||
idna==3.3
|
||||
libsass==0.21.0
|
||||
Markdown==3.4.3
|
||||
playwright==1.29.1
|
||||
psycopg2-binary==2.9.5
|
||||
pyee==9.0.4
|
||||
python-dateutil==2.8.2
|
||||
pytz==2022.2.1
|
||||
rcssmin==1.1.0
|
||||
requests==2.28.1
|
||||
requests==2.31.0
|
||||
rjsmin==1.2.0
|
||||
six==1.16.0
|
||||
soupsieve==2.3.2.post1
|
||||
sqlparse==0.4.2
|
||||
sqlparse==0.4.4
|
||||
typing-extensions==3.10.0.0
|
||||
urllib3==1.26.11
|
||||
waybackpy==3.0.6
|
||||
webencodings==0.5.1
|
||||
|
49
scripts/generate-changelog.py
Executable file
49
scripts/generate-changelog.py
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
import requests
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def load_releases_page(page):
|
||||
url = f'https://api.github.com/repos/sissbruecker/linkding/releases?page={page}'
|
||||
return requests.get(url).json()
|
||||
|
||||
|
||||
def load_all_releases():
|
||||
load_next_page = True
|
||||
page = 1
|
||||
releases = []
|
||||
|
||||
while load_next_page:
|
||||
page_result = load_releases_page(page)
|
||||
releases = releases + page_result
|
||||
load_next_page = len(page_result) > 0
|
||||
page = page + 1
|
||||
|
||||
return releases
|
||||
|
||||
|
||||
def render_release_section(release):
|
||||
date = datetime.fromisoformat(release['published_at'].replace("Z", "+00:00"))
|
||||
formatted_date = date.strftime('%d/%m/%Y')
|
||||
section = f'## {release["name"]} ({formatted_date})\n\n'
|
||||
body = release['body']
|
||||
# increase heading for body content
|
||||
body = body.replace("## What's Changed", "### What's Changed")
|
||||
body = body.replace("## New Contributors", "### New Contributors")
|
||||
section += body.strip()
|
||||
return section
|
||||
|
||||
|
||||
def generate_change_log():
|
||||
releases = load_all_releases()
|
||||
|
||||
change_log = '# Changelog\n\n'
|
||||
sections = [render_release_section(release) for release in releases]
|
||||
body = '\n\n---\n\n'.join(sections)
|
||||
change_log = change_log + body
|
||||
|
||||
with open("CHANGELOG.md", "w") as file:
|
||||
file.write(change_log)
|
||||
|
||||
|
||||
generate_change_log()
|
10
scripts/run-docker.sh
Executable file
10
scripts/run-docker.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
docker build -t sissbruecker/linkding:local .
|
||||
|
||||
docker rm -f linkding-local || true
|
||||
|
||||
docker run --name linkding-local --rm -p 9090:9090 \
|
||||
-e LD_SUPERUSER_NAME=admin \
|
||||
-e LD_SUPERUSER_PASSWORD=admin \
|
||||
sissbruecker/linkding:local
|
14
scripts/test-e2e.sh
Executable file
14
scripts/test-e2e.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Make sure Chromium is installed
|
||||
playwright install chromium
|
||||
|
||||
# Test server loads assets from static folder, so make sure files there are up-to-date
|
||||
rm -rf static
|
||||
npm run build
|
||||
python manage.py compilescss
|
||||
python manage.py collectstatic --ignore=*.scss
|
||||
python manage.py compilescss --delete-files
|
||||
|
||||
# Run E2E tests
|
||||
python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
|
29
scripts/test-postgres.sh
Executable file
29
scripts/test-postgres.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Remove previous container if exists
|
||||
docker rm -f linkding-postgres-test || true
|
||||
|
||||
# Run postgres container
|
||||
docker run -d \
|
||||
-e POSTGRES_DB=linkding \
|
||||
-e POSTGRES_USER=linkding \
|
||||
-e POSTGRES_PASSWORD=linkding \
|
||||
-p 5432:5432 \
|
||||
--name linkding-postgres-test \
|
||||
postgres
|
||||
|
||||
# Wait until postgres has started
|
||||
echo >&2 "$(date +%Y%m%dt%H%M%S) Waiting for postgres container"
|
||||
sleep 15
|
||||
|
||||
# Run tests using postgres
|
||||
export LD_DB_ENGINE=postgres
|
||||
export LD_DB_USER=linkding
|
||||
export LD_DB_PASSWORD=linkding
|
||||
|
||||
./scripts/test.sh
|
||||
|
||||
# Remove postgres container
|
||||
docker rm -f linkding-postgres-test || true
|
1
scripts/test-unit.sh
Executable file
1
scripts/test-unit.sh
Executable file
@@ -0,0 +1 @@
|
||||
python manage.py test bookmarks.tests
|
2
scripts/test.sh
Executable file
2
scripts/test.sh
Executable file
@@ -0,0 +1,2 @@
|
||||
./scripts/test-unit.sh
|
||||
./scripts/test-e2e.sh
|
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/2.2/ref/settings/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
@@ -204,6 +205,7 @@ LD_DB_DATABASE = os.getenv('LD_DB_DATABASE', 'linkding')
|
||||
LD_DB_USER = os.getenv('LD_DB_USER', 'linkding')
|
||||
LD_DB_PASSWORD = os.getenv('LD_DB_PASSWORD', None)
|
||||
LD_DB_PORT = os.getenv('LD_DB_PORT', None)
|
||||
LD_DB_OPTIONS = json.loads(os.getenv('LD_DB_OPTIONS') or '{}')
|
||||
|
||||
if LD_DB_ENGINE == 'postgres':
|
||||
default_database = {
|
||||
@@ -213,11 +215,13 @@ if LD_DB_ENGINE == 'postgres':
|
||||
'PASSWORD': LD_DB_PASSWORD,
|
||||
'HOST': LD_DB_HOST,
|
||||
'PORT': LD_DB_PORT,
|
||||
'OPTIONS': LD_DB_OPTIONS,
|
||||
}
|
||||
else:
|
||||
default_database = {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': os.path.join(BASE_DIR, 'data', 'db.sqlite3'),
|
||||
'OPTIONS': LD_DB_OPTIONS,
|
||||
}
|
||||
|
||||
DATABASES = {
|
||||
|
@@ -19,6 +19,9 @@ INTERNAL_IPS = [
|
||||
'127.0.0.1',
|
||||
]
|
||||
|
||||
# Allow access through ngrok
|
||||
CSRF_TRUSTED_ORIGINS = ['https://*.ngrok-free.app']
|
||||
|
||||
# Enable debug logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
@@ -22,4 +22,8 @@ if-env = LD_REQUEST_TIMEOUT
|
||||
http-timeout = %(_)
|
||||
socket-timeout = %(_)
|
||||
harakiri = %(_)
|
||||
endif =
|
||||
endif =
|
||||
|
||||
if-env = LD_LOG_X_FORWARDED_FOR
|
||||
log-x-forwarded-for = %(_)
|
||||
endif =
|
||||
|
@@ -1 +1 @@
|
||||
1.17.1
|
||||
1.19.1
|
||||
|
Reference in New Issue
Block a user