Compare commits

..

9 Commits

Author SHA1 Message Date
Sascha Ißbrücker
a867614461 Bump version 2021-02-14 18:03:40 +01:00
Sascha Ißbrücker
194f5884df Fix bookmarklet (#46) 2021-02-14 17:57:30 +01:00
Sascha Ißbrücker
8e1cda1104 Update modified timestamp on archive/unarchive (#46) 2021-02-14 17:30:51 +01:00
Sascha Ißbrücker
f9659f4342 Move bookmarklet to settings (#46) 2021-02-14 17:14:58 +01:00
Sascha Ißbrücker
6fab248c95 Implement archive mode for search component (#46) 2021-02-14 16:56:12 +01:00
Sascha Ißbrücker
b7676227c0 Implement archived bookmarks endpoint (#46) 2021-02-14 12:14:46 +01:00
Sascha Ißbrücker
0db7610d68 Filter tags for archived/unarchived (#46) 2021-02-14 11:43:09 +01:00
Sascha Ißbrücker
be7b92d608 Implement archive view (#46) 2021-02-14 11:21:05 +01:00
Sascha Ißbrücker
256084f6cb Implement archive function (#46) 2021-02-14 10:51:32 +01:00
313 changed files with 2855 additions and 27517 deletions

View File

@@ -1,3 +0,0 @@
[run]
source = bookmarks
omit = bookmarks/tests/*

View File

@@ -1,29 +0,0 @@
// 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 -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate",
// Configure tool-specific properties.
"customizations": {
"vscode": {
"extensions": [
"ms-python.python"
]
}
},
"remoteUser": "vscode"
}

View File

@@ -1,21 +1,17 @@
# Ignore everything
*
# Remove project files, data, tmp files, build files
/.env
/.idea
/data
/node_modules
/tmp
/docs
# Include files required for build or at runtime
!/bookmarks
!/siteroot
/.dockerignore
/.gitignore
/build-*.sh
/Dockerfile
/*.iml
/package*.json
!/bootstrap.sh
!/LICENSE.txt
!/manage.py
!/package.json
!/package-lock.json
!/requirements.dev.txt
!/requirements.txt
!/rollup.config.mjs
!/supervisord.conf
!/uwsgi.ini
!/version.txt
# Remove dev settings
# Remove development settings
/siteroot/settings/dev.py

View File

@@ -5,45 +5,5 @@ LD_HOST_PORT=9090
# Directory on the host system that should be mounted as data dir into the Docker container
LD_HOST_DATA_DIR=./data
# Can be used to run linkding under a context path, for example: linkding/
# Must end with a slash `/`
LD_CONTEXT_PATH=
# Username of the initial superuser to create, leave empty to not create one
LD_SUPERUSER_NAME=
# Password for the initial superuser, leave empty to disable credentials authentication and rely on proxy authentication instead
LD_SUPERUSER_PASSWORD=
# Option to disable background tasks
LD_DISABLE_BACKGROUND_TASKS=False
# Option to disable URL validation for bookmarks completely
LD_DISABLE_URL_VALIDATION=False
# Enables support for authentication proxies such as Authelia
LD_ENABLE_AUTH_PROXY=False
# Name of the request header that the auth proxy passes to the application to identify the user
# See docs/Options.md for more details
LD_AUTH_PROXY_USERNAME_HEADER=
# The URL that linkding should redirect to after a logout, when using an auth proxy
# See docs/Options.md for more details
LD_AUTH_PROXY_LOGOUT_URL=
# List of trusted origins from which to accept POST requests
# See docs/Options.md for more details
LD_CSRF_TRUSTED_ORIGINS=
# Database settings
# These are currently only required for configuring PostreSQL.
# By default, linkding uses SQLite for which you don't need to configure anything.
# Database engine, can be sqlite (default) or postgres
LD_DB_ENGINE=
# Database name (default: linkding)
LD_DB_DATABASE=
# Username to connect to the database server (default: linkding)
LD_DB_USER=
# Password to connect to the database server
LD_DB_PASSWORD=
# The hostname where the database is hosted (default: localhost)
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=
LD_DISABLE_URL_VALIDATION=False

2
.gitattributes vendored
View File

@@ -1,2 +0,0 @@
* text=auto
*.sh text eol=lf

View File

@@ -1,59 +1,18 @@
name: linkding CI
on:
pull_request:
push:
branches:
- master
on: [push]
jobs:
unit_tests:
name: Unit Tests
run_tests:
name: Run Django Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v1
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v1
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
mkdir data
python-version: 3.7
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run tests
run: python manage.py test bookmarks.tests
e2e_tests:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt
playwright install chromium
mkdir data
- name: Run build
run: |
npm run build
python manage.py compilescss
python manage.py collectstatic --ignore=*.scss
- name: Run tests
run: python manage.py test bookmarks.e2e --pattern="e2e_test_*.py"
run: python manage.py test

49
.gitignore vendored
View File

@@ -3,14 +3,55 @@
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/dictionaries
.idea/**/shelf
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-debug/
cmake-build-release/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
.idea
*.iml
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -182,9 +223,7 @@ typings/
### Custom
# Rollup compilation output
/bookmarks/static/bundle.js*
# SASS compilation output
/bookmarks/static/theme-*.css*
/build
# Collected static files for deployment
/static
# Build output, etc.

6
.grenrc.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
ignoreIssuesWith: [
"wontfix",
"duplicate"
]
}

15
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<wildcardResourcePatterns>
<entry name="!?*.java" />
<entry name="!?*.form" />
<entry name="!?*.class" />
<entry name="!?*.groovy" />
<entry name="!?*.scala" />
<entry name="!?*.flex" />
<entry name="!?*.kt" />
<entry name="!?*.clj" />
</wildcardResourcePatterns>
</component>
</project>

11
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="SQLite - db.sqlite3" uuid="c880bd6d-554c-484d-a5be-45581d9a9377">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/db.sqlite3</jdbc-url>
</data-source>
</component>
</project>

4
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with NO BOM" />
</project>

View File

@@ -0,0 +1,12 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="PyPep8Inspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="E402" />
</list>
</option>
</inspection_tool>
</profile>
</component>

13
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="HaskellBuildOptions">
<ghcPath>/usr/local/bin/ghc</ghcPath>
<stackPath>/usr/local/bin/stack</stackPath>
</component>
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" default="false" project-jdk-name="Python 3.7 (linkding)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/linkdings.iml" filepath="$PROJECT_DIR$/linkdings.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

4
.idea/watcherTasks.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

View File

@@ -24,7 +24,7 @@ The following resources are available:
GET /api/bookmarks/
```
List bookmarks.
List bookmarks.
Parameters:
@@ -45,13 +45,8 @@ Example response:
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"notes": "Example notes",
"website_title": "Website title",
"website_description": "Website description",
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"
@@ -64,13 +59,13 @@ Example response:
}
```
**List Archived**
**Archived**
```
GET /api/bookmarks/archived/
```
List archived bookmarks.
List archived bookmarks.
Parameters and response are the same as for the regular list endpoint.
@@ -88,8 +83,7 @@ Retrieves a single bookmark by ID.
POST /api/bookmarks/
```
Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
Creates a new bookmark. Tags are simply assigned using their names.
Example payload:
@@ -98,10 +92,6 @@ Example payload:
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"notes": "Example notes",
"is_archived": false,
"unread": false,
"shared": false,
"tag_names": [
"tag1",
"tag2"
@@ -115,9 +105,7 @@ Example payload:
PUT /api/bookmarks/<id>/
```
Updates a bookmark.
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
Tags are simply assigned using their names.
Updates a bookmark. Tags are simply assigned using their names.
Example payload:
@@ -133,46 +121,6 @@ Example payload:
}
```
**Patch**
```
PATCH /api/bookmarks/<id>/
```
Updates a bookmark partially.
Allows to modify individual fields of a bookmark.
Tags are simply assigned using their names.
Example payload:
```json
{
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"tag_names": [
"tag1",
"tag2"
]
}
```
**Archive**
```
POST /api/bookmarks/<id>/archive/
```
Archives a bookmark.
**Unarchive**
```
POST /api/bookmarks/<id>/unarchive/
```
Unarchives a bookmark.
**Delete**
```
@@ -238,34 +186,3 @@ Example payload:
}
```
### User
**Profile**
```
GET /api/user/profile/
```
User preferences.
Example response:
```json
{
"theme": "auto",
"bookmark_date_display": "relative",
"bookmark_link_target": "_blank",
"web_archive_integration": "enabled",
"tag_search": "lax",
"enable_sharing": true,
"enable_public_sharing": true,
"enable_favicons": false,
"display_url": false,
"permanent_notes": false,
"search_preferences": {
"sort": "title_asc",
"shared": "off",
"unread": "off"
}
}
```

View File

@@ -1,744 +1,27 @@
# Changelog
## v1.27.1 (07/04/2024)
### What's Changed
* Fix HTML snapshot errors related to single-file-cli by @sissbruecker in https://github.com/sissbruecker/linkding/pull/683
* Replace django-background-tasks with huey by @sissbruecker in https://github.com/sissbruecker/linkding/pull/657
* Add Authelia OIDC example to docs by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/675
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.27.0...v1.27.1
---
## v1.27.0 (01/04/2024)
### What's Changed
* Archive snapshots of websites locally by @sissbruecker in https://github.com/sissbruecker/linkding/pull/672
* Add Railway hosting option by @tianheg in https://github.com/sissbruecker/linkding/pull/661
* Add how to for increasing the font size by @sissbruecker in https://github.com/sissbruecker/linkding/pull/667
### New Contributors
* @tianheg made their first contribution in https://github.com/sissbruecker/linkding/pull/661
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.26.0...v1.27.0
---
## v1.26.0 (30/03/2024)
### What's Changed
* Add option for showing bookmark description as separate block by @sissbruecker in https://github.com/sissbruecker/linkding/pull/663
* Add bookmark details view by @sissbruecker in https://github.com/sissbruecker/linkding/pull/665
* Make bookmark list actions configurable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/666
* Bump black from 24.1.1 to 24.3.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/662
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.25.0...v1.26.0
---
## v1.25.0 (18/03/2024)
### What's Changed
* Improve PWA capabilities by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/630
* build improvements by @hugo-vrijswijk in https://github.com/sissbruecker/linkding/pull/649
* Add support for oidc by @Nighmared in https://github.com/sissbruecker/linkding/pull/389
* Add option for custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/652
* Update backup location to safe directory by @bphenriques in https://github.com/sissbruecker/linkding/pull/653
* Include web archive link in /api/bookmarks/ by @sissbruecker in https://github.com/sissbruecker/linkding/pull/655
* Add RSS feeds for shared bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/656
* Bump django from 5.0.2 to 5.0.3 by @dependabot in https://github.com/sissbruecker/linkding/pull/658
### New Contributors
* @hugo-vrijswijk made their first contribution in https://github.com/sissbruecker/linkding/pull/630
* @Nighmared made their first contribution in https://github.com/sissbruecker/linkding/pull/389
* @bphenriques made their first contribution in https://github.com/sissbruecker/linkding/pull/653
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.2...v1.25.0
---
## v1.24.2 (16/03/2024)
### What's Changed
* Fix logout button by @sissbruecker in https://github.com/sissbruecker/linkding/pull/648
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.1...v1.24.2
---
## v1.24.1 (16/03/2024)
### What's Changed
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/618
* Persist secret key in data folder by @sissbruecker in https://github.com/sissbruecker/linkding/pull/620
* Group ideographic characters in tag cloud by @jonathan-s in https://github.com/sissbruecker/linkding/pull/613
* Bump django from 5.0.1 to 5.0.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/625
* Add k8s setup to community section by @jzck in https://github.com/sissbruecker/linkding/pull/633
* Added a new Linkding client to community section by @JGeek00 in https://github.com/sissbruecker/linkding/pull/638
### New Contributors
* @jzck made their first contribution in https://github.com/sissbruecker/linkding/pull/633
* @JGeek00 made their first contribution in https://github.com/sissbruecker/linkding/pull/638
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.24.0...v1.24.1
---
## v1.24.0 (27/01/2024)
### What's Changed
* Support Open Graph description by @jonathan-s in https://github.com/sissbruecker/linkding/pull/602
* Add tooltip to truncated bookmark titles by @jonathan-s in https://github.com/sissbruecker/linkding/pull/607
* Improve bulk tag performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/612
* Increase tag limit in tag autocomplete by @hypebeast in https://github.com/sissbruecker/linkding/pull/581
* Add CapRover as managed hosting option by @adamshand in https://github.com/sissbruecker/linkding/pull/585
* Bump playwright dependencies by @jonathan-s in https://github.com/sissbruecker/linkding/pull/601
* Adjust archive.org donation link in general.html by @JnsDornbusch in https://github.com/sissbruecker/linkding/pull/603
### New Contributors
* @hypebeast made their first contribution in https://github.com/sissbruecker/linkding/pull/581
* @adamshand made their first contribution in https://github.com/sissbruecker/linkding/pull/585
* @jonathan-s made their first contribution in https://github.com/sissbruecker/linkding/pull/601
* @JnsDornbusch made their first contribution in https://github.com/sissbruecker/linkding/pull/603
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.1...v1.24.0
---
## v1.23.1 (08/12/2023)
### What's Changed
* Properly encode search query param by @sissbruecker in https://github.com/sissbruecker/linkding/pull/587
> [!WARNING]
> *This resolves a security vulnerability in linkding. Everyone is encouraged to upgrade to the latest version as soon as possible.*
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.23.0...v1.23.1
---
## v1.23.0 (24/11/2023)
### What's Changed
* Add Alpine based Docker image (experimental) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/570
* Add backup CLI command by @sissbruecker in https://github.com/sissbruecker/linkding/pull/571
* Update browser extension links by @OPerepadia in https://github.com/sissbruecker/linkding/pull/574
* Include archived bookmarks in export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/579
### New Contributors
* @OPerepadia made their first contribution in https://github.com/sissbruecker/linkding/pull/574
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.3...v1.23.0
---
## v1.22.3 (04/11/2023)
### What's Changed
* Fix RSS feed not handling None values by @vitormarcal in https://github.com/sissbruecker/linkding/pull/569
* Bump django from 4.1.10 to 4.1.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/567
### New Contributors
* @vitormarcal made their first contribution in https://github.com/sissbruecker/linkding/pull/569
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.2...v1.22.3
---
## v1.22.2 (27/10/2023)
### What's Changed
* Fix search options not opening on iOS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/549
* Bump urllib3 from 1.26.11 to 1.26.17 by @dependabot in https://github.com/sissbruecker/linkding/pull/542
* Add iOS shortcut to community section by @andrewdolphin in https://github.com/sissbruecker/linkding/pull/550
* Disable editing of search preferences in user admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/555
* Add feed2linkding to community section by @Strubbl in https://github.com/sissbruecker/linkding/pull/544
* Sanitize RSS feed to remove control characters by @sissbruecker in https://github.com/sissbruecker/linkding/pull/565
* Bump urllib3 from 1.26.17 to 1.26.18 by @dependabot in https://github.com/sissbruecker/linkding/pull/560
### New Contributors
* @andrewdolphin made their first contribution in https://github.com/sissbruecker/linkding/pull/550
* @Strubbl made their first contribution in https://github.com/sissbruecker/linkding/pull/544
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.1...v1.22.2
---
## v1.22.1 (06/10/2023)
### What's Changed
* Fix memory leak with SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/548
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.22.0...v1.22.1
---
## v1.22.0 (01/10/2023)
### What's Changed
* Fix case-insensitive search for unicode characters in SQLite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/520
* Add sort option to bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/522
* Add button to show tags on smaller screens by @sissbruecker in https://github.com/sissbruecker/linkding/pull/529
* Make code blocks in notes scrollable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/530
* Add filter for shared state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/531
* Add support for exporting/importing bookmark notes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/532
* Add filter for unread state by @sissbruecker in https://github.com/sissbruecker/linkding/pull/535
* Allow saving search preferences by @sissbruecker in https://github.com/sissbruecker/linkding/pull/540
* Add user profile endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/541
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.22.0
---
## v1.21.1 (26/09/2023)
### What's Changed
* Fix bulk edit to respect searched tags by @sissbruecker in https://github.com/sissbruecker/linkding/pull/537
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.21.0...v1.21.1
---
## v1.21.0 (25/08/2023)
### What's Changed
* Make search autocomplete respect link target setting by @sissbruecker in https://github.com/sissbruecker/linkding/pull/513
* Various CSS improvements by @sissbruecker in https://github.com/sissbruecker/linkding/pull/514
* Display shared state in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/515
* Allow bulk editing unread and shared state of bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/517
* Bump uwsgi from 2.0.20 to 2.0.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/516
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.1...v1.21.0
---
## v1.20.1 (23/08/2023)
### What's Changed
* Update cached styles and scripts after version change by @sissbruecker in https://github.com/sissbruecker/linkding/pull/510
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.20.0...v1.20.1
---
## v1.20.0 (22/08/2023)
### What's Changed
* Add option to share bookmarks publicly by @sissbruecker in https://github.com/sissbruecker/linkding/pull/503
* Various improvements to favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/504
* Add support for PRIVATE flag in import and export by @sissbruecker in https://github.com/sissbruecker/linkding/pull/505
* Avoid page reload when triggering actions in bookmark list by @sissbruecker in https://github.com/sissbruecker/linkding/pull/506
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.1...v1.20.0
---
## v1.19.1 (29/07/2023)
### What's Changed
* Add Postman Collection to Community section of README by @gingerbeardman in https://github.com/sissbruecker/linkding/pull/476
* Added Dev Container support by @acbgbca in https://github.com/sissbruecker/linkding/pull/474
* Added Apple web-app meta tag #358 by @acbgbca in https://github.com/sissbruecker/linkding/pull/359
* Bump requests from 2.28.1 to 2.31.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/478
* Allow passing title and description to new bookmark form by @acbgbca in https://github.com/sissbruecker/linkding/pull/479
* Enable WAL to avoid locked database lock errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/480
* Fix website loader content encoding detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/482
* Bump certifi from 2022.12.7 to 2023.7.22 by @dependabot in https://github.com/sissbruecker/linkding/pull/497
* Bump django from 4.1.9 to 4.1.10 by @dependabot in https://github.com/sissbruecker/linkding/pull/494
### New Contributors
* @gingerbeardman made their first contribution in https://github.com/sissbruecker/linkding/pull/476
* @acbgbca made their first contribution in https://github.com/sissbruecker/linkding/pull/474
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.19.0...v1.19.1
---
## 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
* Add Health Check endpoint by @mckennajones in https://github.com/sissbruecker/linkding/pull/392
* Cache website metadata to avoid duplicate scraping by @sissbruecker in https://github.com/sissbruecker/linkding/pull/401
* Prefill form if URL is already bookmarked by @sissbruecker in https://github.com/sissbruecker/linkding/pull/402
* Add option for showing bookmark favicons by @sissbruecker in https://github.com/sissbruecker/linkding/pull/390
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.1...v1.17.0
---
## v1.16.1 (20/01/2023)
### What's Changed
* Fix bookmark website metadata not being updated when URL changes by @sissbruecker in https://github.com/sissbruecker/linkding/pull/400
* Bump django from 4.1 to 4.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/391
* Bump certifi from 2022.6.15 to 2022.12.7 by @dependabot in https://github.com/sissbruecker/linkding/pull/374
* Bump minimatch from 3.0.4 to 3.1.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/366
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.16.0...v1.16.1
---
## v1.16.0 (12/01/2023)
### What's Changed
* Add postgres as database engine by @tomamplius in https://github.com/sissbruecker/linkding/pull/388
* Gracefully stop docker container when it receives SIGTERM by @mckennajones in https://github.com/sissbruecker/linkding/pull/368
* Limit document size for website scraper by @sissbruecker in https://github.com/sissbruecker/linkding/pull/354
* Add error handling for checking latest version by @sissbruecker in https://github.com/sissbruecker/linkding/pull/360
* Trim website metadata title and description by @luca1197 in https://github.com/sissbruecker/linkding/pull/383
* Only show admin link for superusers by @AlexanderS in https://github.com/sissbruecker/linkding/pull/384
* Add apache reverse proxy documentation. by @jhauris in https://github.com/sissbruecker/linkding/pull/371
* Correct LD_ENABLE_AUTH_PROXY documentation by @jhauris in https://github.com/sissbruecker/linkding/pull/379
* Android HTTP shortcuts v3 by @kzshantonu in https://github.com/sissbruecker/linkding/pull/387
### New Contributors
* @jhauris made their first contribution in https://github.com/sissbruecker/linkding/pull/371
* @AlexanderS made their first contribution in https://github.com/sissbruecker/linkding/pull/384
* @mckennajones made their first contribution in https://github.com/sissbruecker/linkding/pull/368
* @tomamplius made their first contribution in https://github.com/sissbruecker/linkding/pull/388
* @luca1197 made their first contribution in https://github.com/sissbruecker/linkding/pull/383
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.1...v1.16.0
---
## v1.15.1 (05/10/2022)
### What's Changed
* Fix static file dir warning by @sissbruecker in https://github.com/sissbruecker/linkding/pull/350
* Add setting and documentation for fixing CSRF errors by @sissbruecker in https://github.com/sissbruecker/linkding/pull/349
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.15.0...v1.15.1
---
## v1.15.0 (11/09/2022)
### What's Changed
* Bump Django and other dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/331
* Add option to create initial superuser by @sissbruecker in https://github.com/sissbruecker/linkding/pull/323
* Improved Android HTTP Shortcuts doc by @kzshantonu in https://github.com/sissbruecker/linkding/pull/330
* Minify bookmark list HTML by @sissbruecker in https://github.com/sissbruecker/linkding/pull/332
* Bump python version to 3.10 by @sissbruecker in https://github.com/sissbruecker/linkding/pull/333
* Fix error when deleting all bookmarks in admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/336
* Improve bookmark query performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/334
* Prevent rate limit errors in wayback machine API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/339
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.14.0...v1.15.0
---
## v1.14.0 (14/08/2022)
### What's Changed
* Add support for context path by @s2marine in https://github.com/sissbruecker/linkding/pull/313
* Add support for authentication proxies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/321
* Add bookmark list keyboard navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/320
* Skip updating website metadata on edit unless URL has changed by @sissbruecker in https://github.com/sissbruecker/linkding/pull/318
* Add simple docs of the new `shared` API parameter by @bachya in https://github.com/sissbruecker/linkding/pull/312
* Add project linka to community section in README by @cmsax in https://github.com/sissbruecker/linkding/pull/319
* Order tags in test_should_create_new_bookmark by @RoGryza in https://github.com/sissbruecker/linkding/pull/310
* Bump django from 3.2.14 to 3.2.15 by @dependabot in https://github.com/sissbruecker/linkding/pull/316
### New Contributors
* @s2marine made their first contribution in https://github.com/sissbruecker/linkding/pull/313
* @RoGryza made their first contribution in https://github.com/sissbruecker/linkding/pull/310
* @cmsax made their first contribution in https://github.com/sissbruecker/linkding/pull/319
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.13.0...v1.14.0
---
## v1.13.0 (04/08/2022)
### What's Changed
* Add bookmark sharing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/311
* Display selected tags in tag cloud by @sissbruecker and @jhauris in https://github.com/sissbruecker/linkding/pull/307
* Update unread flag when saving duplicate URL by @sissbruecker in https://github.com/sissbruecker/linkding/pull/306
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.12.0...v1.13.0
---
## v1.12.0 (23/07/2022)
### What's Changed
* Add read it later functionality by @sissbruecker in https://github.com/sissbruecker/linkding/pull/304
* Add RSS feeds by @sissbruecker in https://github.com/sissbruecker/linkding/pull/305
* Add bookmarklet to community by @ukcuddlyguy in https://github.com/sissbruecker/linkding/pull/293
* Shorten and simplify example bookmarklet in documentation by @FunctionDJ in https://github.com/sissbruecker/linkding/pull/297
* Fix typo by @kianmeng in https://github.com/sissbruecker/linkding/pull/295
* Bump django from 3.2.13 to 3.2.14 by @dependabot in https://github.com/sissbruecker/linkding/pull/294
* Bump svelte from 3.46.4 to 3.49.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/299
* Bump terser from 5.5.1 to 5.14.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/302
### New Contributors
* @ukcuddlyguy made their first contribution in https://github.com/sissbruecker/linkding/pull/293
* @FunctionDJ made their first contribution in https://github.com/sissbruecker/linkding/pull/297
* @kianmeng made their first contribution in https://github.com/sissbruecker/linkding/pull/295
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.1...v1.12.0
---
## v1.11.1 (03/07/2022)
### What's Changed
* Fix duplicate tags on import by @wahlm in https://github.com/sissbruecker/linkding/pull/289
* Add apple-touch-icon by @daveonkels in https://github.com/sissbruecker/linkding/pull/282
* Bump waybackpy to 3.0.6 by @dustinblackman in https://github.com/sissbruecker/linkding/pull/281
### New Contributors
* @wahlm made their first contribution in https://github.com/sissbruecker/linkding/pull/289
* @daveonkels made their first contribution in https://github.com/sissbruecker/linkding/pull/282
* @dustinblackman made their first contribution in https://github.com/sissbruecker/linkding/pull/281
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.11.0...v1.11.1
---
## v1.11.0 (26/05/2022)
### What's Changed
* Add background tasks to admin by @sissbruecker in https://github.com/sissbruecker/linkding/pull/264
* Improve about section by @sissbruecker in https://github.com/sissbruecker/linkding/pull/265
* Allow creating archived bookmark through REST API by @kencx in https://github.com/sissbruecker/linkding/pull/268
* Add PATCH support to bookmarks endpoint by @sissbruecker in https://github.com/sissbruecker/linkding/pull/269
* Add community reference to linkding-cli by @bachya in https://github.com/sissbruecker/linkding/pull/270
### New Contributors
* @kencx made their first contribution in https://github.com/sissbruecker/linkding/pull/268
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.1...v1.11.0
---
## v1.10.1 (21/05/2022)
### What's Changed
* Fake request headers to reduce bot detection by @sissbruecker in https://github.com/sissbruecker/linkding/pull/263
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.10.0...v1.10.1
---
## v1.10.0 (21/05/2022)
### What's Changed
* Add to managed hosting options by @m3nu in https://github.com/sissbruecker/linkding/pull/253
* Add community reference to aiolinkding by @bachya in https://github.com/sissbruecker/linkding/pull/259
* Improve import performance by @sissbruecker in https://github.com/sissbruecker/linkding/pull/261
* Update how-to.md to fix unclear/paraphrased Safari action in IOS Shortcuts by @feoh in https://github.com/sissbruecker/linkding/pull/260
* Allow searching for untagged bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/226
### New Contributors
* @m3nu made their first contribution in https://github.com/sissbruecker/linkding/pull/253
* @bachya made their first contribution in https://github.com/sissbruecker/linkding/pull/259
* @feoh made their first contribution in https://github.com/sissbruecker/linkding/pull/260
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.9.0...v1.10.0
---
## v1.9.0 (14/05/2022)
### What's Changed
* Scroll menu items into view when using keyboard by @sissbruecker in https://github.com/sissbruecker/linkding/pull/248
* Add whitespace after auto-completed tag by @sissbruecker in https://github.com/sissbruecker/linkding/pull/249
* Bump django from 3.2.12 to 3.2.13 by @dependabot in https://github.com/sissbruecker/linkding/pull/244
* Add community helm chart reference to readme by @pascaliske in https://github.com/sissbruecker/linkding/pull/242
* Feature: Shortcut key for new bookmark by @rithask in https://github.com/sissbruecker/linkding/pull/241
* Clarify archive.org feature by @clach04 in https://github.com/sissbruecker/linkding/pull/229
* Make Internet Archive integration opt-in by @sissbruecker in https://github.com/sissbruecker/linkding/pull/250
### New Contributors
* @pascaliske made their first contribution in https://github.com/sissbruecker/linkding/pull/242
* @rithask made their first contribution in https://github.com/sissbruecker/linkding/pull/241
* @clach04 made their first contribution in https://github.com/sissbruecker/linkding/pull/229
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.8.8...v1.9.0
---
## v1.8.8 (27/03/2022)
- [**bug**] Prevent bookmark actions through get requests
- [**bug**] Prevent external redirects
---
## v1.8.7 (26/03/2022)
- [**bug**] Increase request buffer size [#28](https://github.com/sissbruecker/linkding/issues/28)
- [**enhancement**] Allow specifying port through LINKDING_PORT environment variable [#156](https://github.com/sissbruecker/linkding/pull/156)
- [**chore**] Bump NPM packages [#224](https://github.com/sissbruecker/linkding/pull/224)
---
## v1.8.6 (25/03/2022)
- [bug] fix bookmark access restrictions
- [bug] prevent external redirects
- [chore] bump dependencies
---
## v1.8.5 (12/12/2021)
- [**bug**] Ensure tag names do not contain spaces [#182](https://github.com/sissbruecker/linkding/issues/182)
- [**bug**] Consider not copying whole GIT repository to Docker image [#174](https://github.com/sissbruecker/linkding/issues/174)
- [**enhancement**] Make bookmarks count column in admin sortable [#183](https://github.com/sissbruecker/linkding/pull/183)
---
## v1.8.4 (16/10/2021)
- [**enhancement**] Allow non-admin users to change their password [#166](https://github.com/sissbruecker/linkding/issues/166)
---
## v1.8.3 (03/10/2021)
- [**enhancement**] Enhancement: let user configure to open links in same tab instead on a new window/tab [#27](https://github.com/sissbruecker/linkding/issues/27)
---
## v1.8.2 (02/10/2021)
- [**bug**] Fix jumping search box [#163](https://github.com/sissbruecker/linkding/pull/163)
---
## v1.8.1 (01/10/2021)
- [**enhancement**] Add global shortcut for search [#161](https://github.com/sissbruecker/linkding/pull/161)
- allows to press `s` to focus the search input
---
## v1.8.0 (04/09/2021)
- [**enhancement**] Wayback Machine Integration [#59](https://github.com/sissbruecker/linkding/issues/59)
- Automatically creates snapshots of bookmarked websites on [web archive](https://archive.org/web/)
- This is one of the largest changes yet and adds a task processor that runs as a separate process in the background. If you run into issues with this feature, it can be disabled using the [LD_DISABLE_BACKGROUND_TASKS](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_disable_background_tasks) option
---
## v1.7.2 (26/08/2021)
- [**enhancement**] Add support for nanosecond resolution timestamps for bookmark import (e.g. Google Bookmarks) [#146](https://github.com/sissbruecker/linkding/issues/146)
---
## v1.7.1 (25/08/2021)
- [**bug**] umlaut/non-ascii characters broken when using bookmarklet (firefox) [#148](https://github.com/sissbruecker/linkding/issues/148)
- [**bug**] Bookmark import accepts empty URL values [#124](https://github.com/sissbruecker/linkding/issues/124)
- [**enhancement**] Show the version in the settings [#104](https://github.com/sissbruecker/linkding/issues/104)
---
## v1.7.0 (17/08/2021)
- Upgrade to Django 3
- Bump other dependencies
---
## v1.6.5 (15/08/2021)
- [**enhancement**] query with multiple hashtags very slow [#112](https://github.com/sissbruecker/linkding/issues/112)
---
## v1.6.4 (13/05/2021)
- Update dependencies for security fixes
---
## v1.6.3 (06/04/2021)
- [**bug**] relative names use the wrong "today" after day change [#107](https://github.com/sissbruecker/linkding/issues/107)
---
## v1.6.2 (04/04/2021)
- [**enhancement**] Expose `date_added` in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
- [**closed**] Archived bookmarks - no result when searching for a word which is used only as tag [#83](https://github.com/sissbruecker/linkding/issues/83)
- [**closed**] Add archive/unarchive button to edit bookmark page [#82](https://github.com/sissbruecker/linkding/issues/82)
- [**enhancement**] Make scraped title and description editable [#80](https://github.com/sissbruecker/linkding/issues/80)
---
## v1.6.1 (31/03/2021)
- Expose date_added in UI [#85](https://github.com/sissbruecker/linkding/issues/85)
---
## v1.6.0 (28/03/2021)
- Bulk edit mode [#101](https://github.com/sissbruecker/linkding/pull/101)
---
## v1.5.0 (28/03/2021)
- [**closed**] Add a dark mode [#49](https://github.com/sissbruecker/linkding/issues/49)
---
## v1.4.1 (20/03/2021)
- Security patches
- Documentation improvements
---
## v1.4.0 (24/02/2021)
- [**enhancement**] Improve admin utilization [#76](https://github.com/sissbruecker/linkding/issues/76)
---
## v1.3.3 (18/02/2021)
- [**closed**] Missing "description" request body parameter in API causes 500 [#78](https://github.com/sissbruecker/linkding/issues/78)
---
## v1.3.2 (18/02/2021)
- [**closed**] /archive and /unarchive API routes return 404 [#77](https://github.com/sissbruecker/linkding/issues/77)
- [**closed**] API - /api/check_url?url= with token authetification [#55](https://github.com/sissbruecker/linkding/issues/55)
---
## v1.3.1 (15/02/2021)
[enhancement] Enhance delete links with inline confirmation
---
## v1.3.0 (14/02/2021)
- [**closed**] Novice help. [#71](https://github.com/sissbruecker/linkding/issues/71)
- [**closed**] Option to create bookmarks public [#70](https://github.com/sissbruecker/linkding/issues/70)
- [**enhancement**] Show URL if title is not available [#64](https://github.com/sissbruecker/linkding/issues/64)
- [**bug**] minor ui nitpicks [#62](https://github.com/sissbruecker/linkding/issues/62)
- [**enhancement**] add an archive function [#46](https://github.com/sissbruecker/linkding/issues/46)
- [**closed**] remove non fqdn check and alert [#36](https://github.com/sissbruecker/linkding/issues/36)
- [**closed**] Add Lotus Notes links [#22](https://github.com/sissbruecker/linkding/issues/22)
---
## v1.2.1 (12/01/2021)
- [**bug**] Bug: Two equal tags with different capitalisation lead to 500 server errors [#65](https://github.com/sissbruecker/linkding/issues/65)
- [**closed**] Enhancement: category and pagination [#11](https://github.com/sissbruecker/linkding/issues/11)
---
## v1.2.0 (09/01/2021)
- [**closed**] Add Favicon [#58](https://github.com/sissbruecker/linkding/issues/58)
- [**closed**] Make tags case-insensitive [#45](https://github.com/sissbruecker/linkding/issues/45)
---
## v1.1.1 (01/01/2021)
- [**enhancement**] Add docker-compose support [#54](https://github.com/sissbruecker/linkding/pull/54)
---
## v1.1.0 (31/12/2020)
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Search autocomplete [#52](https://github.com/sissbruecker/linkding/issues/52)
- [**enhancement**] Improve Netscape bookmarks file parsing [#50](https://github.com/sissbruecker/linkding/issues/50)
---
## v1.0.0 (31/12/2020)
- [**bug**] Import does not import bookmark descriptions [#47](https://github.com/sissbruecker/linkding/issues/47)
- [**enhancement**] Enhancement: return to same page we were on after editing a bookmark [#26](https://github.com/sissbruecker/linkding/issues/26)
- [**bug**] Increase limit on bookmark URL length [#25](https://github.com/sissbruecker/linkding/issues/25)
@@ -747,4 +30,4 @@
- [**bug**] Error importing bookmarks [#18](https://github.com/sissbruecker/linkding/issues/18)
- [**enhancement**] Enhancement: better administration page [#4](https://github.com/sissbruecker/linkding/issues/4)
- [**enhancement**] Bug: Navigation bar active link stays on add bookmark [#3](https://github.com/sissbruecker/linkding/issues/3)
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)
- [**bug**] CSS Stylesheet presented as text/plain [#2](https://github.com/sissbruecker/linkding/issues/2)

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.7-slim-stretch
# Install packages required for uswgi
RUN apt-get update
RUN apt-get -y install build-essential
RUN apt-get -y install mime-support
# Install requirements and uwsgi server for running python web apps
WORKDIR /etc/linkding
COPY requirements.prod.txt ./requirements.txt
RUN pip install -U pip
RUN pip install -Ur requirements.txt
# Copy application
COPY . .
# Expose uwsgi server at port 9090
EXPOSE 9090
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
CMD ["./bootstrap.sh"]

View File

@@ -1,15 +0,0 @@
.PHONY: serve
serve:
python manage.py runserver
tasks:
python manage.py process_tasks
test:
pytest
format:
black bookmarks
black siteroot
npx prettier bookmarks/frontend --write

38
Options.md Normal file
View File

@@ -0,0 +1,38 @@
# Options
This document lists the options that linkding can be configured with and explains how to use them in the individual install scenarios.
## Using options
### Docker
Options are passed as environment variables to the Docker container by using the `-e` argument when using `docker run`. For example:
```
docker run --name linkding -p 9090:9090 -d -e LD_DISABLE_URL_VALIDATION=True sissbruecker/linkding:latest
```
For multiple options, use one `-e` argument per option.
### Docker-compose
For docker-compose options are configured using an `.env` file.
Follow the docker-compose setup in the README and copy `.env.sample` to `.env`. Then modify the options in `.env`.
### Manual setup
All options need to be defined as environment variables in the environment that linkding runs in.
## List of options
### `LD_DISABLE_URL_VALIDATION`
Values: `True`, `False` | Default = `False`
Completely disables URL validation for bookmarks. This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
### `LD_REQUEST_TIMEOUT`
Values: `Integer` as seconds | Default = `60`
Configures the request timeout in the uwsgi application server. This can be useful if you want to import a bookmark file with a high number of bookmarks and run into request timeouts.

282
README.md
View File

@@ -1,51 +1,13 @@
<div align="center">
<br>
<a href="https://github.com/sissbruecker/linkding">
<img src="docs/header.svg" height="50">
</a>
<br>
</div>
# linkding
## Overview
- [Introduction](#introduction)
- [Installation](#installation)
- [Using Docker](#using-docker)
- [Using Docker Compose](#using-docker-compose)
- [User Setup](#user-setup)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Managed Hosting Options](#managed-hosting-options)
- [Documentation](#documentation)
- [Browser Extension](#browser-extension)
- [Community](#community)
- [Acknowledgements + Donations](#acknowledgements--donations)
- [Development](#development)
## Introduction
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.
*linkding* is a simple bookmark service that you can host yourself. It supports managing bookmarks, categorizing them with tags and has a search function. It provides a bookmarklet for quickly adding new bookmarks while browsing the web. It also supports import / export of bookmarks in the Netscape HTML format. And that's basically it 🙂.
The name comes from:
- *link* which is often used as a synonym for URLs and bookmarks in common language
- *Ding* which is German for thing
- ...so basically something for managing your links
- *Ding* which is german for *thing*
- ...so basically some thing for managing your links
**Feature Overview:**
- Clean UI optimized for readability
- Organize bookmarks with tags
- Bulk editing, Markdown notes, read it later functionality
- Share bookmarks with other users or guests
- Automatically provides titles, descriptions and icons of bookmarked websites
- Automatically archive websites, either as local HTML file or on Internet Archive
- Import and export bookmarks in Netscape HTML format
- Installable as a Progressive Web App (PWA)
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- SSO support via OIDC or authentication proxies
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)
**Demo:** https://demo.linkding.link/ (configured with open registration)
**Screenshot:**
@@ -53,214 +15,98 @@ The name comes from:
## Installation
linkding is designed to be run with container solutions like [Docker](https://docs.docker.com/get-started/).
The Docker image is compatible with ARM platforms, so it can be run on a Raspberry Pi.
The easiest way to run linkding is to use [Docker](https://docs.docker.com/get-started/). The Docker image should be compatible with ARM platforms, so it can be run on a Raspberry Pi.
linkding uses an SQLite database by default.
Alternatively linkding supports PostgreSQL, see the [database options](docs/Options.md#LD_DB_ENGINE) for more information.
There is also the option to set up the installation manually which I do not support, but can give some pointers on below.
### Using Docker
### Docker setup
The Docker image comes in several variants. To use a different image than the default, replace `latest` with the desired tag in the commands below, or in the docker-compose file.
To install linkding using Docker you can just run the image from the Docker registry:
```
docker run --name linkding -p 9090:9090 -d sissbruecker/linkding:latest
```
By default the application runs on port `9090`, but you can map it to a different host port by modifying the command above.
<table>
<thead>
<tr>
<th>Tag</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>latest</code></td>
<td>Provides the basic functionality of linkding</td>
</tr>
<tr>
<td><code>latest-plus</code></td>
<td>
Includes feature for archiving websites as HTML snapshots
<ul>
<li>Significantly larger image size as it includes a Chromium installation</li>
<li>Requires more runtime memory to run Chromium</li>
<li>Requires more disk space for storing HTML snapshots</li>
</ul>
</td>
</tr>
<tr>
<td><code>latest-alpine</code></td>
<td><code>latest</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
<tr>
<td><code>latest-plus-alpine</code></td>
<td><code>latest-plus</code>, but based on Alpine Linux. 🧪 Experimental</td>
</tr>
</tbody>
</table>
To install linkding using Docker you can just run the image from [Docker Hub](https://hub.docker.com/repository/docker/sissbruecker/linkding):
However for **production use** you also want to mount a data folder on your system, so that the applications database is not stored in the container, but on your hosts file system. This is safer in case something happens to the container and makes it easier to update the container later on, or to run backups. To do so you can use the following extended command, where you replace `{host-data-folder}` with the absolute path to a folder on your system where you want to store the data:
```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, provided you did not change the port mapping.
If everything completed successfully, the application should now be running and can be accessed at http://localhost:9090.
### Automated Docker setup
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.
If you are using a Linux system you can use the following [shell script](https://github.com/sissbruecker/linkding/blob/master/install-linkding.sh) for an automated setup. The script does basically everything described above, but also handles updating an existing container to a new application version (technically replaces the existing container with a new container built from a newer image, while mounting the same data folder).
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
The script can be configured using shell variables - for more details have a look at the script itself.
### Using Docker Compose
### Docker-compose setup
To install linkding using [Docker Compose](https://docs.docker.com/compose/), you can use the [`docker-compose.yml`](https://github.com/sissbruecker/linkding/blob/master/docker-compose.yml) file. Copy the [`.env.sample`](https://github.com/sissbruecker/linkding/blob/master/.env.sample) file to `.env`, configure the parameters, and then run:
To install linkding using docker-compose you can use the `docker-compose.yml` file. Copy the `.env.sample` file to `.env` and set your parameters, then run:
```shell
docker-compose up -d
```
To complete the setup, you still have to [create an initial user](#user-setup), so that you can access your installation.
### User setup
### User Setup
For security reasons, the linkding Docker image does not provide an initial user, so you have to create one after setting up an installation. To do so, replace the credentials in the following command and run it:
Finally you need to create a user so that you can access the frontend. Replace the credentials in the following command and run it:
**Docker**
```shell
docker exec -it linkding python manage.py createsuperuser --username=joe --email=joe@example.com
```
**Docker Compose**
**Docker-compose**
```shell
docker-compose exec linkding python manage.py createsuperuser --username=joe --email=joe@example.com
```
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).
### Manual setup
### Reverse Proxy Setup
If you can not or don't want to use Docker you can install the application manually on your server. To do so you can basically follow the steps from the *Development* section below while cross-referencing the `Dockerfile` and `bootstrap.sh` on how to make the application production-ready.
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.
### Options
<details>
<summary>Apache</summary>
Check the [options document](Options.md) on how to configure your linkding installation.
Apache2 does not change the headers by default, and should not
need additional configuration.
### Hosting
An example virtual host that proxies to linkding might look like:
```
<VirtualHost *:9100>
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
The application runs in a web-server called [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) that is production-ready and that you can expose to the web. If you don't know how to configure your server to expose the application to the web there are several more steps involved. I can not support support the process here, but I can give some pointers on what to search for:
- first get the app running (described in this document)
- open the port that the application is running on in your servers firewall
- depending on your network configuration, forward the opened port in your network router, so that the application can be addressed from the internet using your public IP address and the opened port
ProxyPass / http://linkding:9090/
ProxyPassReverse / http://linkding:9090/
</VirtualHost>
```
### Backups
For a full example, see the docker-compose configuration in [jhauris/apache2-reverse-proxy](https://github.com/jhauris/linkding/tree/apache2-reverse-proxy)
For backups you have two options: manually or automatic.
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
For manual backups you can export your bookmarks from the UI and store them on a backup device or online service.
</details>
For automatic backups you want to backup the applications database. As described above, for production setups you should [mount](https://stackoverflow.com/questions/23439126/how-to-mount-a-host-directory-in-a-docker-container) the `/etc/linkding/data` directory from the Docker container to a directory on your host system. You can then use a backup tool of your choice to backup the contents of that directory.
<details>
<summary>Caddy 2</summary>
## API
Caddy does not change the headers by default, and should not need any further configuration.
The application provides a REST API that can be used by 3rd party applications to manage bookmarks. Check the [API docs](API.md) for further information.
If you still run into CSRF issues, please check out the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
## Troubleshooting
</details>
**Import fails with `502 Bad Gateway`**
<details>
<summary>Nginx</summary>
The default timeout for requests is 60 seconds, after which the application server will cancel the request and return the above error.
Depending on the system that the application runs on, and the number of bookmarks that need to be imported, the import may take longer than the default 60 seconds.
Nginx by default rewrites the `Host` header to whatever URL is used in the `proxy_pass` directive.
To forward the correct headers to linkding, add the following directives to the location block of your Nginx config:
```
location /linkding {
...
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
To increase the timeout you can configure the [`LD_REQUEST_TIMEOUT` option](Options.md#LD_REQUEST_TIMEOUT).
</details>
Instead of configuring header forwarding in your proxy, you can also configure the URL from which you want to access your linkding instance with the [`LD_CSRF_TRUSTED_ORIGINS` option](docs/Options.md#LD_CSRF_TRUSTED_ORIGINS).
### Managed Hosting Options
Self-hosting web applications still requires a lot of technical know-how and commitment to maintenance, in order to keep everything up-to-date and secure. This section is intended to provide simple alternatives in form of managed hosting solutions.
- [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) ([Disclosure](#pikapods))
- [CapRover](https://caprover.com/) - Linkding is included as a default one-click app
- [linkding on railway.app](https://github.com/tianheg/linkding-on-railway) - Guide for hosting a linkding installation on [railway.app](https://railway.app/). By [tianheg](https://github.com/tianheg)
## Documentation
| Document | Description |
|-------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| [Options](https://github.com/sissbruecker/linkding/blob/master/docs/Options.md) | Lists available options, and describes how to apply them |
| [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 |
## Browser Extension
linkding comes with an official browser extension that allows to quickly add bookmarks, and search bookmarks through the browser's address bar. You can get the extension here:
- [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/)
- [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe)
The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension).
## Community
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)
- [feed2linkding](https://codeberg.org/strubbl/feed2linkding) A commandline utility to add all web feed item links to linkding via API call. By [Strubbl](https://github.com/Strubbl)
- [Helm Chart](https://charts.pascaliske.dev/charts/linkding/) Helm Chart for deploying linkding inside a Kubernetes cluster. By [pascaliske](https://github.com/pascaliske)
- [iOS Shortcut using API and Tagging](https://gist.github.com/andrewdolphin/a7dff49505e588d940bec55132fab8ad) An iOS shortcut using the Linkding API (no extra logins required) that pulls previously used tags and allows tagging at the time of link creation.
- [k8s + s3](https://github.com/jzck/linkding-k8s-s3) - Setup for hosting stateless linkding on k8s with sqlite replicated to s3. By [jzck](https://github.com/jzck)
- [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)
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
- [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 + Donations
### PikaPods
[PikaPods](https://www.pikapods.com/) has a revenue sharing agreement with this project, sharing some of their revenue from hosting linkding instances. I do not intend to profit from this project financially, so I am in turn donating that revenue. Big thanks to PikaPods for making this possible.
See the table below for a list of donations.
| Source | Description | Amount | Donated to |
|---------------------------------------|---------------------------------------------|---------|---------------------------------------------------------------------|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/docs/donations/2023-10-11-internet-archive.png) |
### JetBrains
JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding.
Note that any proxy servers that you are running in front of linkding may have their own timeout settings, which are not affected by the variable.
## Development
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent [Django docs](https://docs.djangoproject.com/en/4.1/). The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
The application is open source, so you are free to modify or contribute. The application is built using the Django web framework. You can get started by checking out the excellent Django docs: https://docs.djangoproject.com/en/3.0/. The `bookmarks` folder contains the actual bookmark application, `siteroot` is the Django root application. Other than that the code should be self-explanatory / standard Django stuff 🙂.
### Prerequisites
- Python 3.10
- Python 3
- Node.js
### Setup
@@ -275,7 +121,7 @@ source ~/environments/linkding/bin/activate[.csh|.fish]
```
Within the active environment install the application dependencies from the application folder:
```
pip3 install -r requirements.txt -r requirements.dev.txt
pip3 install -Ur requirements.txt
```
Install frontend dependencies:
```
@@ -300,36 +146,6 @@ python3 manage.py runserver
```
The frontend is now available under http://localhost:8000
### Tests
## Community
Run all tests with pytest:
```
pytest
```
### Formatting
Format Python code with black, and JavaScript code with prettier:
```
make format
```
### DevContainers
This repository also supports DevContainers: [![Open in Remote - Containers](https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](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
- [linkding-extension](https://github.com/jeroenpardon/linkding-extension) Chromium compatible extension that wraps the linkding bookmarklet. Tested with Chrome, Edge, Brave. By [jeroenpardon](https://github.com/jeroenpardon)

View File

@@ -1,13 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 1.10.x | :white_check_mark: |
## Reporting a Vulnerability
To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com
I'll try to get back to you as soon as possible.

Binary file not shown.

View File

@@ -1,291 +1,17 @@
from django.contrib import admin, messages
from django.contrib.admin import AdminSite
from django.contrib.auth.admin import UserAdmin
from django.contrib.auth.models import User
from django.core.paginator import Paginator
from django.db.models import Count, QuerySet
from django.shortcuts import render
from django.urls import path
from django.utils.translation import ngettext, gettext
from huey.contrib.djhuey import HUEY as huey
from rest_framework.authtoken.admin import TokenAdmin
from rest_framework.authtoken.models import TokenProxy
from bookmarks.models import Bookmark, BookmarkAsset, Tag, UserProfile, Toast, FeedToken
from bookmarks.services.bookmarks import archive_bookmark, unarchive_bookmark
# Custom paginator to paginate through Huey tasks
class TaskPaginator(Paginator):
def __init__(self):
super().__init__(self, 100)
self.task_count = huey.storage.queue_size()
@property
def count(self):
return self.task_count
def page(self, number):
limit = self.per_page
offset = (number - 1) * self.per_page
return self._get_page(
self.enqueued_items(limit, offset),
number,
self,
)
# Copied from Huey's SqliteStorage with some modifications to allow pagination
def enqueued_items(self, limit, offset):
to_bytes = lambda b: bytes(b) if not isinstance(b, bytes) else b
sql = "select data from task where queue=? order by priority desc, id limit ? offset ?"
params = (huey.storage.name, limit, offset)
serialized_tasks = [
to_bytes(i) for i, in huey.storage.sql(sql, params, results=True)
]
return [huey.deserialize_task(task) for task in serialized_tasks]
# Custom view to display Huey tasks in the admin
def background_task_view(request):
page_number = int(request.GET.get("p", 1))
paginator = TaskPaginator()
page = paginator.get_page(page_number)
page_range = paginator.get_elided_page_range(page_number, on_each_side=2, on_ends=2)
context = {
**linkding_admin_site.each_context(request),
"title": "Background tasks",
"page": page,
"page_range": page_range,
"tasks": page.object_list,
}
return render(request, "admin/background_tasks.html", context)
class LinkdingAdminSite(AdminSite):
site_header = "linkding administration"
site_title = "linkding Admin"
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path("tasks/", background_task_view, name="background_tasks"),
]
return custom_urls + urls
def get_app_list(self, request, app_label=None):
app_list = super().get_app_list(request, app_label)
app_list += [
{
"name": "Huey",
"app_label": "huey_app",
"models": [
{
"name": "Queued tasks",
"object_name": "background_tasks",
"admin_url": "/admin/tasks/",
"view_only": True,
}
],
}
]
return app_list
from django.contrib import admin
from bookmarks.models import Bookmark, Tag
@admin.register(Bookmark)
class AdminBookmark(admin.ModelAdmin):
list_display = ("resolved_title", "url", "is_archived", "owner", "date_added")
search_fields = (
"title",
"description",
"website_title",
"website_description",
"url",
"tags__name",
)
list_filter = (
"owner__username",
"is_archived",
"unread",
"tags",
)
ordering = ("-date_added",)
actions = [
"delete_selected_bookmarks",
"archive_selected_bookmarks",
"unarchive_selected_bookmarks",
"mark_as_read",
"mark_as_unread",
]
def get_actions(self, request):
actions = super().get_actions(request)
# Remove default delete action, which gets replaced by delete_selected_bookmarks below
# The default action shows a confirmation page which can fail in production when selecting all bookmarks and the
# number of objects to delete exceeds the value in DATA_UPLOAD_MAX_NUMBER_FIELDS (1000 by default)
del actions["delete_selected"]
return actions
def delete_selected_bookmarks(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
for bookmark in queryset:
bookmark.delete()
self.message_user(
request,
ngettext(
"%d bookmark was successfully deleted.",
"%d bookmarks were successfully deleted.",
bookmarks_count,
)
% bookmarks_count,
messages.SUCCESS,
)
def archive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
archive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(
request,
ngettext(
"%d bookmark was successfully archived.",
"%d bookmarks were successfully archived.",
bookmarks_count,
)
% bookmarks_count,
messages.SUCCESS,
)
def unarchive_selected_bookmarks(self, request, queryset: QuerySet):
for bookmark in queryset:
unarchive_bookmark(bookmark)
bookmarks_count = queryset.count()
self.message_user(
request,
ngettext(
"%d bookmark was successfully unarchived.",
"%d bookmarks were successfully unarchived.",
bookmarks_count,
)
% bookmarks_count,
messages.SUCCESS,
)
def mark_as_read(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=False)
self.message_user(
request,
ngettext(
"%d bookmark marked as read.",
"%d bookmarks marked as read.",
bookmarks_count,
)
% bookmarks_count,
messages.SUCCESS,
)
def mark_as_unread(self, request, queryset: QuerySet):
bookmarks_count = queryset.count()
queryset.update(unread=True)
self.message_user(
request,
ngettext(
"%d bookmark marked as unread.",
"%d bookmarks marked as unread.",
bookmarks_count,
)
% bookmarks_count,
messages.SUCCESS,
)
class AdminBookmarkAsset(admin.ModelAdmin):
list_display = ("display_name", "date_created", "status")
search_fields = (
"display_name",
"file",
)
list_filter = ("status",)
list_display = ('title', 'url', 'date_added')
search_fields = ('title', 'url', 'tags__name')
list_filter = ('tags',)
ordering = ('-date_added', )
@admin.register(Tag)
class AdminTag(admin.ModelAdmin):
list_display = ("name", "bookmarks_count", "owner", "date_added")
search_fields = ("name", "owner__username")
list_filter = ("owner__username",)
ordering = ("-date_added",)
actions = ["delete_unused_tags"]
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(bookmarks_count=Count("bookmark"))
return queryset
def bookmarks_count(self, obj):
return obj.bookmarks_count
bookmarks_count.admin_order_field = "bookmarks_count"
def delete_unused_tags(self, request, queryset: QuerySet):
unused_tags = queryset.filter(bookmark__isnull=True)
unused_tags_count = unused_tags.count()
for tag in unused_tags:
tag.delete()
if unused_tags_count > 0:
self.message_user(
request,
ngettext(
"%d unused tag was successfully deleted.",
"%d unused tags were successfully deleted.",
unused_tags_count,
)
% unused_tags_count,
messages.SUCCESS,
)
else:
self.message_user(
request,
gettext(
"There were no unused tags in the selection",
),
messages.SUCCESS,
)
class AdminUserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
verbose_name_plural = "Profile"
fk_name = "user"
readonly_fields = ("search_preferences",)
class AdminCustomUser(UserAdmin):
inlines = (AdminUserProfileInline,)
def get_inline_instances(self, request, obj=None):
if not obj:
return list()
return super(AdminCustomUser, self).get_inline_instances(request, obj)
class AdminToast(admin.ModelAdmin):
list_display = ("key", "message", "owner", "acknowledged")
search_fields = ("key", "message")
list_filter = ("owner__username",)
class AdminFeedToken(admin.ModelAdmin):
list_display = ("key", "user")
search_fields = ["key"]
list_filter = ("user__username",)
linkding_admin_site = LinkdingAdminSite()
linkding_admin_site.register(Bookmark, AdminBookmark)
linkding_admin_site.register(BookmarkAsset, AdminBookmarkAsset)
linkding_admin_site.register(Tag, AdminTag)
linkding_admin_site.register(User, AdminCustomUser)
linkding_admin_site.register(TokenProxy, TokenAdmin)
linkding_admin_site.register(Toast, AdminToast)
linkding_admin_site.register(FeedToken, AdminFeedToken)
list_display = ('name', 'date_added', 'owner')
search_fields = ('name', 'owner__username')
list_filter = ('owner__username', )
ordering = ('-date_added', )

View File

@@ -1,121 +1,49 @@
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import (
BookmarkSerializer,
TagSerializer,
UserProfileSerializer,
)
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services.bookmarks import (
archive_bookmark,
unarchive_bookmark,
website_loader,
)
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.api.serializers import BookmarkSerializer, TagSerializer
from bookmarks.models import Bookmark, Tag
class BookmarkViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
):
class BookmarkViewSet(viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin):
serializer_class = BookmarkSerializer
def get_permissions(self):
# Allow unauthenticated access to shared bookmarks.
# The shared action should still filter bookmarks so that
# unauthenticated users only see bookmarks from users that have public
# sharing explicitly enabled
if self.action == "shared":
return [AllowAny()]
# Otherwise use default permissions which should require authentication
return super().get_permissions()
def get_queryset(self):
user = self.request.user
# For list action, use query set that applies search and tag projections
if self.action == "list":
search = BookmarkSearch.from_request(self.request.GET)
return queries.query_bookmarks(user, user.profile, search)
if self.action == 'list':
query_string = self.request.GET.get('q')
return queries.query_bookmarks(user, query_string)
# For single entity actions use default query set without projections
return Bookmark.objects.all().filter(owner=user)
def get_serializer_context(self):
return {"user": self.request.user}
return {'user': self.request.user}
@action(methods=["get"], detail=False)
@action(methods=['get'], detail=False)
def archived(self, request):
user = request.user
search = BookmarkSearch.from_request(request.GET)
query_set = queries.query_archived_bookmarks(user, user.profile, search)
query_string = request.GET.get('q')
query_set = queries.query_archived_bookmarks(user, query_string)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
return self.get_paginated_response(data)
@action(methods=["get"], detail=False)
def shared(self, request):
search = BookmarkSearch.from_request(request.GET)
user = User.objects.filter(username=search.user).first()
public_only = not request.user.is_authenticated
query_set = queries.query_shared_bookmarks(
user, request.user_profile, search, public_only
)
page = self.paginate_queryset(query_set)
serializer = self.get_serializer_class()
data = serializer(page, many=True).data
return self.get_paginated_response(data)
@action(methods=["post"], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["post"], detail=True)
def unarchive(self, request, pk):
bookmark = self.get_object()
unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["get"], detail=False)
def check(self, request):
url = request.GET.get("url")
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
existing_bookmark_data = (
self.get_serializer(bookmark).data if bookmark else None
)
# Either return metadata from existing bookmark, or scrape from URL
if bookmark:
metadata = WebsiteMetadata(
url, bookmark.website_title, bookmark.website_description
)
else:
metadata = website_loader.load_website_metadata(url)
return Response(
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
status=status.HTTP_200_OK,
)
class TagViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
):
class TagViewSet(viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.CreateModelMixin):
serializer_class = TagSerializer
def get_queryset(self):
@@ -123,16 +51,9 @@ class TagViewSet(
return Tag.objects.all().filter(owner=user)
def get_serializer_context(self):
return {"user": self.request.user}
class UserViewSet(viewsets.GenericViewSet):
@action(methods=["get"], detail=False)
def profile(self, request):
return Response(UserProfileSerializer(request.user.profile).data)
return {'user': self.request.user}
router = DefaultRouter()
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
router.register(r"tags", TagViewSet, basename="tag")
router.register(r"user", UserViewSet, basename="user")
router.register(r'bookmarks', BookmarkViewSet, basename='bookmark')
router.register(r'tags', TagViewSet, basename='tag')

View File

@@ -1,8 +1,6 @@
from django.db.models import prefetch_related_objects
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
from bookmarks.models import Bookmark, Tag, build_tag_string
from bookmarks.services.bookmarks import create_bookmark, update_bookmark
from bookmarks.services.tags import get_or_create_tag
@@ -11,101 +9,51 @@ class TagListField(serializers.ListField):
child = serializers.CharField()
class BookmarkListSerializer(ListSerializer):
def to_representation(self, data):
# Prefetch nested relations to avoid n+1 queries
prefetch_related_objects(data, "tags")
return super().to_representation(data)
class BookmarkSerializer(serializers.ModelSerializer):
class Meta:
model = Bookmark
fields = [
"id",
"url",
"title",
"description",
"notes",
"website_title",
"website_description",
"web_archive_snapshot_url",
"is_archived",
"unread",
"shared",
"tag_names",
"date_added",
"date_modified",
'id',
'url',
'title',
'description',
'website_title',
'website_description',
'tag_names',
'date_added',
'date_modified'
]
read_only_fields = [
"website_title",
"website_description",
"web_archive_snapshot_url",
"date_added",
"date_modified",
'website_title',
'website_description',
'date_added',
'date_modified'
]
list_serializer_class = BookmarkListSerializer
# 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)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
tag_names = TagListField()
def create(self, validated_data):
bookmark = Bookmark()
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"]
tag_string = build_tag_string(validated_data["tag_names"])
return create_bookmark(bookmark, tag_string, self.context["user"])
bookmark.url = validated_data['url']
bookmark.title = validated_data['title']
bookmark.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'], ' ')
return create_bookmark(bookmark, tag_string, self.context['user'])
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ["url", "title", "description", "notes", "unread", "shared"]:
if key in validated_data:
setattr(instance, key, validated_data[key])
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if "tag_names" in validated_data:
tag_string = build_tag_string(validated_data["tag_names"])
return update_bookmark(instance, tag_string, self.context["user"])
instance.url = validated_data['url']
instance.title = validated_data['title']
instance.description = validated_data['description']
tag_string = build_tag_string(validated_data['tag_names'], ' ')
return update_bookmark(instance, tag_string, self.context['user'])
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ["id", "name", "date_added"]
read_only_fields = ["date_added"]
fields = ['id', 'name', 'date_added']
read_only_fields = ['date_added']
def create(self, validated_data):
return get_or_create_tag(validated_data["name"], self.context["user"])
class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = UserProfile
fields = [
"theme",
"bookmark_date_display",
"bookmark_link_target",
"web_archive_integration",
"tag_search",
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"display_url",
"permanent_notes",
"search_preferences",
]
return get_or_create_tag(validated_data['name'], self.context['user'])

View File

@@ -2,8 +2,4 @@ from django.apps import AppConfig
class BookmarksConfig(AppConfig):
name = "bookmarks"
def ready(self):
# Register signal handlers
import bookmarks.signals
name = 'bookmarks'

View File

@@ -0,0 +1,280 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "./util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = 'default';
export let apiClient;
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const search = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const fetchedBookmarks = mode === 'archive'
? await apiClient.getArchivedBookmarks(value, {limit: 5, offset: 0})
: await apiClient.getBookmarks(value, {limit: 5, offset: 0})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(search, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(search, bookmarks, tagSuggestions) {
search = search || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
search,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...search,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, '_blank')
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.search.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.search as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
<div class="tile tile-centered">
<div class="tile-content">
{suggestion.label}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
.group-item, .group-item:hover {
color: #999999;
text-transform: uppercase;
background: none;
font-size: 0.6rem;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,48 @@
const SEARCH_HISTORY_KEY = 'searchHistory'
const MAX_ENTRIES = 30
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY)
return historyJson ? JSON.parse(historyJson) : {
recent: []
}
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get('q');
if (!searchParam) return
this.push(searchParam)
}
push(search) {
const history = this.getHistory()
history.recent.unshift(search)
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc
if (acc.indexOf(cur) >= 0) return acc
acc.push(cur)
return acc
}, [])
const newHistoryJson = JSON.stringify(history)
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson)
}
getRecentSearches(query, max) {
const history = this.getHistory()
return history.recent
.filter(search => !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0)
.slice(0, max)
}
}

View File

@@ -0,0 +1,133 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "./util";
export let id;
export let name;
export let value;
export let tags;
let isFocus = false;
let isOpen = false;
let input = null;
let suggestions = [];
let selectedIndex = 0;
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word ? tags.filter(tag => tag.indexOf(word) === 0) : [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}"
class="form-input" type="text" autocomplete="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
<div class="tile tile-centered">
<div class="tile-content">
{tag}
</div>
</div>
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
/* TODO: Should be read from theme */
.menu-item.selected > a {
background: #f1f1fc;
color: #5755d9;
}
</style>

View File

@@ -0,0 +1,23 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl
}
getBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
getArchivedBookmarks(query, options = {limit: 100, offset: 0}) {
const encodedQuery = encodeURIComponent(query)
const url = `${this.baseUrl}bookmarks/archived?q=${encodedQuery}&limit=${options.limit}&offset=${options.offset}`
return fetch(url)
.then(response => response.json())
.then(data => data.results)
}
}

View File

@@ -0,0 +1,10 @@
import TagAutoComplete from './TagAutocomplete.svelte'
import SearchAutoComplete from './SearchAutoComplete.svelte'
import {ApiClient} from './api'
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete
}

View File

@@ -0,0 +1,37 @@
export function debounce(callback, delay = 250) {
let timeoutId
return (...args) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
timeoutId = null
callback(...args)
}, delay)
}
}
export function clampText(text, maxChars = 30) {
if(!text || text.length <= 30) return text
return text.substr(0, maxChars) + '...'
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== ' ' && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return {start, end};
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View File

@@ -1,36 +0,0 @@
from bookmarks import queries
from bookmarks.models import BookmarkSearch, Toast
from bookmarks import utils
def toasts(request):
user = request.user
toast_messages = (
Toast.objects.filter(owner=user, acknowledged=False)
if user.is_authenticated
else []
)
has_toasts = len(toast_messages) > 0
return {
"has_toasts": has_toasts,
"toast_messages": toast_messages,
}
def public_shares(request):
# Only check for public shares for anonymous users
if not request.user.is_authenticated:
query_set = queries.query_shared_bookmarks(
None, request.user_profile, BookmarkSearch(), True
)
has_public_shares = query_set.count() > 0
return {
"has_public_shares": has_public_shares,
}
return {}
def app_version(request):
return {"app_version": utils.app_version}

View File

@@ -1,133 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
def test_show_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
details_modal = self.open_details_modal(bookmark)
title = details_modal.locator("h2")
expect(title).to_have_text(bookmark.title)
def test_close_details(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
# close with close button
details_modal = self.open_details_modal(bookmark)
details_modal.locator("button.close").click()
expect(details_modal).to_be_hidden()
# close with backdrop
details_modal = self.open_details_modal(bookmark)
overlay = details_modal.locator(".modal-overlay")
overlay.click(position={"x": 0, "y": 0})
expect(details_modal).to_be_hidden()
def test_toggle_archived(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# archive
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
# unarchive
url = reverse("bookmarks:archived")
self.page.goto(self.live_server_url + url)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Archived", exact=False).click()
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
def test_toggle_unread(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# mark as unread
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).to_be_visible()
# mark as read
details_modal.get_by_text("Unread").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Unread")).not_to_be_visible()
def test_toggle_shared(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
bookmark = self.setup_bookmark()
with sync_playwright() as p:
# share bookmark
url = reverse("bookmarks:index")
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).to_be_visible()
# unshare bookmark
details_modal.get_by_text("Shared").click()
bookmark_item = self.locate_bookmark(bookmark.title)
expect(bookmark_item.get_by_text("Shared")).not_to_be_visible()
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
# Navigate to edit page
with self.page.expect_navigation():
details_modal.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
self.page.get_by_text("Nevermind").click()
def test_delete(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
url = reverse("bookmarks:index") + f"?q={bookmark.title}"
self.open(url, p)
details_modal = self.open_details_modal(bookmark)
# Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete...").click()
details_modal.get_by_text("Confirm").click()
# verify bookmark is deleted
self.locate_bookmark(bookmark.title)
expect(self.locate_bookmark(bookmark.title)).not_to_be_visible()
self.assertEqual(Bookmark.objects.count(), 0)

View File

@@ -1,37 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkDetailsViewE2ETestCase(LinkdingE2ETestCase):
def test_edit_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Navigate to edit page
with self.page.expect_navigation():
self.page.get_by_text("Edit").click()
# Cancel edit, verify return url
with self.page.expect_navigation(
url=self.live_server_url
+ reverse("bookmarks:details", args=[bookmark.id])
):
self.page.get_by_text("Nevermind").click()
def test_delete_return_url(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
self.open(reverse("bookmarks:details", args=[bookmark.id]), p)
# Trigger delete, verify return url
# Should probably return to last bookmark list page, but for now just returns to index
with self.page.expect_navigation(
url=self.live_server_url + reverse("bookmarks:index")
):
self.page.get_by_text("Delete...").click()
self.page.get_by_text("Confirm").click()

View File

@@ -1,87 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
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",
unread=True,
)
tag_names = " ".join(existing_bookmark.tag_names)
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:new"))
# Enter bookmarked URL
page.get_by_label("URL").fill(existing_bookmark.url)
# Already bookmarked hint should be visible
page.get_by_text("This URL is already bookmarked.").wait_for(timeout=2000)
# 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"),
)
self.assertEqual(tag_names, page.get_by_label("Tags").input_value())
self.assertTrue(tag_names, page.get_by_label("Mark as unread").is_checked())
# Enter non-bookmarked URL
page.get_by_label("URL").fill("https://example.com/unknown")
# Already bookmarked hint should be hidden
page.get_by_text("This URL is already bookmarked.").wait_for(
state="hidden", timeout=2000
)
browser.close()
def test_edit_should_not_check_for_existing_bookmark(self):
bookmark = self.setup_bookmark()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(
self.live_server_url + reverse("bookmarks:edit", args=[bookmark.id])
)
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="")

View File

@@ -1,25 +0,0 @@
from unittest import skip
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkItemE2ETestCase(LinkdingE2ETestCase):
@skip("Fails in CI, needs investigation")
def test_toggle_notes_should_show_hide_notes(self):
bookmark = self.setup_bookmark(notes="Test notes")
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
notes = self.locate_bookmark(bookmark.title).locator(".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()

View File

@@ -1,335 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import Bookmark
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_test_data(self):
self.setup_numbered_bookmarks(50)
self.setup_numbered_bookmarks(50, archived=True)
self.setup_numbered_bookmarks(50, prefix="foo")
self.setup_numbered_bookmarks(50, archived=True, prefix="foo")
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=False, title__startswith="Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=True, title__startswith="Archived Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
)
def test_active_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
0,
Bookmark.objects.filter(
is_archived=False, title__startswith="Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=True, title__startswith="Archived Bookmark"
).count(),
)
self.assertEqual(
0,
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
)
def test_archived_bookmarks_bulk_select_across(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=False, title__startswith="Bookmark"
).count(),
)
self.assertEqual(
0,
Bookmark.objects.filter(
is_archived=True, title__startswith="Archived Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
)
self.assertEqual(
0,
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
)
def test_active_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("bookmarks:index") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=False, title__startswith="Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=True, title__startswith="Archived Bookmark"
).count(),
)
self.assertEqual(
0,
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
)
def test_archived_bookmarks_bulk_select_across_respects_query(self):
self.setup_test_data()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived") + "?q=foo", p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=False, title__startswith="Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(
is_archived=True, title__startswith="Archived Bookmark"
).count(),
)
self.assertEqual(
50,
Bookmark.objects.filter(is_archived=False, title__startswith="foo").count(),
)
self.assertEqual(
0,
Bookmark.objects.filter(is_archived=True, title__startswith="foo").count(),
)
def test_select_all_toggles_all_checkboxes(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("bookmarks:index")
page = self.open(url, p)
self.locate_bulk_edit_toggle().click()
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
self.assertEqual(6, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).to_be_checked()
self.locate_bulk_edit_select_all().click()
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
def test_select_all_shows_select_across(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("bookmarks:index")
self.open(url, p)
self.locate_bulk_edit_toggle().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).to_be_visible()
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
def test_select_across_is_unchecked_when_toggling_all(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("bookmarks:index")
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling select all
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_select_across_is_unchecked_when_toggling_bookmark(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("bookmarks:index")
self.open(url, p)
self.locate_bulk_edit_toggle().click()
# Show select across, check it
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
expect(self.locate_bulk_edit_select_across()).to_be_checked()
# Hide select across by toggling a single bookmark
self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]"
).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_visible()
# Show select across again, verify it is unchecked
self.locate_bookmark("Bookmark 1").locator(
"label[ld-bulk-edit-checkbox]"
).click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_execute_resets_all_checkboxes(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse("bookmarks:index")
page = self.open(url, p)
bookmark_list = self.locate_bookmark_list()
# Select all bookmarks, enable select across
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
self.locate_bulk_edit_select_across().click()
# Execute bulk action
self.select_bulk_action("Mark as unread")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
# Verify bulk edit checkboxes are reset
checkboxes = page.locator("label[ld-bulk-edit-checkbox] input")
self.assertEqual(31, checkboxes.count())
for i in range(checkboxes.count()):
expect(checkboxes.nth(i)).not_to_be_checked()
# Toggle select all and verify select across is reset
self.locate_bulk_edit_select_all().click()
expect(self.locate_bulk_edit_select_across()).not_to_be_checked()
def test_update_select_across_bookmark_count(self):
self.setup_numbered_bookmarks(100)
with sync_playwright() as p:
url = reverse("bookmarks:index")
self.open(url, p)
bookmark_list = self.locate_bookmark_list()
self.locate_bulk_edit_toggle().click()
self.locate_bulk_edit_select_all().click()
expect(
self.locate_bulk_edit_bar().get_by_text("All pages (100 bookmarks)")
).to_be_visible()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
expect(
self.locate_bulk_edit_bar().get_by_text("All pages (70 bookmarks)")
).to_be_visible()

View File

@@ -1,308 +0,0 @@
from typing import List
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
def setup_fixture(self):
profile = self.get_or_create_test_user().profile
profile.enable_sharing = True
profile.save()
# create a number of bookmarks with different states / visibility to
# verify correct data is loaded on update
self.setup_numbered_bookmarks(3, with_tags=True)
self.setup_numbered_bookmarks(3, with_tags=True, archived=True)
self.setup_numbered_bookmarks(
3,
shared=True,
prefix="Joe's Bookmark",
user=self.setup_user(enable_sharing=True),
)
def assertVisibleBookmarks(self, titles: List[str]):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
expect(bookmark_tags).to_have_count(len(titles))
for title in titles:
matching_tag = bookmark_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def assertVisibleTags(self, titles: List[str]):
tag_tags = self.page.locator(".tag-cloud .unselected-tags a")
expect(tag_tags).to_have_count(len(titles))
for title in titles:
matching_tag = tag_tags.filter(has_text=title)
expect(matching_tag).to_be_visible()
def test_partial_update_respects_query(self):
self.setup_numbered_bookmarks(5, prefix="foo")
self.setup_numbered_bookmarks(5, prefix="bar")
with sync_playwright() as p:
url = reverse("bookmarks:index") + "?q=foo"
self.open(url, p)
self.assertVisibleBookmarks(["foo 1", "foo 2", "foo 3", "foo 4", "foo 5"])
self.locate_bookmark("foo 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["foo 1", "foo 3", "foo 4", "foo 5"])
def test_partial_update_respects_sort(self):
self.setup_numbered_bookmarks(5, prefix="foo")
with sync_playwright() as p:
url = reverse("bookmarks:index") + "?sort=title_asc"
page = self.open(url, p)
first_item = page.locator("li[ld-bookmark-item]").first
expect(first_item).to_contain_text("foo 1")
first_item.get_by_text("Archive").click()
first_item = page.locator("li[ld-bookmark-item]").first
expect(first_item).to_contain_text("foo 2")
def test_partial_update_respects_page(self):
# add a suffix, otherwise 'foo 1' also matches 'foo 10'
self.setup_numbered_bookmarks(50, prefix="foo", suffix="-")
with sync_playwright() as p:
url = reverse("bookmarks:index") + "?q=foo&page=2"
self.open(url, p)
# with descending sort, page two has 'foo 1' to 'foo 20'
expected_titles = [f"foo {i}-" for i in range(1, 21)]
self.assertVisibleBookmarks(expected_titles)
self.locate_bookmark("foo 20-").get_by_text("Archive").click()
expected_titles = [f"foo {i}-" for i in range(1, 20)]
self.assertVisibleBookmarks(expected_titles)
def test_multiple_partial_updates(self):
self.setup_numbered_bookmarks(5)
with sync_playwright() as p:
url = reverse("bookmarks:index")
self.open(url, p)
self.locate_bookmark("Bookmark 1").get_by_text("Archive").click()
self.assertVisibleBookmarks(
["Bookmark 2", "Bookmark 3", "Bookmark 4", "Bookmark 5"]
)
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 3", "Bookmark 4", "Bookmark 5"])
self.locate_bookmark("Bookmark 3").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 4", "Bookmark 5"])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Archive").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_mark_as_read(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
bookmark2.unread = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_unshare(self):
self.setup_fixture()
bookmark2 = self.get_numbered_bookmark("Bookmark 2")
bookmark2.shared = True
bookmark2.save()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_archive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]"
).click()
self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0)
def test_active_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]"
).click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Unarchive").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_unarchive(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]"
).click()
self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0)
def test_archived_bookmarks_partial_update_on_bulk_delete(self):
self.setup_fixture()
with sync_playwright() as p:
self.open(reverse("bookmarks:archived"), p)
self.locate_bulk_edit_toggle().click()
self.locate_bookmark("Archived Bookmark 2").locator(
"label[ld-bulk-edit-checkbox]"
).click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_unarchive(self):
self.setup_fixture()
self.setup_numbered_bookmarks(
3, shared=True, prefix="My Bookmark", with_tags=True
)
with sync_playwright() as p:
self.open(reverse("bookmarks:shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Archive").click()
# Shared bookmarks page also shows archived bookmarks, though it probably shouldn't
self.assertVisibleBookmarks(
[
"My Bookmark 1",
"My Bookmark 2",
"My Bookmark 3",
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
]
)
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 2", "Shared Tag 3"])
self.assertReloads(0)
def test_shared_bookmarks_partial_update_on_delete(self):
self.setup_fixture()
self.setup_numbered_bookmarks(
3, shared=True, prefix="My Bookmark", with_tags=True
)
with sync_playwright() as p:
self.open(reverse("bookmarks:shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
self.assertVisibleBookmarks(
[
"My Bookmark 1",
"My Bookmark 3",
"Joe's Bookmark 1",
"Joe's Bookmark 2",
"Joe's Bookmark 3",
]
)
self.assertVisibleTags(["Shared Tag 1", "Shared Tag 3"])
self.assertReloads(0)

View File

@@ -1,30 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase):
def test_focus_search(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:index"))
page.press("body", "s")
expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused()
browser.close()
def test_add_bookmark(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:index"))
page.press("body", "n")
expect(page).to_have_url(self.live_server_url + reverse("bookmarks:new"))
browser.close()

View File

@@ -1,88 +0,0 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
from bookmarks.models import UserProfile
class SettingsGeneralE2ETestCase(LinkdingE2ETestCase):
def test_should_only_enable_public_sharing_if_sharing_is_enabled(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
enable_sharing = page.get_by_label("Enable bookmark sharing")
enable_sharing_label = page.get_by_text("Enable bookmark sharing")
enable_public_sharing = page.get_by_label("Enable public bookmark sharing")
enable_public_sharing_label = page.get_by_text(
"Enable public bookmark sharing"
)
# Public sharing is disabled by default
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
# Enable sharing
enable_sharing_label.click()
expect(enable_sharing).to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_enabled()
# Enable public sharing
enable_public_sharing_label.click()
expect(enable_public_sharing).to_be_checked()
expect(enable_public_sharing).to_be_enabled()
# Disable sharing
enable_sharing_label.click()
expect(enable_sharing).not_to_be_checked()
expect(enable_public_sharing).not_to_be_checked()
expect(enable_public_sharing).to_be_disabled()
def test_should_not_show_bookmark_description_max_lines_when_display_inline(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
def test_should_show_bookmark_description_max_lines_when_display_separate(self):
profile = self.get_or_create_test_user().profile
profile.bookmark_description_display = (
UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_SEPARATE
)
profile.save()
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_visible()
def test_should_update_bookmark_description_max_lines_when_changing_display(self):
with sync_playwright() as p:
browser = self.setup_browser(p)
page = browser.new_page()
page.goto(self.live_server_url + reverse("bookmarks:settings.general"))
max_lines = page.get_by_label("Bookmark description max lines")
expect(max_lines).to_be_hidden()
display = page.get_by_label("Bookmark description", exact=True)
display.select_option("separate")
expect(max_lines).to_be_visible()
display.select_option("inline")
expect(max_lines).to_be_hidden()

View File

@@ -1,78 +0,0 @@
from django.contrib.staticfiles.testing import LiveServerTestCase
from playwright.sync_api import BrowserContext, Playwright, Page
from playwright.sync_api import expect
from bookmarks.tests.helpers import BookmarkFactoryMixin
class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.client.force_login(self.get_or_create_test_user())
self.cookie = self.client.cookies["sessionid"]
def setup_browser(self, playwright) -> BrowserContext:
browser = playwright.chromium.launch(headless=True)
context = browser.new_context()
context.add_cookies(
[
{
"name": "sessionid",
"value": self.cookie.value,
"domain": self.live_server_url.replace("http:", ""),
"path": "/",
}
]
)
return context
def open(self, url: str, playwright: Playwright) -> Page:
browser = self.setup_browser(playwright)
self.page = browser.new_page()
self.page.goto(self.live_server_url + url)
self.page.on("load", self.on_load)
self.num_loads = 0
return self.page
def on_load(self):
self.num_loads += 1
def assertReloads(self, count: int):
self.assertEqual(self.num_loads, count)
def locate_bookmark_list(self):
return self.page.locator("ul[ld-bookmark-list]")
def locate_bookmark(self, title: str):
bookmark_tags = self.page.locator("li[ld-bookmark-item]")
return bookmark_tags.filter(has_text=title)
def locate_details_modal(self):
return self.page.locator(".modal.bookmark-details")
def open_details_modal(self, bookmark):
details_button = self.locate_bookmark(bookmark.title).get_by_text("View")
details_button.click()
details_modal = self.locate_details_modal()
expect(details_modal).to_be_visible()
return details_modal
def locate_bulk_edit_bar(self):
return self.page.locator(".bulk-edit-bar")
def locate_bulk_edit_select_all(self):
return self.locate_bulk_edit_bar().locator("label[ld-bulk-edit-checkbox][all]")
def locate_bulk_edit_select_across(self):
return self.locate_bulk_edit_bar().locator("label.select-across")
def locate_bulk_edit_toggle(self):
return self.page.get_by_title("Bulk edit")
def select_bulk_action(self, value: str):
return (
self.locate_bulk_edit_bar()
.locator('select[name="bulk_action"]')
.select_option(value)
)

View File

@@ -1,105 +0,0 @@
import unicodedata
from dataclasses import dataclass
from django.contrib.syndication.views import Feed
from django.db.models import QuerySet
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
def sanitize(text: str):
if not text:
return ""
# remove control characters
valid_chars = ["\n", "\r", "\t"]
return "".join(
ch for ch in text if ch in valid_chars or unicodedata.category(ch)[0] != "C"
)
class BaseBookmarksFeed(Feed):
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_bookmarks(
feed_token.user, feed_token.user.profile, search
)
return FeedContext(feed_token, query_set)
def item_title(self, item: Bookmark):
return sanitize(item.resolved_title)
def item_description(self, item: Bookmark):
return sanitize(item.resolved_description)
def item_link(self, item: Bookmark):
return item.url
def item_pubdate(self, item: Bookmark):
return item.date_added
class AllBookmarksFeed(BaseBookmarksFeed):
title = "All bookmarks"
description = "All bookmarks"
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class UnreadBookmarksFeed(BaseBookmarksFeed):
title = "Unread bookmarks"
description = "All unread bookmarks"
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -1,29 +0,0 @@
export class ApiClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
listBookmarks(search, options = { limit: 100, offset: 0, path: "" }) {
const query = [`limit=${options.limit}`, `offset=${options.offset}`];
Object.keys(search).forEach((key) => {
const value = search[key];
if (value) {
query.push(`${key}=${encodeURIComponent(value)}`);
}
});
const queryString = query.join("&");
const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
getTags(options = { limit: 100, offset: 0 }) {
const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`;
return fetch(url)
.then((response) => response.json())
.then((data) => data.results);
}
}

View File

@@ -1,87 +0,0 @@
import { registerBehavior, swapContent } from "./index";
class BookmarkPage {
constructor(element) {
this.element = element;
this.form = element.querySelector("form.bookmark-actions");
this.form.addEventListener("submit", this.onFormSubmit.bind(this));
this.bookmarkList = element.querySelector(".bookmark-list-container");
this.tagCloud = element.querySelector(".tag-cloud-container");
document.addEventListener("bookmark-page-refresh", () => {
this.refresh();
});
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.form.action;
const formData = new FormData(this.form);
formData.append(event.submitter.name, event.submitter.value);
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
await this.refresh();
}
async refresh() {
const query = window.location.search;
const bookmarksUrl = this.element.getAttribute("bookmarks-url");
const tagsUrl = this.element.getAttribute("tags-url");
Promise.all([
fetch(`${bookmarksUrl}${query}`).then((response) => response.text()),
fetch(`${tagsUrl}${query}`).then((response) => response.text()),
]).then(([bookmarkListHtml, tagCloudHtml]) => {
swapContent(this.bookmarkList, bookmarkListHtml);
swapContent(this.tagCloud, tagCloudHtml);
// Dispatch list updated event
const listElement = this.bookmarkList.querySelector(
"ul[data-bookmarks-total]",
);
const bookmarksTotal =
(listElement && listElement.dataset.bookmarksTotal) || 0;
this.bookmarkList.dispatchEvent(
new CustomEvent("bookmark-list-updated", {
bubbles: true,
detail: { bookmarksTotal },
}),
);
});
}
}
registerBehavior("ld-bookmark-page", BookmarkPage);
class BookmarkItem {
constructor(element) {
this.element = element;
// Toggle notes
const notesToggle = element.querySelector(".toggle-notes");
if (notesToggle) {
notesToggle.addEventListener("click", this.onToggleNotes.bind(this));
}
// Add tooltip to title if it is truncated
const titleAnchor = element.querySelector(".title > a");
const titleSpan = titleAnchor.querySelector("span");
if (titleSpan.offsetWidth > titleAnchor.offsetWidth) {
titleAnchor.dataset.tooltip = titleSpan.textContent;
}
}
onToggleNotes(event) {
event.preventDefault();
event.stopPropagation();
this.element.classList.toggle("show-notes");
}
}
registerBehavior("ld-bookmark-item", BookmarkItem);

View File

@@ -1,141 +0,0 @@
import { registerBehavior } from "./index";
class BulkEdit {
constructor(element) {
this.element = element;
this.active = false;
this.actionSelect = element.querySelector("select[name='bulk_action']");
this.tagAutoComplete = element.querySelector(".tag-autocomplete");
this.selectAcross = element.querySelector("label.select-across");
element.addEventListener(
"bulk-edit-toggle-active",
this.onToggleActive.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-all",
this.onToggleAll.bind(this),
);
element.addEventListener(
"bulk-edit-toggle-bookmark",
this.onToggleBookmark.bind(this),
);
element.addEventListener(
"bookmark-list-updated",
this.onListUpdated.bind(this),
);
this.actionSelect.addEventListener(
"change",
this.onActionSelected.bind(this),
);
}
get allCheckbox() {
return this.element.querySelector("[ld-bulk-edit-checkbox][all] input");
}
get bookmarkCheckboxes() {
return [
...this.element.querySelectorAll(
"[ld-bulk-edit-checkbox]:not([all]) input",
),
];
}
onToggleActive() {
this.active = !this.active;
if (this.active) {
this.element.classList.add("active", "activating");
setTimeout(() => {
this.element.classList.remove("activating");
}, 500);
} else {
this.element.classList.remove("active");
}
}
onToggleBookmark() {
const allChecked = this.bookmarkCheckboxes.every((checkbox) => {
return checkbox.checked;
});
this.allCheckbox.checked = allChecked;
this.updateSelectAcross(allChecked);
}
onToggleAll() {
const allChecked = this.allCheckbox.checked;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = allChecked;
});
this.updateSelectAcross(allChecked);
}
onActionSelected() {
const action = this.actionSelect.value;
if (action === "bulk_tag" || action === "bulk_untag") {
this.tagAutoComplete.classList.remove("d-none");
} else {
this.tagAutoComplete.classList.add("d-none");
}
}
onListUpdated(event) {
// Reset checkbox states
this.reset();
// Update total number of bookmarks
const total = event.detail.bookmarksTotal;
const totalSpan = this.selectAcross.querySelector("span.total");
totalSpan.textContent = total;
}
updateSelectAcross(allChecked) {
if (allChecked) {
this.selectAcross.classList.remove("d-none");
} else {
this.selectAcross.classList.add("d-none");
this.selectAcross.querySelector("input").checked = false;
}
}
reset() {
this.allCheckbox.checked = false;
this.bookmarkCheckboxes.forEach((checkbox) => {
checkbox.checked = false;
});
this.updateSelectAcross(false);
}
}
class BulkEditActiveToggle {
constructor(element) {
this.element = element;
element.addEventListener("click", this.onClick.bind(this));
}
onClick() {
this.element.dispatchEvent(
new CustomEvent("bulk-edit-toggle-active", { bubbles: true }),
);
}
}
class BulkEditCheckbox {
constructor(element) {
this.element = element;
element.addEventListener("change", this.onChange.bind(this));
}
onChange() {
const type = this.element.hasAttribute("all") ? "all" : "bookmark";
this.element.dispatchEvent(
new CustomEvent(`bulk-edit-toggle-${type}`, { bubbles: true }),
);
}
}
registerBehavior("ld-bulk-edit", BulkEdit);
registerBehavior("ld-bulk-edit-active-toggle", BulkEditActiveToggle);
registerBehavior("ld-bulk-edit-checkbox", BulkEditCheckbox);

View File

@@ -1,74 +0,0 @@
import { registerBehavior } from "./index";
class ConfirmButtonBehavior {
constructor(element) {
const button = element;
button.dataset.type = button.type;
button.dataset.name = button.name;
button.dataset.value = button.value;
button.removeAttribute("type");
button.removeAttribute("name");
button.removeAttribute("value");
button.addEventListener("click", this.onClick.bind(this));
this.button = button;
}
onClick(event) {
event.preventDefault();
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.button.getAttribute("confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
}
const question = this.button.getAttribute("confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
const buttonClasses = Array.from(this.button.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const cancelButton = document.createElement(this.button.nodeName);
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
const confirmButton = document.createElement(this.button.nodeName);
confirmButton.type = this.button.dataset.type;
confirmButton.name = this.button.dataset.name;
confirmButton.value = this.button.dataset.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
container.append(cancelButton, confirmButton);
this.container = container;
this.button.before(container);
this.button.classList.add("d-none");
}
reset() {
setTimeout(() => {
this.container.remove();
this.button.classList.remove("d-none");
});
}
}
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -1,36 +0,0 @@
import { registerBehavior } from "./index";
class DropdownBehavior {
constructor(element) {
this.element = element;
this.opened = false;
this.onOutsideClick = this.onOutsideClick.bind(this);
const toggle = element.querySelector(".dropdown-toggle");
toggle.addEventListener("click", () => {
if (this.opened) {
this.close();
} else {
this.open();
}
});
}
open() {
this.element.classList.add("active");
document.addEventListener("click", this.onOutsideClick);
}
close() {
this.element.classList.remove("active");
document.removeEventListener("click", this.onOutsideClick);
}
onOutsideClick(event) {
if (!this.element.contains(event.target)) {
this.close();
}
}
}
registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -1,54 +0,0 @@
import { registerBehavior, swap } from "./index";
class FormBehavior {
constructor(element) {
this.element = element;
element.addEventListener("submit", this.onFormSubmit.bind(this));
}
async onFormSubmit(event) {
event.preventDefault();
const url = this.element.action;
const formData = new FormData(this.element);
if (event.submitter) {
formData.append(event.submitter.name, event.submitter.value);
}
await fetch(url, {
method: "POST",
body: formData,
redirect: "manual", // ignore redirect
});
// Dispatch refresh events
const refreshEvents = this.element.getAttribute("refresh-events");
if (refreshEvents) {
refreshEvents.split(",").forEach((eventName) => {
document.dispatchEvent(new CustomEvent(eventName));
});
}
// Refresh form
await this.refresh();
}
async refresh() {
const refreshUrl = this.element.getAttribute("refresh-url");
const html = await fetch(refreshUrl).then((response) => response.text());
swap(this.element, html);
}
}
class FormAutoSubmitBehavior {
constructor(element) {
this.element = element;
this.element.addEventListener("change", () => {
const form = this.element.closest("form");
form.dispatchEvent(new Event("submit", { cancelable: true }));
});
}
}
registerBehavior("ld-form", FormBehavior);
registerBehavior("ld-form-auto-submit", FormAutoSubmitBehavior);

View File

@@ -1,73 +0,0 @@
import { registerBehavior } from "./index";
class GlobalShortcuts {
constructor() {
document.addEventListener("keydown", this.onKeyDown.bind(this));
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
// Handle shortcuts for navigating bookmarks with arrow keys
const isArrowUp = event.key === "ArrowUp";
const isArrowDown = event.key === "ArrowDown";
if (isArrowUp || isArrowDown) {
event.preventDefault();
// Detect current bookmark list item
const path = event.composedPath();
const currentItem = path.find(
(item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"),
);
// Find next item
let nextItem;
if (currentItem) {
nextItem = isArrowUp
? currentItem.previousElementSibling
: currentItem.nextElementSibling;
} else {
// Select first item
nextItem = document.querySelector("[ld-bookmark-item]");
}
// Focus first link
if (nextItem) {
nextItem.querySelector("a").focus();
}
}
// Handle shortcut for toggling all notes
if (event.key === "e") {
const list = document.querySelector(".bookmark-list");
if (list) {
list.classList.toggle("show-notes");
}
}
// Handle shortcut for focusing search input
if (event.key === "s") {
const searchInput = document.querySelector('input[type="search"]');
if (searchInput) {
searchInput.focus();
event.preventDefault();
}
}
// Handle shortcut for adding new bookmark
if (event.key === "n") {
window.location.assign("/bookmarks/new");
}
}
}
registerBehavior("ld-global-shortcuts", GlobalShortcuts);

View File

@@ -1,50 +0,0 @@
const behaviorRegistry = {};
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
applyBehaviors(document, [name]);
}
export function applyBehaviors(container, behaviorNames = null) {
if (!behaviorNames) {
behaviorNames = Object.keys(behaviorRegistry);
}
behaviorNames.forEach((behaviorName) => {
const behavior = behaviorRegistry[behaviorName];
const elements = Array.from(
container.querySelectorAll(`[${behaviorName}]`),
);
// Include the container element if it has the behavior
if (container.hasAttribute && container.hasAttribute(behaviorName)) {
elements.push(container);
}
elements.forEach((element) => {
element.__behaviors = element.__behaviors || [];
const hasBehavior = element.__behaviors.some(
(b) => b instanceof behavior,
);
if (hasBehavior) {
return;
}
const behaviorInstance = new behavior(element);
element.__behaviors.push(behaviorInstance);
});
});
}
export function swap(element, html) {
const dom = new DOMParser().parseFromString(html, "text/html");
const newElement = dom.body.firstChild;
element.replaceWith(newElement);
applyBehaviors(newElement);
}
export function swapContent(element, html) {
element.innerHTML = html;
applyBehaviors(element);
}

View File

@@ -1,100 +0,0 @@
import { applyBehaviors, registerBehavior } from "./index";
class ModalBehavior {
constructor(element) {
const toggle = element;
toggle.addEventListener("click", this.onToggleClick.bind(this));
this.toggle = toggle;
}
async onToggleClick(event) {
// Ignore Ctrl + click
if (event.ctrlKey || event.metaKey) {
return;
}
event.preventDefault();
event.stopPropagation();
// Create modal either by teleporting existing content or fetching from URL
const modal = this.toggle.hasAttribute("modal-content")
? this.createFromContent()
: await this.createFromUrl();
if (!modal) {
return;
}
// Register close handlers
const modalOverlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector("button.close");
modalOverlay.addEventListener("click", this.onClose.bind(this));
closeButton.addEventListener("click", this.onClose.bind(this));
document.body.append(modal);
applyBehaviors(document.body);
this.modal = modal;
}
async createFromUrl() {
const url = this.toggle.getAttribute("modal-url");
const modalHtml = await fetch(url).then((response) => response.text());
const parser = new DOMParser();
const doc = parser.parseFromString(modalHtml, "text/html");
return doc.querySelector(".modal");
}
createFromContent() {
const contentSelector = this.toggle.getAttribute("modal-content");
const content = document.querySelector(contentSelector);
if (!content) {
return;
}
// Todo: make title configurable, only used for tag cloud for now
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header d-flex justify-between align-center">
<div class="modal-title h5">Tags</div>
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" 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="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const contentOwner = content.parentElement;
const contentContainer = modal.querySelector(".content");
contentContainer.append(content);
this.content = content;
this.contentOwner = contentOwner;
return modal;
}
onClose() {
// Teleport content back
if (this.content && this.contentOwner) {
this.contentOwner.append(this.content);
}
// Remove modal
this.modal.classList.add("closing");
this.modal.addEventListener("animationend", (event) => {
if (event.animationName === "fade-out") {
this.modal.remove();
}
});
}
}
registerBehavior("ld-modal", ModalBehavior);

View File

@@ -1,27 +0,0 @@
import { registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte";
import { ApiClient } from "../api";
class TagAutocomplete {
constructor(element) {
const wrapper = document.createElement("div");
const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || "";
const apiClient = new ApiClient(apiBaseUrl);
new TagAutoCompleteComponent({
target: wrapper,
props: {
id: element.id,
name: element.name,
value: element.value,
placeholder: element.getAttribute("placeholder") || "",
apiClient: apiClient,
variant: element.getAttribute("variant"),
},
});
element.replaceWith(wrapper.firstElementChild);
}
}
registerBehavior("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -1,261 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds} from "../util";
const searchHistory = new SearchHistory()
export let name;
export let placeholder;
export let value;
export let tags;
export let mode = '';
export let apiClient;
export let search;
export let linkTarget = '_blank';
let isFocus = false;
let isOpen = false;
let suggestions = []
let selectedIndex = undefined;
let input = null;
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tagName => tagName.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tagName => ({
type: 'tag',
index: nextIndex(),
label: `#${tagName}`,
tagName: tagName
}))
}
// Recent search suggestions
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionSearch = {
...search,
q: value
}
const fetchedBookmarks = await apiClient.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.website_title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...recentSearches,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, linkTarget)
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
on:input={handleInput} on:keydown={handleKeyDown} on:focus={handleFocus} on:blur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.recentSearches.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.recentSearches as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" on:mousedown|preventDefault={() => completeSuggestion(suggestion)}>
{suggestion.label}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -1,52 +0,0 @@
const SEARCH_HISTORY_KEY = "searchHistory";
const MAX_ENTRIES = 30;
export class SearchHistory {
getHistory() {
const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY);
return historyJson
? JSON.parse(historyJson)
: {
recent: [],
};
}
pushCurrent() {
// Skip if browser is not compatible
if (!window.URLSearchParams) return;
const urlParams = new URLSearchParams(window.location.search);
const searchParam = urlParams.get("q");
if (!searchParam) return;
this.push(searchParam);
}
push(search) {
const history = this.getHistory();
history.recent.unshift(search);
// Remove duplicates and clamp to max entries
history.recent = history.recent.reduce((acc, cur) => {
if (acc.length >= MAX_ENTRIES) return acc;
if (acc.indexOf(cur) >= 0) return acc;
acc.push(cur);
return acc;
}, []);
const newHistoryJson = JSON.stringify(history);
localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson);
}
getRecentSearches(query, max) {
const history = this.getHistory();
return history.recent
.filter(
(search) =>
!query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0,
)
.slice(0, max);
}
}

View File

@@ -1,180 +0,0 @@
<script>
import {getCurrentWord, getCurrentWordBounds} from "../util";
export let id;
export let name;
export let value;
export let placeholder;
export let apiClient;
export let variant = 'default';
let tags = [];
let isFocus = false;
let isOpen = false;
let input = null;
let suggestionList = null;
let suggestions = [];
let selectedIndex = 0;
init();
async function init() {
// For now we cache all tags on load as the template did before
try {
tags = await apiClient.getTags({limit: 5000, offset: 0});
tags.sort((left, right) => left.name.toLowerCase().localeCompare(right.name.toLowerCase()))
} catch (e) {
console.warn('TagAutocomplete: Error loading tag list');
}
}
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
input = e.target;
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
on:input={handleInput} on:keydown={handleKeyDown}
on:focus={handleFocus} on:blur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" on:mousedown|preventDefault={() => complete(tag)}>
{tag.name}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
box-sizing: border-box;
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
}
.form-autocomplete-input input {
width: 100%;
height: 100%;
border: none;
margin: 0;
}
.form-autocomplete.small .form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete.small .form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm);
}
</style>

View File

@@ -1,11 +0,0 @@
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/modal";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { ApiClient } from "./api";

View File

@@ -1,37 +0,0 @@
export function debounce(callback, delay = 250) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
timeoutId = null;
callback(...args);
}, delay);
};
}
export function clampText(text, maxChars = 30) {
if (!text || text.length <= 30) return text;
return text.substr(0, maxChars) + "...";
}
export function getCurrentWordBounds(input) {
const text = input.value;
const end = input.selectionStart;
let start = end;
let currentChar = text.charAt(start - 1);
while (currentChar && currentChar !== " " && start > 0) {
start--;
currentChar = text.charAt(start - 1);
}
return { start, end };
}
export function getCurrentWord(input) {
const bounds = getCurrentWordBounds(input);
return input.value.substring(bounds.start, bounds.end);
}

View File

@@ -1,31 +0,0 @@
import sqlite3
import os
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Creates a backup of the linkding database"
def add_arguments(self, parser):
parser.add_argument("destination", type=str, help="Backup file destination")
def handle(self, *args, **options):
destination = options["destination"]
def progress(status, remaining, total):
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
backup_db = sqlite3.connect(destination)
with backup_db:
source_db.backup(backup_db, pages=50, progress=progress)
backup_db.close()
source_db.close()
self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}"))
self.stdout.write(
self.style.WARNING(
"This backup method is deprecated and may be removed in the future. Please use the full_backup command instead, which creates backup zip file with all contents of the data folder."
)
)

View File

@@ -1,39 +0,0 @@
import os
import logging
from django.core.management.base import BaseCommand
from django.contrib.auth import get_user_model
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Creates an initial superuser for a deployment using env variables"
def handle(self, *args, **options):
User = get_user_model()
superuser_name = os.getenv("LD_SUPERUSER_NAME", None)
superuser_password = os.getenv("LD_SUPERUSER_PASSWORD", None)
# Skip if option is undefined
if not superuser_name:
logger.info(
"Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined"
)
return
# Skip if user already exists
user_exists = User.objects.filter(username=superuser_name).exists()
if user_exists:
logger.info("Skip creating initial superuser, user already exists")
return
user = User(username=superuser_name, is_superuser=True, is_staff=True)
if superuser_password:
user.set_password(superuser_password)
else:
user.set_unusable_password()
user.save()
logger.info("Created initial superuser")

View File

@@ -1,24 +0,0 @@
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 not settings.USE_SQLITE:
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")

View File

@@ -6,15 +6,13 @@ class Command(BaseCommand):
help = "Creates an admin user non-interactively if it doesn't exist"
def add_arguments(self, parser):
parser.add_argument("--username", help="Admin's username")
parser.add_argument("--email", help="Admin's email")
parser.add_argument("--password", help="Admin's password")
parser.add_argument('--username', help="Admin's username")
parser.add_argument('--email', help="Admin's email")
parser.add_argument('--password', help="Admin's password")
def handle(self, *args, **options):
User = get_user_model()
if not User.objects.filter(username=options["username"]).exists():
User.objects.create_superuser(
username=options["username"],
email=options["email"],
password=options["password"],
)
if not User.objects.filter(username=options['username']).exists():
User.objects.create_superuser(username=options['username'],
email=options['email'],
password=options['password'])

View File

@@ -1,62 +0,0 @@
import sqlite3
import os
import tempfile
import zipfile
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Creates a backup of the linkding data folder"
def add_arguments(self, parser):
parser.add_argument("backup_file", type=str, help="Backup zip file destination")
def handle(self, *args, **options):
backup_file = options["backup_file"]
with zipfile.ZipFile(backup_file, "w", zipfile.ZIP_DEFLATED) as zip_file:
# Backup the database
self.stdout.write("Create database backup...")
with tempfile.TemporaryDirectory() as temp_dir:
backup_db_file = os.path.join(temp_dir, "db.sqlite3")
self.backup_database(backup_db_file)
zip_file.write(backup_db_file, "db.sqlite3")
# Backup the assets folder
if not os.path.exists(os.path.join("data", "assets")):
self.stdout.write(
self.style.WARNING("No assets folder found. Skipping...")
)
else:
self.stdout.write("Backup bookmark assets...")
assets_folder = os.path.join("data", "assets")
for root, _, files in os.walk(assets_folder):
for file in files:
file_path = os.path.join(root, file)
zip_file.write(file_path, os.path.join("assets", file))
# Backup the favicons folder
if not os.path.exists(os.path.join("data", "favicons")):
self.stdout.write(
self.style.WARNING("No favicons folder found. Skipping...")
)
else:
self.stdout.write("Backup bookmark favicons...")
favicons_folder = os.path.join("data", "favicons")
for root, _, files in os.walk(favicons_folder):
for file in files:
file_path = os.path.join(root, file)
zip_file.write(file_path, os.path.join("favicons", file))
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
def backup_database(self, backup_db_file):
def progress(status, remaining, total):
self.stdout.write(f"Copied {total-remaining} of {total} pages...")
source_db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
backup_db = sqlite3.connect(backup_db_file)
with backup_db:
source_db.backup(backup_db, pages=50, progress=progress)
backup_db.close()
source_db.close()

View File

@@ -1,24 +0,0 @@
import logging
import os
from django.core.management.base import BaseCommand
from django.core.management.utils import get_random_secret_key
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Generate secret key file if it does not exist"
def handle(self, *args, **options):
secret_key_file = os.path.join("data", "secretkey.txt")
if os.path.exists(secret_key_file):
logger.info(f"Secret key file already exists")
return
secret_key = get_random_secret_key()
with open(secret_key_file, "w") as f:
f.write(secret_key)
logger.info(f"Generated secret key file")

View File

@@ -5,17 +5,15 @@ from bookmarks.services.importer import import_netscape_html
class Command(BaseCommand):
help = "Import Netscape HTML bookmark file"
help = 'Import Netscape HTML bookmark file'
def add_arguments(self, parser):
parser.add_argument("file", type=str, help="Path to file")
parser.add_argument(
"user", type=str, help="Name of the user for which to import"
)
parser.add_argument('file', type=str, help='Path to file')
parser.add_argument('user', type=str, help='Name of the user for which to import')
def handle(self, *args, **kwargs):
filepath = kwargs["file"]
username = kwargs["user"]
filepath = kwargs['file']
username = kwargs['user']
with open(filepath) as html_file:
html = html_file.read()
user = User.objects.get(username=username)

View File

@@ -1,75 +0,0 @@
import json
import os
import sqlite3
import importlib
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = "Migrate tasks from django-background-tasks to Huey"
def handle(self, *args, **options):
db = sqlite3.connect(os.path.join("data", "db.sqlite3"))
# Check if background_task table exists
cursor = db.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='background_task'"
)
row = cursor.fetchone()
if not row:
self.stdout.write(
"Legacy task table does not exist. Skipping task migration"
)
return
# Load legacy tasks
cursor.execute("SELECT id, task_name, task_params FROM background_task")
legacy_tasks = cursor.fetchall()
if len(legacy_tasks) == 0:
self.stdout.write("No legacy tasks found. Skipping task migration")
return
self.stdout.write(
f"Found {len(legacy_tasks)} legacy tasks. Migrating to Huey..."
)
# Migrate tasks to Huey
succeeded_tasks = []
for task in legacy_tasks:
task_id = task[0]
task_name = task[1]
task_params_json = task[2]
try:
task_params = json.loads(task_params_json)
function_params = task_params[0]
# Resolve task function
module_name, func_name = task_name.rsplit(".", 1)
module = importlib.import_module(module_name)
func = getattr(module, func_name)
# Call task function
func(*function_params)
succeeded_tasks.append(task_id)
except Exception:
self.stderr.write(f"Error migrating task [{task_id}] {task_name}")
self.stdout.write(f"Migrated {len(succeeded_tasks)} tasks successfully")
# Clean up
try:
placeholders = ", ".join("?" for _ in succeeded_tasks)
sql = f"DELETE FROM background_task WHERE id IN ({placeholders})"
cursor.execute(sql, succeeded_tasks)
db.commit()
self.stdout.write(
f"Deleted {len(succeeded_tasks)} migrated tasks from legacy table"
)
except Exception:
self.stderr.write("Error cleaning up legacy tasks")
cursor.close()
db.close()

View File

@@ -1,24 +0,0 @@
from django.conf import settings
from django.contrib.auth.middleware import RemoteUserMiddleware
from bookmarks.models import UserProfile
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
class UserProfileMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.user.is_authenticated:
request.user_profile = request.user.profile
else:
request.user_profile = UserProfile()
request.user_profile.enable_favicons = True
response = self.get_response(request)
return response

View File

@@ -15,36 +15,19 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name="Bookmark",
name='Bookmark',
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("url", models.URLField()),
("title", models.CharField(max_length=512)),
("description", models.TextField()),
(
"website_title",
models.CharField(blank=True, max_length=512, null=True),
),
("website_description", models.TextField(blank=True, null=True)),
("unread", models.BooleanField(default=True)),
("date_added", models.DateTimeField()),
("date_modified", models.DateTimeField()),
("date_accessed", models.DateTimeField(blank=True, null=True)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('url', models.URLField()),
('title', models.CharField(max_length=512)),
('description', models.TextField()),
('website_title', models.CharField(blank=True, max_length=512, null=True)),
('website_description', models.TextField(blank=True, null=True)),
('unread', models.BooleanField(default=True)),
('date_added', models.DateTimeField()),
('date_modified', models.DateTimeField()),
('date_accessed', models.DateTimeField(blank=True, null=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -9,36 +9,22 @@ class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0001_initial"),
('bookmarks', '0001_initial'),
]
operations = [
migrations.CreateModel(
name="Tag",
name='Tag',
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=64)),
("date_added", models.DateTimeField()),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64)),
('date_added', models.DateTimeField()),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name="bookmark",
name="tags",
field=models.ManyToManyField(to="bookmarks.Tag"),
model_name='bookmark',
name='tags',
field=models.ManyToManyField(to='bookmarks.Tag'),
),
]

View File

@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0002_auto_20190629_2303"),
('bookmarks', '0002_auto_20190629_2303'),
]
operations = [
migrations.AlterField(
model_name="bookmark",
name="url",
model_name='bookmark',
name='url',
field=models.URLField(max_length=2048),
),
]

View File

@@ -6,18 +6,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0003_auto_20200913_0656"),
('bookmarks', '0003_auto_20200913_0656'),
]
operations = [
migrations.AlterField(
model_name="bookmark",
name="description",
model_name='bookmark',
name='description',
field=models.TextField(blank=True),
),
migrations.AlterField(
model_name="bookmark",
name="title",
model_name='bookmark',
name='title',
field=models.CharField(blank=True, max_length=512),
),
]

View File

@@ -7,16 +7,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0004_auto_20200926_1028"),
('bookmarks', '0004_auto_20200926_1028'),
]
operations = [
migrations.AlterField(
model_name="bookmark",
name="url",
field=models.CharField(
max_length=2048,
validators=[bookmarks.validators.BookmarkURLValidator()],
),
model_name='bookmark',
name='url',
field=models.CharField(max_length=2048, validators=[bookmarks.validators.BookmarkURLValidator()]),
),
]

View File

@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0005_auto_20210103_1212"),
('bookmarks', '0005_auto_20210103_1212'),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="is_archived",
model_name='bookmark',
name='is_archived',
field=models.BooleanField(default=False),
),
]

View File

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

View File

@@ -1,26 +0,0 @@
# Generated by Django 2.2.18 on 2021-03-30 10:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0007_userprofile"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bookmark_date_display",
field=models.CharField(
choices=[
("relative", "Relative"),
("absolute", "Absolute"),
("hidden", "Hidden"),
],
default="relative",
max_length=10,
),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 2.2.20 on 2021-05-16 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0008_userprofile_bookmark_date_display"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="web_archive_snapshot_url",
field=models.CharField(blank=True, max_length=2048),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 3.2.6 on 2021-10-03 06:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0009_bookmark_web_archive_snapshot_url"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bookmark_link_target",
field=models.CharField(
choices=[("_blank", "New page"), ("_self", "Same page")],
default="_blank",
max_length=10,
),
),
]

View File

@@ -1,22 +0,0 @@
# Generated by Django 3.2.6 on 2022-01-08 12:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0010_userprofile_bookmark_link_target"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="web_archive_integration",
field=models.CharField(
choices=[("disabled", "Disabled"), ("enabled", "Enabled")],
default="disabled",
max_length=10,
),
),
]

View File

@@ -1,40 +0,0 @@
# Generated by Django 3.2.6 on 2022-01-08 19:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0011_userprofile_web_archive_integration"),
]
operations = [
migrations.CreateModel(
name="Toast",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=50)),
("message", models.TextField()),
("acknowledged", models.BooleanField(default=False)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -1,32 +0,0 @@
# Generated by Django 3.2.6 on 2022-01-08 19:27
from django.db import migrations
from django.contrib.auth import get_user_model
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="web_archive_opt_in_hint",
message="The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="web_archive_opt_in_hint").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0012_toast"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 3.2.13 on 2022-07-23 12:30
from django.db import migrations, models
def forwards(apps, schema_editor):
Bookmark = apps.get_model("bookmarks", "Bookmark")
Bookmark.objects.update(unread=False)
def reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0013_web_archive_optin_toast"),
]
operations = [
migrations.AlterField(
model_name="bookmark",
name="unread",
field=models.BooleanField(default=False),
),
migrations.RunPython(forwards, reverse),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 3.2.13 on 2022-07-23 20:35
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("bookmarks", "0014_alter_bookmark_unread"),
]
operations = [
migrations.CreateModel(
name="FeedToken",
fields=[
(
"key",
models.CharField(max_length=40, primary_key=True, serialize=False),
),
("created", models.DateTimeField(auto_now_add=True)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="feed_token",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.14 on 2022-08-02 18:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0015_feedtoken"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="shared",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 3.2.14 on 2022-08-04 09:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0016_bookmark_shared"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_sharing",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.1 on 2023-01-07 23:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0017_userprofile_enable_sharing"),
]
operations = [
migrations.AddField(
model_name="bookmark",
name="favicon_file",
field=models.CharField(blank=True, max_length=512),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.1 on 2023-01-09 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0018_bookmark_favicon_file"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_favicons",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,22 +0,0 @@
# 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,
),
),
]

View File

@@ -1,18 +0,0 @@
# 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),
),
]

View File

@@ -1,18 +0,0 @@
# 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),
),
]

View File

@@ -1,18 +0,0 @@
# 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),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.1.9 on 2023-08-14 07:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0023_userprofile_permanent_notes"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_public_sharing",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 4.1.9 on 2023-09-30 10:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0024_userprofile_enable_public_sharing"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="search_preferences",
field=models.JSONField(default=dict),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-16 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0025_userprofile_search_preferences"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="custom_css",
field=models.TextField(blank=True),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-23 21:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0026_userprofile_custom_css"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bookmark_description_display",
field=models.CharField(
choices=[("inline", "Inline"), ("separate", "Separate")],
default="inline",
max_length=10,
),
),
migrations.AddField(
model_name="userprofile",
name="bookmark_description_max_lines",
field=models.IntegerField(default=1),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-29 20:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0027_userprofile_bookmark_description_display_and_more"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="display_archive_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_edit_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_remove_bookmark_action",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="userprofile",
name="display_view_bookmark_action",
field=models.BooleanField(default=True),
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-29 21:25
from django.db import migrations
from django.contrib.auth import get_user_model
from bookmarks.models import Toast
User = get_user_model()
def forwards(apps, schema_editor):
for user in User.objects.all():
toast = Toast(
key="bookmark_list_actions_hint",
message="This version adds a new link to each bookmark to view details in a dialog. If you feel there is too much clutter you can now hide individual links in the settings.",
owner=user,
)
toast.save()
def reverse(apps, schema_editor):
Toast.objects.filter(key="bookmark_list_actions_hint").delete()
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"),
]
operations = [
migrations.RunPython(forwards, reverse),
]

View File

@@ -1,43 +0,0 @@
# Generated by Django 5.0.2 on 2024-03-31 08:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0029_bookmark_list_actions_toast"),
]
operations = [
migrations.CreateModel(
name="BookmarkAsset",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("date_created", models.DateTimeField(auto_now_add=True)),
("file", models.CharField(blank=True, max_length=2048)),
("file_size", models.IntegerField(null=True)),
("asset_type", models.CharField(max_length=64)),
("content_type", models.CharField(max_length=128)),
("display_name", models.CharField(blank=True, max_length=2048)),
("status", models.CharField(max_length=64)),
("gzip", models.BooleanField(default=False)),
(
"bookmark",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="bookmarks.bookmark",
),
),
],
),
]

Some files were not shown because too many files have changed in this diff Show More