├── .coveragerc ├── .devcontainer └── devcontainer.json ├── .dockerignore ├── .env.sample ├── .gitattributes ├── .github └── workflows │ ├── build.yaml │ └── main.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── Makefile ├── README.md ├── SECURITY.md ├── assets ├── header.afdesign ├── header.png ├── header.svg ├── logo-inset.afdesign ├── logo.afdesign ├── logo.png ├── logo.svg ├── social-preview.afdesign └── social-preview.png ├── bookmarks ├── __init__.py ├── admin.py ├── api │ ├── __init__.py │ ├── auth.py │ ├── routes.py │ └── serializers.py ├── apps.py ├── context_processors.py ├── feeds.py ├── forms.py ├── frontend │ ├── api.js │ ├── behaviors │ │ ├── bookmark-page.js │ │ ├── bulk-edit.js │ │ ├── clear-button.js │ │ ├── confirm-button.js │ │ ├── details-modal.js │ │ ├── dropdown.js │ │ ├── filter-drawer.js │ │ ├── focus-utils.js │ │ ├── form.js │ │ ├── global-shortcuts.js │ │ ├── index.js │ │ ├── modal.js │ │ ├── search-autocomplete.js │ │ └── tag-autocomplete.js │ ├── cache.js │ ├── components │ │ ├── SearchAutoComplete.svelte │ │ ├── SearchHistory.js │ │ └── TagAutocomplete.svelte │ ├── index.js │ └── util.js ├── management │ └── commands │ │ ├── backup.py │ │ ├── create_initial_superuser.py │ │ ├── enable_wal.py │ │ ├── ensure_superuser.py │ │ ├── full_backup.py │ │ ├── generate_secret_key.py │ │ ├── import_netscape.py │ │ └── migrate_tasks.py ├── middlewares.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20190629_2303.py │ ├── 0003_auto_20200913_0656.py │ ├── 0004_auto_20200926_1028.py │ ├── 0005_auto_20210103_1212.py │ ├── 0006_bookmark_is_archived.py │ ├── 0007_userprofile.py │ ├── 0008_userprofile_bookmark_date_display.py │ ├── 0009_bookmark_web_archive_snapshot_url.py │ ├── 0010_userprofile_bookmark_link_target.py │ ├── 0011_userprofile_web_archive_integration.py │ ├── 0012_toast.py │ ├── 0013_web_archive_optin_toast.py │ ├── 0014_alter_bookmark_unread.py │ ├── 0015_feedtoken.py │ ├── 0016_bookmark_shared.py │ ├── 0017_userprofile_enable_sharing.py │ ├── 0018_bookmark_favicon_file.py │ ├── 0019_userprofile_enable_favicons.py │ ├── 0020_userprofile_tag_search.py │ ├── 0021_userprofile_display_url.py │ ├── 0022_bookmark_notes.py │ ├── 0023_userprofile_permanent_notes.py │ ├── 0024_userprofile_enable_public_sharing.py │ ├── 0025_userprofile_search_preferences.py │ ├── 0026_userprofile_custom_css.py │ ├── 0027_userprofile_bookmark_description_display_and_more.py │ ├── 0028_userprofile_display_archive_bookmark_action_and_more.py │ ├── 0029_bookmark_list_actions_toast.py │ ├── 0030_bookmarkasset.py │ ├── 0031_userprofile_enable_automatic_html_snapshots.py │ ├── 0032_html_snapshots_hint_toast.py │ ├── 0033_userprofile_default_mark_unread.py │ ├── 0034_bookmark_preview_image_file_and_more.py │ ├── 0035_userprofile_tag_grouping.py │ ├── 0036_userprofile_auto_tagging_rules.py │ ├── 0037_globalsettings.py │ ├── 0038_globalsettings_guest_profile_user.py │ ├── 0039_globalsettings_enable_link_prefetch.py │ ├── 0040_userprofile_items_per_page_and_more.py │ ├── 0041_merge_metadata.py │ ├── 0042_userprofile_custom_css_hash.py │ ├── 0043_userprofile_collapse_side_panel.py │ ├── 0044_bookmark_latest_snapshot.py │ └── __init__.py ├── models.py ├── queries.py ├── services │ ├── __init__.py │ ├── assets.py │ ├── auto_tagging.py │ ├── bookmarks.py │ ├── exporter.py │ ├── favicon_loader.py │ ├── importer.py │ ├── monolith.py │ ├── parser.py │ ├── preview_image_loader.py │ ├── singlefile.py │ ├── tags.py │ ├── tasks.py │ ├── wayback.py │ └── website_loader.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── custom.py │ ├── dev.py │ └── prod.py ├── signals.py ├── static │ ├── apple-touch-icon.png │ ├── favicon.ico │ ├── favicon.svg │ ├── linkding-screenshot.png │ ├── logo-192.png │ ├── logo-512.png │ ├── logo.png │ ├── logo.svg │ ├── maskable-logo-192.png │ ├── maskable-logo-512.png │ ├── maskable-logo.svg │ ├── preview-placeholder.svg │ ├── robots.txt │ ├── safari-pinned-tab.svg │ └── vendor │ │ └── Readability.js ├── styles │ ├── bookmark-details.css │ ├── bookmark-form.css │ ├── bookmark-page.css │ ├── components.css │ ├── layout.css │ ├── markdown.css │ ├── reader-mode.css │ ├── responsive.css │ ├── settings.css │ ├── theme-dark.css │ ├── theme-light.css │ └── theme │ │ ├── LICENSE │ │ ├── _normalize.css │ │ ├── animations.css │ │ ├── asian.css │ │ ├── autocomplete.css │ │ ├── badges.css │ │ ├── base.css │ │ ├── buttons.css │ │ ├── code.css │ │ ├── dropdowns.css │ │ ├── empty.css │ │ ├── forms.css │ │ ├── menus.css │ │ ├── modals.css │ │ ├── pagination.css │ │ ├── tables.css │ │ ├── tabs.css │ │ ├── toasts.css │ │ ├── typography.css │ │ ├── utilities.css │ │ └── variables.css ├── tasks.py ├── templates │ ├── admin │ │ └── background_tasks.html │ ├── bookmarks │ │ ├── archive.html │ │ ├── bookmark_list.html │ │ ├── bookmarklet.js │ │ ├── bulk_edit │ │ │ ├── bar.html │ │ │ └── toggle.html │ │ ├── close.html │ │ ├── details │ │ │ ├── asset_icon.html │ │ │ ├── assets.html │ │ │ ├── form.html │ │ │ └── modal.html │ │ ├── edit.html │ │ ├── empty_bookmarks.html │ │ ├── form.html │ │ ├── head.html │ │ ├── index.html │ │ ├── layout.html │ │ ├── nav_menu.html │ │ ├── new.html │ │ ├── pagination.html │ │ ├── read.html │ │ ├── search.html │ │ ├── shared.html │ │ ├── tag_cloud.html │ │ ├── updates │ │ │ ├── bookmark_view_stream.html │ │ │ └── details-modal-frame.html │ │ └── user_select.html │ ├── django_registration │ │ ├── registration_complete.html │ │ └── registration_form.html │ ├── opensearch.xml │ ├── registration │ │ ├── login.html │ │ ├── password_change_done.html │ │ └── password_change_form.html │ └── settings │ │ ├── general.html │ │ └── integrations.html ├── templatetags │ ├── __init__.py │ ├── bookmarks.py │ ├── pagination.py │ └── shared.py ├── tests │ ├── __init__.py │ ├── helpers.py │ ├── resources │ │ ├── invalid_import_file.png │ │ ├── simple_valid_import_file.html │ │ └── simple_valid_import_file_with_one_invalid_bookmark.html │ ├── test_app_options.py │ ├── test_assets_service.py │ ├── test_auth_api.py │ ├── test_auth_proxy_support.py │ ├── test_auto_tagging.py │ ├── test_bookmark_action_view.py │ ├── test_bookmark_archived_view.py │ ├── test_bookmark_archived_view_performance.py │ ├── test_bookmark_asset_view.py │ ├── test_bookmark_assets.py │ ├── test_bookmark_assets_api.py │ ├── test_bookmark_details_modal.py │ ├── test_bookmark_edit_view.py │ ├── test_bookmark_index_view.py │ ├── test_bookmark_index_view_performance.py │ ├── test_bookmark_new_view.py │ ├── test_bookmark_previews.py │ ├── test_bookmark_search_form.py │ ├── test_bookmark_search_model.py │ ├── test_bookmark_search_tag.py │ ├── test_bookmark_shared_view.py │ ├── test_bookmark_shared_view_performance.py │ ├── test_bookmark_validation.py │ ├── test_bookmarks_api.py │ ├── test_bookmarks_api_performance.py │ ├── test_bookmarks_api_permissions.py │ ├── test_bookmarks_list_template.py │ ├── test_bookmarks_model.py │ ├── test_bookmarks_service.py │ ├── test_bookmarks_tasks.py │ ├── test_context_path.py │ ├── test_create_initial_superuser_command.py │ ├── test_custom_css_view.py │ ├── test_exporter.py │ ├── test_exporter_performance.py │ ├── test_favicon_loader.py │ ├── test_feeds.py │ ├── test_feeds_performance.py │ ├── test_health_view.py │ ├── test_importer.py │ ├── test_layout.py │ ├── test_linkding_middleware.py │ ├── test_login_view.py │ ├── test_metadata_view.py │ ├── test_monolith_service.py │ ├── test_oidc_support.py │ ├── test_opensearch_view.py │ ├── test_pagination_tag.py │ ├── test_parser.py │ ├── test_password_change_view.py │ ├── test_preview_image_loader.py │ ├── test_queries.py │ ├── test_root_view.py │ ├── test_settings_export_view.py │ ├── test_settings_general_view.py │ ├── test_settings_import_view.py │ ├── test_settings_integrations_view.py │ ├── test_singlefile_service.py │ ├── test_tag_cloud_template.py │ ├── test_tags_model.py │ ├── test_tags_service.py │ ├── test_toasts_view.py │ ├── test_user_profile_model.py │ ├── test_user_select_tag.py │ ├── test_utils.py │ └── test_website_loader.py ├── tests_e2e │ ├── __init__.py │ ├── e2e_test_a11y_navigation_focus.py │ ├── e2e_test_bookmark_details_modal.py │ ├── e2e_test_bookmark_item.py │ ├── e2e_test_bookmark_page_bulk_edit.py │ ├── e2e_test_bookmark_page_partial_updates.py │ ├── e2e_test_collapse_side_panel.py │ ├── e2e_test_edit_bookmark_form.py │ ├── e2e_test_filter_drawer.py │ ├── e2e_test_global_shortcuts.py │ ├── e2e_test_new_bookmark_form.py │ ├── e2e_test_settings_general.py │ └── helpers.py ├── type_defs.py ├── urls.py ├── utils.py ├── validators.py ├── views │ ├── __init__.py │ ├── access.py │ ├── assets.py │ ├── auth.py │ ├── bookmarks.py │ ├── contexts.py │ ├── custom_css.py │ ├── health.py │ ├── manifest.py │ ├── opensearch.py │ ├── partials.py │ ├── root.py │ ├── settings.py │ ├── toasts.py │ └── turbo.py └── wsgi.py ├── bootstrap.sh ├── docker-compose.yml ├── docker ├── alpine.Dockerfile └── default.Dockerfile ├── docs ├── .gitignore ├── README.md ├── astro.config.mjs ├── package-lock.json ├── package.json ├── public │ ├── donations │ │ ├── 2023-10-11-internet-archive.png │ │ ├── 2024-10-04-django.png │ │ ├── 2024-10-04-internet-archive.png │ │ ├── 2024-10-04-noyb.png │ │ └── 2024-10-04-singlefile.png │ ├── favicon.svg │ ├── linkding-screenshot-dark.png │ └── linkding-screenshot.png ├── src │ ├── assets │ │ ├── Add To Linkding.shortcut │ │ ├── linkding_shortcut.json │ │ └── logo.svg │ ├── components │ │ ├── Card.astro │ │ └── icons.ts │ ├── content │ │ ├── config.ts │ │ └── docs │ │ │ ├── acknowledgements.md │ │ │ ├── admin.md │ │ │ ├── api.md │ │ │ ├── archiving.md │ │ │ ├── auto-tagging.md │ │ │ ├── backups.md │ │ │ ├── browser-extension.md │ │ │ ├── community.md │ │ │ ├── how-to.md │ │ │ ├── index.mdx │ │ │ ├── installation.md │ │ │ ├── managed-hosting.md │ │ │ ├── options.md │ │ │ ├── shortcuts.md │ │ │ └── troubleshooting.md │ ├── env.d.ts │ └── styles │ │ └── custom.css └── tsconfig.json ├── install-linkding.sh ├── manage.py ├── package-lock.json ├── package.json ├── postcss.config.js ├── pytest.ini ├── requirements.dev.in ├── requirements.dev.txt ├── requirements.in ├── requirements.txt ├── rollup.config.mjs ├── scripts ├── build-docker.sh ├── coverage.sh ├── generate-changelog.py ├── release.sh ├── run-docker.sh ├── run-postgres.sh ├── setup-oicd.sh ├── setup-ublock.sh ├── test-e2e.sh ├── test-postgres.sh ├── test-unit.sh └── test.sh ├── supervisord.conf ├── uwsgi.ini ├── version.txt └── web-types.json /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = bookmarks 3 | omit = bookmarks/tests/* 4 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Python 3", 5 | "image": "mcr.microsoft.com/devcontainers/python:3.12", 6 | "features": { 7 | "ghcr.io/devcontainers/features/node:1": {} 8 | }, 9 | 10 | // Features to add to the dev container. More info: https://containers.dev/features. 11 | // "features": {}, 12 | 13 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 14 | "forwardPorts": [8000], 15 | 16 | // Use 'postCreateCommand' to run commands after the container is created. 17 | "postCreateCommand": "pip3 install --user -r requirements.txt -r requirements.dev.txt && npm install && mkdir -p data && python3 manage.py migrate", 18 | 19 | // Configure tool-specific properties. 20 | "customizations": { 21 | "vscode": { 22 | "extensions": [ 23 | "ms-python.python" 24 | ] 25 | } 26 | }, 27 | 28 | "remoteUser": "vscode" 29 | } 30 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Include files required for build or at runtime 5 | !/bookmarks 6 | 7 | !/bootstrap.sh 8 | !/LICENSE.txt 9 | !/manage.py 10 | !/package.json 11 | !/package-lock.json 12 | !/postcss.config.js 13 | !/requirements.dev.txt 14 | !/requirements.txt 15 | !/rollup.config.mjs 16 | !/supervisord.conf 17 | !/uwsgi.ini 18 | !/version.txt 19 | 20 | # Remove dev settings 21 | /bookmarks/settings/dev.py 22 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: linkding CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | unit_tests: 11 | name: Unit Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Set up Python 16 | uses: actions/setup-python@v5 17 | with: 18 | python-version: "3.12" 19 | - name: Set up Node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | cache: 'npm' 24 | - name: Install Node dependencies 25 | run: npm ci 26 | - name: Setup Python environment 27 | run: | 28 | pip install -r requirements.txt -r requirements.dev.txt 29 | mkdir data 30 | - name: Run tests 31 | run: python manage.py test bookmarks.tests 32 | e2e_tests: 33 | name: E2E Tests 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Set up Python 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: "3.12" 41 | - name: Set up Node 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: 20 45 | cache: 'npm' 46 | - name: Install Node dependencies 47 | run: npm ci 48 | - name: Setup Python environment 49 | run: | 50 | pip install -r requirements.txt -r requirements.dev.txt 51 | playwright install chromium 52 | mkdir data 53 | - name: Run build 54 | run: | 55 | npm run build 56 | python manage.py collectstatic 57 | - name: Run tests 58 | run: python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py" 59 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Sascha Ißbrücker 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: serve 2 | 3 | serve: 4 | python manage.py runserver 5 | 6 | tasks: 7 | python manage.py run_huey 8 | 9 | test: 10 | pytest -n auto 11 | 12 | format: 13 | black bookmarks 14 | npx prettier bookmarks/frontend --write 15 | npx prettier bookmarks/styles --write 16 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.10.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | To report a vulnerability, please send a mail to: 588ex5zl8@mozmail.com 12 | 13 | I'll try to get back to you as soon as possible. 14 | -------------------------------------------------------------------------------- /assets/header.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/header.afdesign -------------------------------------------------------------------------------- /assets/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/header.png -------------------------------------------------------------------------------- /assets/logo-inset.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/logo-inset.afdesign -------------------------------------------------------------------------------- /assets/logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/logo.afdesign -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/logo.png -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /assets/social-preview.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/social-preview.afdesign -------------------------------------------------------------------------------- /assets/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/assets/social-preview.png -------------------------------------------------------------------------------- /bookmarks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/__init__.py -------------------------------------------------------------------------------- /bookmarks/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/api/__init__.py -------------------------------------------------------------------------------- /bookmarks/api/auth.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from rest_framework import exceptions 3 | from rest_framework.authentication import TokenAuthentication, get_authorization_header 4 | 5 | 6 | class LinkdingTokenAuthentication(TokenAuthentication): 7 | """ 8 | Extends DRF TokenAuthentication to add support for multiple keywords 9 | """ 10 | 11 | keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]] 12 | 13 | def authenticate(self, request): 14 | auth = get_authorization_header(request).split() 15 | 16 | if not auth or auth[0].lower() not in self.keywords: 17 | return None 18 | 19 | if len(auth) == 1: 20 | msg = _("Invalid token header. No credentials provided.") 21 | raise exceptions.AuthenticationFailed(msg) 22 | elif len(auth) > 2: 23 | msg = _("Invalid token header. Token string should not contain spaces.") 24 | raise exceptions.AuthenticationFailed(msg) 25 | 26 | try: 27 | token = auth[1].decode() 28 | except UnicodeError: 29 | msg = _( 30 | "Invalid token header. Token string should not contain invalid characters." 31 | ) 32 | raise exceptions.AuthenticationFailed(msg) 33 | 34 | return self.authenticate_credentials(token) 35 | -------------------------------------------------------------------------------- /bookmarks/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class BookmarksConfig(AppConfig): 5 | name = "bookmarks" 6 | 7 | def ready(self): 8 | # Register signal handlers 9 | import bookmarks.signals 10 | -------------------------------------------------------------------------------- /bookmarks/context_processors.py: -------------------------------------------------------------------------------- 1 | from bookmarks import utils 2 | from bookmarks.models import Toast 3 | 4 | 5 | def toasts(request): 6 | user = request.user 7 | toast_messages = ( 8 | Toast.objects.filter(owner=user, acknowledged=False) 9 | if user.is_authenticated 10 | else [] 11 | ) 12 | has_toasts = len(toast_messages) > 0 13 | 14 | return { 15 | "has_toasts": has_toasts, 16 | "toast_messages": toast_messages, 17 | } 18 | 19 | 20 | def app_version(request): 21 | return {"app_version": utils.app_version} 22 | -------------------------------------------------------------------------------- /bookmarks/frontend/api.js: -------------------------------------------------------------------------------- 1 | export class Api { 2 | constructor(baseUrl) { 3 | this.baseUrl = baseUrl; 4 | } 5 | 6 | listBookmarks(search, options = { limit: 100, offset: 0, path: "" }) { 7 | const query = [`limit=${options.limit}`, `offset=${options.offset}`]; 8 | Object.keys(search).forEach((key) => { 9 | const value = search[key]; 10 | if (value) { 11 | query.push(`${key}=${encodeURIComponent(value)}`); 12 | } 13 | }); 14 | const queryString = query.join("&"); 15 | const url = `${this.baseUrl}bookmarks${options.path}/?${queryString}`; 16 | 17 | return fetch(url) 18 | .then((response) => response.json()) 19 | .then((data) => data.results); 20 | } 21 | 22 | getTags(options = { limit: 100, offset: 0 }) { 23 | const url = `${this.baseUrl}tags/?limit=${options.limit}&offset=${options.offset}`; 24 | 25 | return fetch(url) 26 | .then((response) => response.json()) 27 | .then((data) => data.results); 28 | } 29 | } 30 | 31 | const apiBaseUrl = document.documentElement.dataset.apiBaseUrl || ""; 32 | export const api = new Api(apiBaseUrl); 33 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/bookmark-page.js: -------------------------------------------------------------------------------- 1 | import { Behavior, registerBehavior } from "./index"; 2 | 3 | class BookmarkItem extends Behavior { 4 | constructor(element) { 5 | super(element); 6 | 7 | // Toggle notes 8 | this.onToggleNotes = this.onToggleNotes.bind(this); 9 | this.notesToggle = element.querySelector(".toggle-notes"); 10 | if (this.notesToggle) { 11 | this.notesToggle.addEventListener("click", this.onToggleNotes); 12 | } 13 | 14 | // Add tooltip to title if it is truncated 15 | const titleAnchor = element.querySelector(".title > a"); 16 | const titleSpan = titleAnchor.querySelector("span"); 17 | requestAnimationFrame(() => { 18 | if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { 19 | titleAnchor.dataset.tooltip = titleSpan.textContent; 20 | } 21 | }); 22 | } 23 | 24 | destroy() { 25 | if (this.notesToggle) { 26 | this.notesToggle.removeEventListener("click", this.onToggleNotes); 27 | } 28 | } 29 | 30 | onToggleNotes(event) { 31 | event.preventDefault(); 32 | event.stopPropagation(); 33 | this.element.classList.toggle("show-notes"); 34 | } 35 | } 36 | 37 | registerBehavior("ld-bookmark-item", BookmarkItem); 38 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/clear-button.js: -------------------------------------------------------------------------------- 1 | import { Behavior, registerBehavior } from "./index"; 2 | 3 | class ClearButtonBehavior extends Behavior { 4 | constructor(element) { 5 | super(element); 6 | 7 | this.field = document.getElementById(element.dataset.for); 8 | if (!this.field) { 9 | console.error(`Field with ID ${element.dataset.for} not found`); 10 | return; 11 | } 12 | 13 | this.update = this.update.bind(this); 14 | this.clear = this.clear.bind(this); 15 | 16 | this.element.addEventListener("click", this.clear); 17 | this.field.addEventListener("input", this.update); 18 | this.field.addEventListener("value-changed", this.update); 19 | this.update(); 20 | } 21 | 22 | destroy() { 23 | if (!this.field) { 24 | return; 25 | } 26 | this.element.removeEventListener("click", this.clear); 27 | this.field.removeEventListener("input", this.update); 28 | this.field.removeEventListener("value-changed", this.update); 29 | } 30 | 31 | update() { 32 | this.element.style.display = this.field.value ? "inline-flex" : "none"; 33 | } 34 | 35 | clear() { 36 | this.field.value = ""; 37 | this.field.focus(); 38 | this.update(); 39 | } 40 | } 41 | 42 | registerBehavior("ld-clear-button", ClearButtonBehavior); 43 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/details-modal.js: -------------------------------------------------------------------------------- 1 | import { registerBehavior } from "./index"; 2 | import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils"; 3 | import { ModalBehavior } from "./modal"; 4 | 5 | class DetailsModalBehavior extends ModalBehavior { 6 | doClose() { 7 | super.doClose(); 8 | 9 | // Navigate to close URL 10 | const closeUrl = this.element.dataset.closeUrl; 11 | Turbo.visit(closeUrl, { 12 | action: "replace", 13 | frame: "details-modal", 14 | }); 15 | 16 | // Try restore focus to view details to view details link of respective bookmark 17 | const bookmarkId = this.element.dataset.bookmarkId; 18 | setAfterPageLoadFocusTarget( 19 | `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, 20 | ); 21 | } 22 | } 23 | 24 | registerBehavior("ld-details-modal", DetailsModalBehavior); 25 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/form.js: -------------------------------------------------------------------------------- 1 | import { Behavior, registerBehavior } from "./index"; 2 | 3 | class AutoSubmitBehavior extends Behavior { 4 | constructor(element) { 5 | super(element); 6 | 7 | this.submit = this.submit.bind(this); 8 | element.addEventListener("change", this.submit); 9 | } 10 | 11 | destroy() { 12 | this.element.removeEventListener("change", this.submit); 13 | } 14 | 15 | submit() { 16 | this.element.closest("form").requestSubmit(); 17 | } 18 | } 19 | 20 | class UploadButton extends Behavior { 21 | constructor(element) { 22 | super(element); 23 | this.fileInput = element.nextElementSibling; 24 | 25 | this.onClick = this.onClick.bind(this); 26 | this.onChange = this.onChange.bind(this); 27 | 28 | element.addEventListener("click", this.onClick); 29 | this.fileInput.addEventListener("change", this.onChange); 30 | } 31 | 32 | destroy() { 33 | this.element.removeEventListener("click", this.onClick); 34 | this.fileInput.removeEventListener("change", this.onChange); 35 | } 36 | 37 | onClick(event) { 38 | event.preventDefault(); 39 | this.fileInput.click(); 40 | } 41 | 42 | onChange() { 43 | // Check if the file input has a file selected 44 | if (!this.fileInput.files.length) { 45 | return; 46 | } 47 | const form = this.fileInput.closest("form"); 48 | form.requestSubmit(this.element); 49 | // remove selected file so it doesn't get submitted again 50 | this.fileInput.value = ""; 51 | } 52 | } 53 | 54 | registerBehavior("ld-auto-submit", AutoSubmitBehavior); 55 | registerBehavior("ld-upload-button", UploadButton); 56 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/search-autocomplete.js: -------------------------------------------------------------------------------- 1 | import { Behavior, registerBehavior } from "./index"; 2 | import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte"; 3 | 4 | class SearchAutocomplete extends Behavior { 5 | constructor(element) { 6 | super(element); 7 | const input = element.querySelector("input"); 8 | if (!input) { 9 | console.warn("SearchAutocomplete: input element not found"); 10 | return; 11 | } 12 | 13 | const container = document.createElement("div"); 14 | 15 | new SearchAutoCompleteComponent({ 16 | target: container, 17 | props: { 18 | name: "q", 19 | placeholder: input.getAttribute("placeholder") || "", 20 | value: input.value, 21 | linkTarget: input.dataset.linkTarget, 22 | mode: input.dataset.mode, 23 | search: { 24 | user: input.dataset.user, 25 | shared: input.dataset.shared, 26 | unread: input.dataset.unread, 27 | }, 28 | }, 29 | }); 30 | 31 | this.input = input; 32 | this.autocomplete = container.firstElementChild; 33 | input.replaceWith(this.autocomplete); 34 | } 35 | 36 | destroy() { 37 | this.autocomplete.replaceWith(this.input); 38 | } 39 | } 40 | 41 | registerBehavior("ld-search-autocomplete", SearchAutocomplete); 42 | -------------------------------------------------------------------------------- /bookmarks/frontend/behaviors/tag-autocomplete.js: -------------------------------------------------------------------------------- 1 | import { Behavior, registerBehavior } from "./index"; 2 | import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; 3 | 4 | class TagAutocomplete extends Behavior { 5 | constructor(element) { 6 | super(element); 7 | const input = element.querySelector("input"); 8 | if (!input) { 9 | console.warn("TagAutocomplete: input element not found"); 10 | return; 11 | } 12 | 13 | const container = document.createElement("div"); 14 | 15 | new TagAutoCompleteComponent({ 16 | target: container, 17 | props: { 18 | id: input.id, 19 | name: input.name, 20 | value: input.value, 21 | placeholder: input.getAttribute("placeholder") || "", 22 | variant: input.getAttribute("variant"), 23 | }, 24 | }); 25 | 26 | this.input = input; 27 | this.autocomplete = container.firstElementChild; 28 | input.replaceWith(this.autocomplete); 29 | } 30 | 31 | destroy() { 32 | this.autocomplete.replaceWith(this.input); 33 | } 34 | } 35 | 36 | registerBehavior("ld-tag-autocomplete", TagAutocomplete); 37 | -------------------------------------------------------------------------------- /bookmarks/frontend/cache.js: -------------------------------------------------------------------------------- 1 | import { api } from "./api.js"; 2 | 3 | class Cache { 4 | constructor(api) { 5 | this.api = api; 6 | 7 | // Reset cached tags after a form submission 8 | document.addEventListener("turbo:submit-end", () => { 9 | this.tagsPromise = null; 10 | }); 11 | } 12 | 13 | getTags() { 14 | if (!this.tagsPromise) { 15 | this.tagsPromise = this.api 16 | .getTags({ 17 | limit: 5000, 18 | offset: 0, 19 | }) 20 | .then((tags) => 21 | tags.sort((left, right) => 22 | left.name.toLowerCase().localeCompare(right.name.toLowerCase()), 23 | ), 24 | ) 25 | .catch((e) => { 26 | console.warn("Cache: Error loading tags", e); 27 | return []; 28 | }); 29 | } 30 | 31 | return this.tagsPromise; 32 | } 33 | } 34 | 35 | export const cache = new Cache(api); 36 | -------------------------------------------------------------------------------- /bookmarks/frontend/components/SearchHistory.js: -------------------------------------------------------------------------------- 1 | const SEARCH_HISTORY_KEY = "searchHistory"; 2 | const MAX_ENTRIES = 30; 3 | 4 | export class SearchHistory { 5 | getHistory() { 6 | const historyJson = localStorage.getItem(SEARCH_HISTORY_KEY); 7 | return historyJson 8 | ? JSON.parse(historyJson) 9 | : { 10 | recent: [], 11 | }; 12 | } 13 | 14 | pushCurrent() { 15 | // Skip if browser is not compatible 16 | if (!window.URLSearchParams) return; 17 | const urlParams = new URLSearchParams(window.location.search); 18 | const searchParam = urlParams.get("q"); 19 | 20 | if (!searchParam) return; 21 | 22 | this.push(searchParam); 23 | } 24 | 25 | push(search) { 26 | const history = this.getHistory(); 27 | 28 | history.recent.unshift(search); 29 | 30 | // Remove duplicates and clamp to max entries 31 | history.recent = history.recent.reduce((acc, cur) => { 32 | if (acc.length >= MAX_ENTRIES) return acc; 33 | if (acc.indexOf(cur) >= 0) return acc; 34 | acc.push(cur); 35 | return acc; 36 | }, []); 37 | 38 | const newHistoryJson = JSON.stringify(history); 39 | localStorage.setItem(SEARCH_HISTORY_KEY, newHistoryJson); 40 | } 41 | 42 | getRecentSearches(query, max) { 43 | const history = this.getHistory(); 44 | 45 | return history.recent 46 | .filter( 47 | (search) => 48 | !query || search.toLowerCase().indexOf(query.toLowerCase()) >= 0, 49 | ) 50 | .slice(0, max); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /bookmarks/frontend/index.js: -------------------------------------------------------------------------------- 1 | import "@hotwired/turbo"; 2 | import "./behaviors/bookmark-page"; 3 | import "./behaviors/bulk-edit"; 4 | import "./behaviors/clear-button"; 5 | import "./behaviors/confirm-button"; 6 | import "./behaviors/details-modal"; 7 | import "./behaviors/dropdown"; 8 | import "./behaviors/filter-drawer"; 9 | import "./behaviors/form"; 10 | import "./behaviors/global-shortcuts"; 11 | import "./behaviors/search-autocomplete"; 12 | import "./behaviors/tag-autocomplete"; 13 | 14 | export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte"; 15 | export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte"; 16 | export { api } from "./api"; 17 | export { cache } from "./cache"; 18 | -------------------------------------------------------------------------------- /bookmarks/frontend/util.js: -------------------------------------------------------------------------------- 1 | export function debounce(callback, delay = 250) { 2 | let timeoutId; 3 | return (...args) => { 4 | clearTimeout(timeoutId); 5 | timeoutId = setTimeout(() => { 6 | timeoutId = null; 7 | callback(...args); 8 | }, delay); 9 | }; 10 | } 11 | 12 | export function clampText(text, maxChars = 30) { 13 | if (!text || text.length <= 30) return text; 14 | 15 | return text.substr(0, maxChars) + "..."; 16 | } 17 | 18 | export function getCurrentWordBounds(input) { 19 | const text = input.value; 20 | const end = input.selectionStart; 21 | let start = end; 22 | 23 | let currentChar = text.charAt(start - 1); 24 | 25 | while (currentChar && currentChar !== " " && start > 0) { 26 | start--; 27 | currentChar = text.charAt(start - 1); 28 | } 29 | 30 | return { start, end }; 31 | } 32 | 33 | export function getCurrentWord(input) { 34 | const bounds = getCurrentWordBounds(input); 35 | 36 | return input.value.substring(bounds.start, bounds.end); 37 | } 38 | -------------------------------------------------------------------------------- /bookmarks/management/commands/backup.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import os 3 | 4 | from django.core.management.base import BaseCommand 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Creates a backup of the linkding database" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("destination", type=str, help="Backup file destination") 12 | 13 | def handle(self, *args, **options): 14 | destination = options["destination"] 15 | 16 | def progress(status, remaining, total): 17 | self.stdout.write(f"Copied {total-remaining} of {total} pages...") 18 | 19 | source_db = sqlite3.connect(os.path.join("data", "db.sqlite3")) 20 | backup_db = sqlite3.connect(destination) 21 | with backup_db: 22 | source_db.backup(backup_db, pages=50, progress=progress) 23 | backup_db.close() 24 | source_db.close() 25 | 26 | self.stdout.write(self.style.SUCCESS(f"Backup created at {destination}")) 27 | self.stdout.write( 28 | self.style.WARNING( 29 | "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." 30 | ) 31 | ) 32 | -------------------------------------------------------------------------------- /bookmarks/management/commands/create_initial_superuser.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.contrib.auth import get_user_model 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Creates an initial superuser for a deployment using env variables" 12 | 13 | def handle(self, *args, **options): 14 | User = get_user_model() 15 | superuser_name = os.getenv("LD_SUPERUSER_NAME", None) 16 | superuser_password = os.getenv("LD_SUPERUSER_PASSWORD", None) 17 | 18 | # Skip if option is undefined 19 | if not superuser_name: 20 | logger.info( 21 | "Skip creating initial superuser, LD_SUPERUSER_NAME option is not defined" 22 | ) 23 | return 24 | 25 | # Skip if user already exists 26 | user_exists = User.objects.filter(username=superuser_name).exists() 27 | if user_exists: 28 | logger.info("Skip creating initial superuser, user already exists") 29 | return 30 | 31 | user = User(username=superuser_name, is_superuser=True, is_staff=True) 32 | 33 | if superuser_password: 34 | user.set_password(superuser_password) 35 | else: 36 | user.set_unusable_password() 37 | 38 | user.save() 39 | logger.info("Created initial superuser") 40 | -------------------------------------------------------------------------------- /bookmarks/management/commands/enable_wal.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.conf import settings 4 | from django.core.management.base import BaseCommand 5 | from django.db import connections 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class Command(BaseCommand): 11 | help = "Enable WAL journal mode when using an SQLite database" 12 | 13 | def handle(self, *args, **options): 14 | if not settings.USE_SQLITE: 15 | return 16 | 17 | connection = connections["default"] 18 | with connection.cursor() as cursor: 19 | cursor.execute("PRAGMA journal_mode") 20 | current_mode = cursor.fetchone()[0] 21 | logger.info(f"Current journal mode: {current_mode}") 22 | if current_mode != "wal": 23 | cursor.execute("PRAGMA journal_mode=wal;") 24 | logger.info("Switched to WAL journal mode") 25 | -------------------------------------------------------------------------------- /bookmarks/management/commands/ensure_superuser.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.contrib.auth import get_user_model 3 | 4 | 5 | class Command(BaseCommand): 6 | help = "Creates an admin user non-interactively if it doesn't exist" 7 | 8 | def add_arguments(self, parser): 9 | parser.add_argument("--username", help="Admin's username") 10 | parser.add_argument("--email", help="Admin's email") 11 | parser.add_argument("--password", help="Admin's password") 12 | 13 | def handle(self, *args, **options): 14 | User = get_user_model() 15 | if not User.objects.filter(username=options["username"]).exists(): 16 | User.objects.create_superuser( 17 | username=options["username"], 18 | email=options["email"], 19 | password=options["password"], 20 | ) 21 | -------------------------------------------------------------------------------- /bookmarks/management/commands/generate_secret_key.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.core.management.utils import get_random_secret_key 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class Command(BaseCommand): 12 | help = "Generate secret key file if it does not exist" 13 | 14 | def handle(self, *args, **options): 15 | secret_key_file = os.path.join("data", "secretkey.txt") 16 | 17 | if os.path.exists(secret_key_file): 18 | logger.info(f"Secret key file already exists") 19 | return 20 | 21 | secret_key = get_random_secret_key() 22 | with open(secret_key_file, "w") as f: 23 | f.write(secret_key) 24 | logger.info(f"Generated secret key file") 25 | -------------------------------------------------------------------------------- /bookmarks/management/commands/import_netscape.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.core.management.base import BaseCommand 3 | 4 | from bookmarks.services.importer import import_netscape_html 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Import Netscape HTML bookmark file" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("file", type=str, help="Path to file") 12 | parser.add_argument( 13 | "user", type=str, help="Name of the user for which to import" 14 | ) 15 | 16 | def handle(self, *args, **kwargs): 17 | filepath = kwargs["file"] 18 | username = kwargs["user"] 19 | with open(filepath) as html_file: 20 | html = html_file.read() 21 | user = User.objects.get(username=username) 22 | 23 | import_netscape_html(html, user) 24 | -------------------------------------------------------------------------------- /bookmarks/middlewares.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth.middleware import RemoteUserMiddleware 3 | 4 | from bookmarks.models import UserProfile, GlobalSettings 5 | 6 | 7 | class CustomRemoteUserMiddleware(RemoteUserMiddleware): 8 | header = settings.LD_AUTH_PROXY_USERNAME_HEADER 9 | 10 | 11 | default_global_settings = GlobalSettings() 12 | 13 | standard_profile = UserProfile() 14 | standard_profile.enable_favicons = True 15 | 16 | 17 | class LinkdingMiddleware: 18 | def __init__(self, get_response): 19 | self.get_response = get_response 20 | 21 | def __call__(self, request): 22 | # add global settings to request 23 | try: 24 | global_settings = GlobalSettings.get() 25 | except: 26 | global_settings = default_global_settings 27 | request.global_settings = global_settings 28 | 29 | # add user profile to request 30 | if request.user.is_authenticated: 31 | request.user_profile = request.user.profile 32 | else: 33 | # check if a custom profile for guests exists, otherwise use standard profile 34 | if global_settings.guest_profile_user: 35 | request.user_profile = global_settings.guest_profile_user.profile 36 | else: 37 | request.user_profile = standard_profile 38 | 39 | response = self.get_response(request) 40 | 41 | return response 42 | -------------------------------------------------------------------------------- /bookmarks/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-28 23:49 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Bookmark", 19 | fields=[ 20 | ( 21 | "id", 22 | models.AutoField( 23 | auto_created=True, 24 | primary_key=True, 25 | serialize=False, 26 | verbose_name="ID", 27 | ), 28 | ), 29 | ("url", models.URLField()), 30 | ("title", models.CharField(max_length=512)), 31 | ("description", models.TextField()), 32 | ( 33 | "website_title", 34 | models.CharField(blank=True, max_length=512, null=True), 35 | ), 36 | ("website_description", models.TextField(blank=True, null=True)), 37 | ("unread", models.BooleanField(default=True)), 38 | ("date_added", models.DateTimeField()), 39 | ("date_modified", models.DateTimeField()), 40 | ("date_accessed", models.DateTimeField(blank=True, null=True)), 41 | ( 42 | "owner", 43 | models.ForeignKey( 44 | on_delete=django.db.models.deletion.CASCADE, 45 | to=settings.AUTH_USER_MODEL, 46 | ), 47 | ), 48 | ], 49 | ), 50 | ] 51 | -------------------------------------------------------------------------------- /bookmarks/migrations/0002_auto_20190629_2303.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.2 on 2019-06-29 23:03 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("bookmarks", "0001_initial"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Tag", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=64)), 29 | ("date_added", models.DateTimeField()), 30 | ( 31 | "owner", 32 | models.ForeignKey( 33 | on_delete=django.db.models.deletion.CASCADE, 34 | to=settings.AUTH_USER_MODEL, 35 | ), 36 | ), 37 | ], 38 | ), 39 | migrations.AddField( 40 | model_name="bookmark", 41 | name="tags", 42 | field=models.ManyToManyField(to="bookmarks.Tag"), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /bookmarks/migrations/0003_auto_20200913_0656.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-09-13 06:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0002_auto_20190629_2303"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="bookmark", 15 | name="url", 16 | field=models.URLField(max_length=2048), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0004_auto_20200926_1028.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2020-09-26 10:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0003_auto_20200913_0656"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="bookmark", 15 | name="description", 16 | field=models.TextField(blank=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="bookmark", 20 | name="title", 21 | field=models.CharField(blank=True, max_length=512), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /bookmarks/migrations/0005_auto_20210103_1212.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2021-01-03 12:12 2 | 3 | import bookmarks.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("bookmarks", "0004_auto_20200926_1028"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="bookmark", 16 | name="url", 17 | field=models.CharField( 18 | max_length=2048, 19 | validators=[bookmarks.validators.BookmarkURLValidator()], 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bookmarks/migrations/0006_bookmark_is_archived.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.13 on 2021-02-14 09:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0005_auto_20210103_1212"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="is_archived", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0008_userprofile_bookmark_date_display.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.18 on 2021-03-30 10:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0007_userprofile"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="bookmark_date_display", 16 | field=models.CharField( 17 | choices=[ 18 | ("relative", "Relative"), 19 | ("absolute", "Absolute"), 20 | ("hidden", "Hidden"), 21 | ], 22 | default="relative", 23 | max_length=10, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /bookmarks/migrations/0009_bookmark_web_archive_snapshot_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.20 on 2021-05-16 14:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0008_userprofile_bookmark_date_display"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="web_archive_snapshot_url", 16 | field=models.CharField(blank=True, max_length=2048), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0010_userprofile_bookmark_link_target.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2021-10-03 06:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0009_bookmark_web_archive_snapshot_url"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="bookmark_link_target", 16 | field=models.CharField( 17 | choices=[("_blank", "New page"), ("_self", "Same page")], 18 | default="_blank", 19 | max_length=10, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bookmarks/migrations/0011_userprofile_web_archive_integration.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2022-01-08 12:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0010_userprofile_bookmark_link_target"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="web_archive_integration", 16 | field=models.CharField( 17 | choices=[("disabled", "Disabled"), ("enabled", "Enabled")], 18 | default="disabled", 19 | max_length=10, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bookmarks/migrations/0012_toast.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2022-01-08 19:24 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("bookmarks", "0011_userprofile_web_archive_integration"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Toast", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("key", models.CharField(max_length=50)), 29 | ("message", models.TextField()), 30 | ("acknowledged", models.BooleanField(default=False)), 31 | ( 32 | "owner", 33 | models.ForeignKey( 34 | on_delete=django.db.models.deletion.CASCADE, 35 | to=settings.AUTH_USER_MODEL, 36 | ), 37 | ), 38 | ], 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /bookmarks/migrations/0013_web_archive_optin_toast.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.6 on 2022-01-08 19:27 2 | 3 | from django.db import migrations 4 | from django.contrib.auth import get_user_model 5 | 6 | from bookmarks.models import Toast 7 | 8 | User = get_user_model() 9 | 10 | 11 | def forwards(apps, schema_editor): 12 | for user in User.objects.all(): 13 | toast = Toast( 14 | key="web_archive_opt_in_hint", 15 | message="The Internet Archive Wayback Machine integration has been disabled by default. Check the Settings to re-enable it.", 16 | owner=user, 17 | ) 18 | toast.save() 19 | 20 | 21 | def reverse(apps, schema_editor): 22 | Toast.objects.filter(key="web_archive_opt_in_hint").delete() 23 | 24 | 25 | class Migration(migrations.Migration): 26 | dependencies = [ 27 | ("bookmarks", "0012_toast"), 28 | ] 29 | 30 | operations = [ 31 | migrations.RunPython(forwards, reverse), 32 | ] 33 | -------------------------------------------------------------------------------- /bookmarks/migrations/0014_alter_bookmark_unread.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-07-23 12:30 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | def forwards(apps, schema_editor): 7 | Bookmark = apps.get_model("bookmarks", "Bookmark") 8 | Bookmark.objects.update(unread=False) 9 | 10 | 11 | def reverse(apps, schema_editor): 12 | pass 13 | 14 | 15 | class Migration(migrations.Migration): 16 | dependencies = [ 17 | ("bookmarks", "0013_web_archive_optin_toast"), 18 | ] 19 | 20 | operations = [ 21 | migrations.AlterField( 22 | model_name="bookmark", 23 | name="unread", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.RunPython(forwards, reverse), 27 | ] 28 | -------------------------------------------------------------------------------- /bookmarks/migrations/0015_feedtoken.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.13 on 2022-07-23 20:35 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ("bookmarks", "0014_alter_bookmark_unread"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="FeedToken", 18 | fields=[ 19 | ( 20 | "key", 21 | models.CharField(max_length=40, primary_key=True, serialize=False), 22 | ), 23 | ("created", models.DateTimeField(auto_now_add=True)), 24 | ( 25 | "user", 26 | models.OneToOneField( 27 | on_delete=django.db.models.deletion.CASCADE, 28 | related_name="feed_token", 29 | to=settings.AUTH_USER_MODEL, 30 | ), 31 | ), 32 | ], 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /bookmarks/migrations/0016_bookmark_shared.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-08-02 18:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0015_feedtoken"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="shared", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0017_userprofile_enable_sharing.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.14 on 2022-08-04 09:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0016_bookmark_shared"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="enable_sharing", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0018_bookmark_favicon_file.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2023-01-07 23:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0017_userprofile_enable_sharing"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="favicon_file", 16 | field=models.CharField(blank=True, max_length=512), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0019_userprofile_enable_favicons.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1 on 2023-01-09 21:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0018_bookmark_favicon_file"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="enable_favicons", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0020_userprofile_tag_search.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-04-10 01:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0019_userprofile_enable_favicons"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="tag_search", 16 | field=models.CharField( 17 | choices=[("strict", "Strict"), ("lax", "Lax")], 18 | default="strict", 19 | max_length=10, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bookmarks/migrations/0021_userprofile_display_url.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-18 07:58 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0020_userprofile_tag_search"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="display_url", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0022_bookmark_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.7 on 2023-05-19 10:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0021_userprofile_display_url"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="notes", 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0023_userprofile_permanent_notes.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-05-20 08:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0022_bookmark_notes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="permanent_notes", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0024_userprofile_enable_public_sharing.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-08-14 07:08 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0023_userprofile_permanent_notes"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="enable_public_sharing", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0025_userprofile_search_preferences.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-09-30 10:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0024_userprofile_enable_public_sharing"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="search_preferences", 16 | field=models.JSONField(default=dict), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0026_userprofile_custom_css.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-16 23:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0025_userprofile_search_preferences"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="custom_css", 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0027_userprofile_bookmark_description_display_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-23 21:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0026_userprofile_custom_css"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="bookmark_description_display", 16 | field=models.CharField( 17 | choices=[("inline", "Inline"), ("separate", "Separate")], 18 | default="inline", 19 | max_length=10, 20 | ), 21 | ), 22 | migrations.AddField( 23 | model_name="userprofile", 24 | name="bookmark_description_max_lines", 25 | field=models.IntegerField(default=1), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /bookmarks/migrations/0028_userprofile_display_archive_bookmark_action_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-29 20:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0027_userprofile_bookmark_description_display_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="display_archive_bookmark_action", 16 | field=models.BooleanField(default=True), 17 | ), 18 | migrations.AddField( 19 | model_name="userprofile", 20 | name="display_edit_bookmark_action", 21 | field=models.BooleanField(default=True), 22 | ), 23 | migrations.AddField( 24 | model_name="userprofile", 25 | name="display_remove_bookmark_action", 26 | field=models.BooleanField(default=True), 27 | ), 28 | migrations.AddField( 29 | model_name="userprofile", 30 | name="display_view_bookmark_action", 31 | field=models.BooleanField(default=True), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /bookmarks/migrations/0029_bookmark_list_actions_toast.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-29 21:25 2 | 3 | from django.db import migrations 4 | from django.contrib.auth import get_user_model 5 | 6 | from bookmarks.models import Toast 7 | 8 | User = get_user_model() 9 | 10 | 11 | def forwards(apps, schema_editor): 12 | 13 | for user in User.objects.all(): 14 | toast = Toast( 15 | key="bookmark_list_actions_hint", 16 | 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.", 17 | owner=user, 18 | ) 19 | toast.save() 20 | 21 | 22 | def reverse(apps, schema_editor): 23 | Toast.objects.filter(key="bookmark_list_actions_hint").delete() 24 | 25 | 26 | class Migration(migrations.Migration): 27 | 28 | dependencies = [ 29 | ("bookmarks", "0028_userprofile_display_archive_bookmark_action_and_more"), 30 | ] 31 | 32 | operations = [ 33 | migrations.RunPython(forwards, reverse), 34 | ] 35 | -------------------------------------------------------------------------------- /bookmarks/migrations/0030_bookmarkasset.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-03-31 08:21 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("bookmarks", "0029_bookmark_list_actions_toast"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="BookmarkAsset", 16 | fields=[ 17 | ( 18 | "id", 19 | models.AutoField( 20 | auto_created=True, 21 | primary_key=True, 22 | serialize=False, 23 | verbose_name="ID", 24 | ), 25 | ), 26 | ("date_created", models.DateTimeField(auto_now_add=True)), 27 | ("file", models.CharField(blank=True, max_length=2048)), 28 | ("file_size", models.IntegerField(null=True)), 29 | ("asset_type", models.CharField(max_length=64)), 30 | ("content_type", models.CharField(max_length=128)), 31 | ("display_name", models.CharField(blank=True, max_length=2048)), 32 | ("status", models.CharField(max_length=64)), 33 | ("gzip", models.BooleanField(default=False)), 34 | ( 35 | "bookmark", 36 | models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | to="bookmarks.bookmark", 39 | ), 40 | ), 41 | ], 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /bookmarks/migrations/0031_userprofile_enable_automatic_html_snapshots.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-04-01 10:29 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0030_bookmarkasset"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="enable_automatic_html_snapshots", 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0032_html_snapshots_hint_toast.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.2 on 2024-04-01 12:17 2 | 3 | from django.db import migrations 4 | from django.contrib.auth import get_user_model 5 | 6 | from bookmarks.models import Toast 7 | 8 | User = get_user_model() 9 | 10 | 11 | def forwards(apps, schema_editor): 12 | 13 | for user in User.objects.all(): 14 | toast = Toast( 15 | key="html_snapshots_hint", 16 | message="This version adds a new feature for archiving snapshots of websites locally. To use it, you need to switch to a different Docker image. See the installation instructions on GitHub for details.", 17 | owner=user, 18 | ) 19 | toast.save() 20 | 21 | 22 | def reverse(apps, schema_editor): 23 | Toast.objects.filter(key="bookmark_list_actions_hint").delete() 24 | 25 | 26 | class Migration(migrations.Migration): 27 | 28 | dependencies = [ 29 | ("bookmarks", "0031_userprofile_enable_automatic_html_snapshots"), 30 | ] 31 | 32 | operations = [ 33 | migrations.RunPython(forwards, reverse), 34 | ] 35 | -------------------------------------------------------------------------------- /bookmarks/migrations/0033_userprofile_default_mark_unread.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-04-17 19:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0032_html_snapshots_hint_toast"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="default_mark_unread", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-05-10 07:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0033_userprofile_default_mark_unread"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="bookmark", 15 | name="preview_image_file", 16 | field=models.CharField(blank=True, max_length=512), 17 | ), 18 | migrations.AddField( 19 | model_name="userprofile", 20 | name="enable_preview_images", 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /bookmarks/migrations/0035_userprofile_tag_grouping.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-05-14 08:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0034_bookmark_preview_image_file_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="tag_grouping", 16 | field=models.CharField( 17 | choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")], 18 | default="alphabetical", 19 | max_length=12, 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /bookmarks/migrations/0036_userprofile_auto_tagging_rules.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.3 on 2024-05-17 07:09 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0035_userprofile_tag_grouping"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="auto_tagging_rules", 16 | field=models.TextField(blank=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0037_globalsettings.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-08-31 12:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0036_userprofile_auto_tagging_rules"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="GlobalSettings", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ( 26 | "landing_page", 27 | models.CharField( 28 | choices=[ 29 | ("login", "Login"), 30 | ("shared_bookmarks", "Shared Bookmarks"), 31 | ], 32 | default="login", 33 | max_length=50, 34 | ), 35 | ), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /bookmarks/migrations/0038_globalsettings_guest_profile_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-08-31 17:54 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("bookmarks", "0037_globalsettings"), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name="globalsettings", 18 | name="guest_profile_user", 19 | field=models.ForeignKey( 20 | blank=True, 21 | null=True, 22 | on_delete=django.db.models.deletion.SET_NULL, 23 | to=settings.AUTH_USER_MODEL, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /bookmarks/migrations/0039_globalsettings_enable_link_prefetch.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-14 07:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0038_globalsettings_guest_profile_user"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="globalsettings", 15 | name="enable_link_prefetch", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0040_userprofile_items_per_page_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-09-18 20:11 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("bookmarks", "0039_globalsettings_enable_link_prefetch"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="userprofile", 16 | name="items_per_page", 17 | field=models.IntegerField( 18 | default=30, validators=[django.core.validators.MinValueValidator(10)] 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name="userprofile", 23 | name="sticky_pagination", 24 | field=models.BooleanField(default=False), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /bookmarks/migrations/0041_merge_metadata.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-09-21 08:13 2 | 3 | from django.db import migrations 4 | from django.db.models import Q 5 | from django.db.models.expressions import RawSQL 6 | 7 | from bookmarks.models import Bookmark 8 | 9 | 10 | def forwards(apps, schema_editor): 11 | Bookmark.objects.filter( 12 | Q(title__isnull=True) | Q(title__exact=""), 13 | ).extra( 14 | where=["website_title IS NOT NULL"] 15 | ).update(title=RawSQL("website_title", ())) 16 | 17 | Bookmark.objects.filter( 18 | Q(description__isnull=True) | Q(description__exact=""), 19 | ).extra(where=["website_description IS NOT NULL"]).update( 20 | description=RawSQL("website_description", ()) 21 | ) 22 | 23 | 24 | def reverse(apps, schema_editor): 25 | pass 26 | 27 | 28 | class Migration(migrations.Migration): 29 | 30 | dependencies = [ 31 | ("bookmarks", "0040_userprofile_items_per_page_and_more"), 32 | ] 33 | 34 | operations = [ 35 | migrations.RunPython(forwards, reverse), 36 | ] 37 | -------------------------------------------------------------------------------- /bookmarks/migrations/0042_userprofile_custom_css_hash.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.1 on 2024-09-28 08:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0041_merge_metadata"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="custom_css_hash", 16 | field=models.CharField(blank=True, max_length=32), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0043_userprofile_collapse_side_panel.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.5 on 2025-02-02 09:35 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("bookmarks", "0042_userprofile_custom_css_hash"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="userprofile", 15 | name="collapse_side_panel", 16 | field=models.BooleanField(default=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /bookmarks/migrations/0044_bookmark_latest_snapshot.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.7 on 2025-03-22 12:28 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | from django.db.models import OuterRef, Subquery 6 | 7 | 8 | def forwards(apps, schema_editor): 9 | # Update the latest snapshot for each bookmark 10 | Bookmark = apps.get_model("bookmarks", "bookmark") 11 | BookmarkAsset = apps.get_model("bookmarks", "bookmarkasset") 12 | 13 | latest_snapshots = ( 14 | BookmarkAsset.objects.filter( 15 | bookmark=OuterRef("pk"), asset_type="snapshot", status="complete" 16 | ) 17 | .order_by("-date_created") 18 | .values("id")[:1] 19 | ) 20 | Bookmark.objects.update(latest_snapshot_id=Subquery(latest_snapshots)) 21 | 22 | 23 | def reverse(apps, schema_editor): 24 | pass 25 | 26 | 27 | class Migration(migrations.Migration): 28 | 29 | dependencies = [ 30 | ("bookmarks", "0043_userprofile_collapse_side_panel"), 31 | ] 32 | 33 | operations = [ 34 | migrations.AddField( 35 | model_name="bookmark", 36 | name="latest_snapshot", 37 | field=models.ForeignKey( 38 | blank=True, 39 | null=True, 40 | on_delete=django.db.models.deletion.SET_NULL, 41 | related_name="latest_snapshot", 42 | to="bookmarks.bookmarkasset", 43 | ), 44 | ), 45 | migrations.RunPython(forwards, reverse), 46 | ] 47 | -------------------------------------------------------------------------------- /bookmarks/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/migrations/__init__.py -------------------------------------------------------------------------------- /bookmarks/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/services/__init__.py -------------------------------------------------------------------------------- /bookmarks/services/exporter.py: -------------------------------------------------------------------------------- 1 | import html 2 | from typing import List 3 | 4 | from bookmarks.models import Bookmark 5 | 6 | BookmarkDocument = List[str] 7 | 8 | 9 | def export_netscape_html(bookmarks: List[Bookmark]): 10 | doc = [] 11 | append_header(doc) 12 | append_list_start(doc) 13 | [append_bookmark(doc, bookmark) for bookmark in bookmarks] 14 | append_list_end(doc) 15 | 16 | return "\n\r".join(doc) 17 | 18 | 19 | def append_header(doc: BookmarkDocument): 20 | doc.append("") 21 | doc.append('') 22 | doc.append("Bookmarks") 23 | doc.append("

Bookmarks

") 24 | 25 | 26 | def append_list_start(doc: BookmarkDocument): 27 | doc.append("

") 28 | 29 | 30 | def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark): 31 | url = bookmark.url 32 | title = html.escape(bookmark.resolved_title or "") 33 | desc = html.escape(bookmark.resolved_description or "") 34 | if bookmark.notes: 35 | desc += f"[linkding-notes]{html.escape(bookmark.notes)}[/linkding-notes]" 36 | tag_names = bookmark.tag_names 37 | if bookmark.is_archived: 38 | tag_names.append("linkding:bookmarks.archived") 39 | tags = ",".join(tag_names) 40 | toread = "1" if bookmark.unread else "0" 41 | private = "0" if bookmark.shared else "1" 42 | added = int(bookmark.date_added.timestamp()) 43 | modified = int(bookmark.date_modified.timestamp()) 44 | 45 | doc.append( 46 | f'

{title}' 47 | ) 48 | 49 | if desc: 50 | doc.append(f"
{desc}") 51 | 52 | 53 | def append_list_end(doc: BookmarkDocument): 54 | doc.append("

") 55 | -------------------------------------------------------------------------------- /bookmarks/services/monolith.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import shutil 3 | import subprocess 4 | import os 5 | 6 | from django.conf import settings 7 | 8 | 9 | class MonolithError(Exception): 10 | pass 11 | 12 | 13 | # Monolith isn't used at the moment, as the local snapshot implementation 14 | # switched to single-file after the prototype. Keeping this around in case 15 | # it turns out to be useful in the future. 16 | def create_snapshot(url: str, filepath: str): 17 | monolith_path = settings.LD_MONOLITH_PATH 18 | monolith_options = settings.LD_MONOLITH_OPTIONS 19 | temp_filepath = filepath + ".tmp" 20 | 21 | try: 22 | command = f"{monolith_path} '{url}' {monolith_options} -o {temp_filepath}" 23 | subprocess.run(command, check=True, shell=True) 24 | 25 | with open(temp_filepath, "rb") as raw_file, gzip.open( 26 | filepath, "wb" 27 | ) as gz_file: 28 | shutil.copyfileobj(raw_file, gz_file) 29 | 30 | os.remove(temp_filepath) 31 | except subprocess.CalledProcessError as error: 32 | raise MonolithError(f"Failed to create snapshot: {error.stderr}") 33 | -------------------------------------------------------------------------------- /bookmarks/services/singlefile.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shlex 4 | import signal 5 | import subprocess 6 | 7 | from django.conf import settings 8 | 9 | 10 | class SingleFileError(Exception): 11 | pass 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def create_snapshot(url: str, filepath: str): 18 | singlefile_path = settings.LD_SINGLEFILE_PATH 19 | 20 | # parse options to list of arguments 21 | ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS) 22 | custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS) 23 | # concat lists 24 | args = [singlefile_path] + ublock_options + custom_options + [url, filepath] 25 | try: 26 | # Use start_new_session=True to create a new process group 27 | process = subprocess.Popen(args, start_new_session=True) 28 | process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC) 29 | 30 | # check if the file was created 31 | if not os.path.exists(filepath): 32 | raise SingleFileError("Failed to create snapshot") 33 | except subprocess.TimeoutExpired: 34 | # First try to terminate properly 35 | try: 36 | logger.error( 37 | "Timeout expired while creating snapshot. Terminating process..." 38 | ) 39 | process.terminate() 40 | process.wait(timeout=20) 41 | raise SingleFileError("Timeout expired while creating snapshot") 42 | except subprocess.TimeoutExpired: 43 | # Kill the whole process group, which should also clean up any chromium 44 | # processes spawned by single-file 45 | logger.error("Timeout expired while terminating. Killing process...") 46 | os.killpg(os.getpgid(process.pid), signal.SIGTERM) 47 | raise SingleFileError("Timeout expired while creating snapshot") 48 | except subprocess.CalledProcessError as error: 49 | raise SingleFileError(f"Failed to create snapshot: {error.stderr}") 50 | -------------------------------------------------------------------------------- /bookmarks/services/tags.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import operator 3 | from typing import List 4 | 5 | from django.contrib.auth.models import User 6 | from django.utils import timezone 7 | 8 | from bookmarks.models import Tag 9 | from bookmarks.utils import unique 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def get_or_create_tags(tag_names: List[str], user: User): 15 | tags = [get_or_create_tag(tag_name, user) for tag_name in tag_names] 16 | return unique(tags, operator.attrgetter("id")) 17 | 18 | 19 | def get_or_create_tag(name: str, user: User): 20 | try: 21 | return Tag.objects.get(name__iexact=name, owner=user) 22 | except Tag.DoesNotExist: 23 | tag = Tag(name=name, owner=user) 24 | tag.date_added = timezone.now() 25 | tag.save() 26 | return tag 27 | except Tag.MultipleObjectsReturned: 28 | # Legacy databases might contain duplicate tags with different capitalization 29 | first_tag = Tag.objects.filter(name__iexact=name, owner=user).first() 30 | message = ( 31 | "Found multiple tags for the name '{0}' with different capitalization. " 32 | "Using the first tag with the name '{1}'. " 33 | "Since v.1.2 tags work case-insensitive, which means duplicates of the same name are not allowed anymore. " 34 | "To solve this error remove the duplicate tag in admin." 35 | ).format(name, first_tag.name) 36 | logger.error(message) 37 | return first_tag 38 | -------------------------------------------------------------------------------- /bookmarks/services/wayback.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django.utils import timezone 4 | 5 | 6 | def generate_fallback_webarchive_url( 7 | url: str, timestamp: datetime.datetime 8 | ) -> str | None: 9 | """ 10 | Generate a URL to the web archive for the given URL and timestamp. 11 | A snapshot for the specific timestamp might not exist, in which case the 12 | web archive will show the closest snapshot to the given timestamp. 13 | If there is no snapshot at all the URL will be invalid. 14 | """ 15 | if not url: 16 | return None 17 | if not timestamp: 18 | timestamp = timezone.now() 19 | 20 | return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}" 21 | -------------------------------------------------------------------------------- /bookmarks/settings/__init__.py: -------------------------------------------------------------------------------- 1 | # Use dev settings as default, use production if dev settings do not exist 2 | try: 3 | from .dev import * 4 | except: 5 | from .prod import * 6 | -------------------------------------------------------------------------------- /bookmarks/settings/custom.py: -------------------------------------------------------------------------------- 1 | # Placeholder, can be mounted in a Docker container with a custom settings 2 | 3 | # ALLOW_REGISTRATION = True 4 | -------------------------------------------------------------------------------- /bookmarks/settings/dev.py: -------------------------------------------------------------------------------- 1 | """ 2 | Development settings for linkding webapp 3 | """ 4 | 5 | # Start from development settings 6 | # noinspection PyUnresolvedReferences 7 | from .base import * 8 | 9 | # Turn on debug mode 10 | DEBUG = True 11 | 12 | # Enable debug toolbar 13 | # INSTALLED_APPS.append("debug_toolbar") 14 | # MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware") 15 | 16 | INTERNAL_IPS = [ 17 | "127.0.0.1", 18 | ] 19 | 20 | # Allow access through ngrok 21 | CSRF_TRUSTED_ORIGINS = ["https://*.ngrok-free.app"] 22 | 23 | STATICFILES_DIRS = [ 24 | # Resolve theme files from style source folder 25 | os.path.join(BASE_DIR, "bookmarks", "styles"), 26 | # Resolve downloaded files in dev environment 27 | os.path.join(BASE_DIR, "data", "favicons"), 28 | os.path.join(BASE_DIR, "data", "previews"), 29 | ] 30 | 31 | # Enable debug logging 32 | LOGGING = { 33 | "version": 1, 34 | "disable_existing_loggers": False, 35 | "formatters": { 36 | "simple": { 37 | "format": "{levelname} {asctime} {module}: {message}", 38 | "style": "{", 39 | }, 40 | }, 41 | "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "simple"}}, 42 | "root": { 43 | "handlers": ["console"], 44 | "level": "WARNING", 45 | }, 46 | "loggers": { 47 | "django.db.backends": { 48 | "level": "ERROR", # Set to DEBUG to log all SQL calls 49 | "handlers": ["console"], 50 | }, 51 | "bookmarks": { # Log importer debug output 52 | "level": "DEBUG", 53 | "handlers": ["console"], 54 | "propagate": False, 55 | }, 56 | "huey": { # Huey 57 | "level": "INFO", 58 | "handlers": ["console"], 59 | "propagate": False, 60 | }, 61 | }, 62 | } 63 | 64 | # Import custom settings 65 | # noinspection PyUnresolvedReferences 66 | from .custom import * 67 | -------------------------------------------------------------------------------- /bookmarks/settings/prod.py: -------------------------------------------------------------------------------- 1 | """ 2 | Production settings for linkding webapp 3 | """ 4 | 5 | # Start from development settings 6 | # noinspection PyUnresolvedReferences 7 | import os 8 | 9 | from django.core.management.utils import get_random_secret_key 10 | from .base import * 11 | 12 | # Turn of debug mode 13 | DEBUG = False 14 | 15 | # Try read secret key from file 16 | try: 17 | with open(os.path.join(BASE_DIR, "data", "secretkey.txt")) as f: 18 | SECRET_KEY = f.read().strip() 19 | except: 20 | SECRET_KEY = get_random_secret_key() 21 | 22 | # Set ALLOWED_HOSTS 23 | # By default look in the HOST_NAME environment variable, if that is not set then allow all hosts 24 | host_name = os.environ.get("HOST_NAME") 25 | if host_name: 26 | ALLOWED_HOSTS = [host_name] 27 | else: 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | # Logging 31 | LOGGING = { 32 | "version": 1, 33 | "disable_existing_loggers": False, 34 | "formatters": { 35 | "simple": { 36 | "format": "{asctime} {levelname} {message}", 37 | "style": "{", 38 | }, 39 | }, 40 | "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "simple"}}, 41 | "root": { 42 | "handlers": ["console"], 43 | "level": "WARN", 44 | }, 45 | "loggers": { 46 | "bookmarks": { 47 | "level": "INFO", 48 | "handlers": ["console"], 49 | "propagate": False, 50 | }, 51 | "huey": { 52 | "level": "INFO", 53 | "handlers": ["console"], 54 | "propagate": False, 55 | }, 56 | }, 57 | } 58 | 59 | # Import custom settings 60 | # noinspection PyUnresolvedReferences 61 | from .custom import * 62 | -------------------------------------------------------------------------------- /bookmarks/signals.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.backends.signals import connection_created 3 | from django.dispatch import receiver 4 | 5 | 6 | @receiver(connection_created) 7 | def extend_sqlite(connection=None, **kwargs): 8 | # Load ICU extension into Sqlite connection to support case-insensitive 9 | # comparisons with unicode characters 10 | if connection.vendor == "sqlite" and settings.USE_SQLITE_ICU_EXTENSION: 11 | connection.connection.enable_load_extension(True) 12 | connection.connection.load_extension( 13 | settings.SQLITE_ICU_EXTENSION_PATH.rstrip(".so") 14 | ) 15 | 16 | with connection.cursor() as cursor: 17 | # Load an ICU collation for case-insensitive ordering. 18 | # The first param can be a specific locale, it seems that not 19 | # providing one will use a default collation from the ICU project 20 | # that works reasonably for multiple languages 21 | cursor.execute("SELECT icu_load_collation('', 'ICU');") 22 | -------------------------------------------------------------------------------- /bookmarks/static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/apple-touch-icon.png -------------------------------------------------------------------------------- /bookmarks/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/favicon.ico -------------------------------------------------------------------------------- /bookmarks/static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bookmarks/static/linkding-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/linkding-screenshot.png -------------------------------------------------------------------------------- /bookmarks/static/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/logo-192.png -------------------------------------------------------------------------------- /bookmarks/static/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/logo-512.png -------------------------------------------------------------------------------- /bookmarks/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/logo.png -------------------------------------------------------------------------------- /bookmarks/static/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bookmarks/static/maskable-logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/maskable-logo-192.png -------------------------------------------------------------------------------- /bookmarks/static/maskable-logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/static/maskable-logo-512.png -------------------------------------------------------------------------------- /bookmarks/static/maskable-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bookmarks/static/preview-placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bookmarks/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /bookmarks/static/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bookmarks/styles/bookmark-form.css: -------------------------------------------------------------------------------- 1 | .bookmarks-form-page { 2 | main { 3 | max-width: 550px; 4 | margin: 0 auto; 5 | } 6 | } 7 | 8 | .bookmarks-form { 9 | & .has-icon-right > input, 10 | & .has-icon-right > textarea { 11 | padding-right: 30px; 12 | } 13 | 14 | & .form-icon.loading { 15 | visibility: hidden; 16 | } 17 | 18 | & .form-group .suffix-button { 19 | padding: 0; 20 | border: none; 21 | height: auto; 22 | font-size: var(--font-size-sm); 23 | } 24 | 25 | & .form-group .clear-button, 26 | & .form-group #refresh-button { 27 | display: none; 28 | } 29 | 30 | & .form-group input.modified, 31 | & .form-group textarea.modified { 32 | background: var(--primary-color-shade); 33 | } 34 | 35 | & .form-input-hint.bookmark-exists { 36 | display: none; 37 | color: var(--warning-color); 38 | } 39 | 40 | & .form-input-hint.auto-tags { 41 | display: none; 42 | color: var(--success-color); 43 | } 44 | 45 | & details.notes textarea { 46 | box-sizing: border-box; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /bookmarks/styles/components.css: -------------------------------------------------------------------------------- 1 | /* Shared components */ 2 | 3 | /* Section header component */ 4 | .section-header { 5 | border-bottom: solid 1px var(--secondary-border-color); 6 | display: flex; 7 | flex-wrap: wrap; 8 | column-gap: var(--unit-5); 9 | padding-bottom: var(--unit-2); 10 | margin-bottom: var(--unit-4); 11 | 12 | h1, 13 | h2, 14 | h3 { 15 | font-size: var(--font-size-lg); 16 | flex: 0 0 auto; 17 | line-height: var(--unit-9); 18 | margin: 0; 19 | } 20 | 21 | .header-controls { 22 | flex: 1 1 0; 23 | display: flex; 24 | } 25 | } 26 | 27 | @media (max-width: 600px) { 28 | .section-header { 29 | flex-direction: column; 30 | } 31 | } 32 | 33 | /* Confirm button component */ 34 | span.confirmation { 35 | display: flex; 36 | align-items: baseline; 37 | gap: var(--unit-1); 38 | color: var(--error-color) !important; 39 | 40 | svg { 41 | align-self: center; 42 | } 43 | 44 | .btn.btn-link { 45 | color: var(--error-color) !important; 46 | 47 | &:hover { 48 | text-decoration: underline; 49 | } 50 | } 51 | } 52 | 53 | /* Divider */ 54 | .divider { 55 | border-bottom: solid 1px var(--secondary-border-color); 56 | margin: var(--unit-5) 0; 57 | } 58 | 59 | /* Turbo progress bar */ 60 | .turbo-progress-bar { 61 | background-color: var(--primary-color); 62 | } 63 | -------------------------------------------------------------------------------- /bookmarks/styles/layout.css: -------------------------------------------------------------------------------- 1 | /* Main layout */ 2 | body { 3 | margin: 20px 10px; 4 | 5 | @media (min-width: 600px) { 6 | /* Horizontal offset accounts for checkboxes that show up in bulk edit mode */ 7 | margin: 20px 32px; 8 | } 9 | } 10 | 11 | header { 12 | margin-bottom: var(--unit-9); 13 | 14 | a.app-link:hover { 15 | text-decoration: none; 16 | } 17 | 18 | .app-logo { 19 | width: 28px; 20 | height: 28px; 21 | } 22 | 23 | .app-name { 24 | margin-left: var(--unit-3); 25 | font-size: var(--font-size-lg); 26 | font-weight: 500; 27 | line-height: 1.2; 28 | } 29 | } 30 | 31 | header .toasts { 32 | margin-bottom: 20px; 33 | 34 | .toast { 35 | margin-bottom: 0.4rem; 36 | } 37 | 38 | .toast a.btn-clear:visited { 39 | color: currentColor; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /bookmarks/styles/markdown.css: -------------------------------------------------------------------------------- 1 | .markdown { 2 | & p, 3 | & ul, 4 | & ol, 5 | & pre, 6 | & blockquote { 7 | margin: 0 0 var(--unit-2) 0; 8 | } 9 | 10 | & > *:first-child { 11 | margin-top: 0; 12 | } 13 | 14 | & > *:last-child { 15 | margin-bottom: 0; 16 | } 17 | 18 | & ul, 19 | & ol { 20 | margin-left: var(--unit-4); 21 | } 22 | 23 | & ul li, 24 | & ol li { 25 | margin-top: var(--unit-1); 26 | } 27 | 28 | & pre { 29 | padding: var(--unit-1) var(--unit-2); 30 | background-color: var(--code-bg-color); 31 | border-radius: var(--unit-1); 32 | overflow-x: auto; 33 | } 34 | 35 | & pre code { 36 | background: none; 37 | box-shadow: none; 38 | padding: 0; 39 | } 40 | 41 | & > pre:first-child:last-child { 42 | padding: 0; 43 | background: none; 44 | border-radius: 0; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /bookmarks/styles/reader-mode.css: -------------------------------------------------------------------------------- 1 | html.reader-mode { 2 | --font-size: 1rem; 3 | line-height: 1.6; 4 | 5 | body { 6 | margin: 3rem 2rem; 7 | } 8 | 9 | .container { 10 | max-width: 600px; 11 | } 12 | 13 | .byline { 14 | font-style: italic; 15 | font-size: 0.8rem; 16 | } 17 | 18 | .reading-time { 19 | font-size: 0.7rem; 20 | } 21 | 22 | img { 23 | max-width: 100%; 24 | height: auto; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /bookmarks/styles/responsive.css: -------------------------------------------------------------------------------- 1 | .show-sm, 2 | .show-md { 3 | display: none !important; 4 | } 5 | 6 | .width-25 { 7 | width: 25%; 8 | } 9 | 10 | .width-50 { 11 | width: 50%; 12 | } 13 | 14 | .width-75 { 15 | width: 75%; 16 | } 17 | 18 | .width-100 { 19 | width: 100%; 20 | } 21 | 22 | .container { 23 | margin-left: auto; 24 | margin-right: auto; 25 | width: 100%; 26 | max-width: var(--size-lg); 27 | } 28 | 29 | .grid { 30 | --grid-columns: 3; 31 | display: grid; 32 | grid-template-columns: repeat(var(--grid-columns), 1fr); 33 | grid-gap: var(--unit-4); 34 | } 35 | 36 | .grid > * { 37 | min-width: 0; 38 | } 39 | 40 | .columns-2 { 41 | --grid-columns: 2; 42 | } 43 | 44 | .gap-0 { 45 | gap: 0; 46 | } 47 | 48 | .col-1 { 49 | grid-column: span min(1, var(--grid-columns)); 50 | } 51 | 52 | .col-2 { 53 | grid-column: span min(2, var(--grid-columns)); 54 | } 55 | 56 | .col-3 { 57 | grid-column: span min(3, var(--grid-columns)); 58 | } 59 | 60 | @media (max-width: 840px) { 61 | .hide-md { 62 | display: none !important; 63 | } 64 | .show-md { 65 | display: block !important; 66 | } 67 | 68 | .width-md-25 { 69 | width: 25%; 70 | } 71 | .width-md-50 { 72 | width: 50%; 73 | } 74 | .width-md-75 { 75 | width: 75%; 76 | } 77 | .width-md-100 { 78 | width: 100%; 79 | } 80 | 81 | .columns-md-1 { 82 | --grid-columns: 1; 83 | } 84 | .columns-md-2 { 85 | --grid-columns: 2; 86 | } 87 | } 88 | 89 | @media (max-width: 600px) { 90 | .hide-sm { 91 | display: none !important; 92 | } 93 | .show-sm { 94 | display: block !important; 95 | } 96 | 97 | .width-sm-25 { 98 | width: 25%; 99 | } 100 | .width-sm-50 { 101 | width: 50%; 102 | } 103 | .width-sm-75 { 104 | width: 75%; 105 | } 106 | .width-sm-100 { 107 | width: 100%; 108 | } 109 | 110 | .columns-sm-1 { 111 | --grid-columns: 1; 112 | } 113 | .columns-sm-2 { 114 | --grid-columns: 2; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /bookmarks/styles/settings.css: -------------------------------------------------------------------------------- 1 | .settings-page { 2 | h1 { 3 | font-size: var(--font-size-xl); 4 | margin-bottom: var(--unit-6); 5 | } 6 | 7 | section { 8 | margin-bottom: var(--unit-10); 9 | 10 | h2 { 11 | font-size: var(--font-size-lg); 12 | margin-bottom: var(--unit-3); 13 | } 14 | } 15 | 16 | textarea.monospace { 17 | font-family: monospace; 18 | box-sizing: border-box; 19 | } 20 | 21 | .input-group > input[type="submit"] { 22 | height: auto; 23 | } 24 | 25 | section.about table { 26 | max-width: 400px; 27 | } 28 | 29 | & .form-group { 30 | margin-bottom: var(--unit-4); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /bookmarks/styles/theme-light.css: -------------------------------------------------------------------------------- 1 | @import "theme/variables.css"; 2 | @import "theme/_normalize.css"; 3 | @import "theme/base.css"; 4 | @import "theme/typography.css"; 5 | @import "theme/asian.css"; 6 | @import "theme/tables.css"; 7 | @import "theme/buttons.css"; 8 | @import "theme/forms.css"; 9 | @import "theme/code.css"; 10 | @import "theme/dropdowns.css"; 11 | @import "theme/menus.css"; 12 | @import "theme/badges.css"; 13 | @import "theme/empty.css"; 14 | @import "theme/modals.css"; 15 | @import "theme/pagination.css"; 16 | @import "theme/tabs.css"; 17 | @import "theme/toasts.css"; 18 | @import "theme/autocomplete.css"; 19 | @import "theme/animations.css"; 20 | @import "theme/utilities.css"; 21 | 22 | @import "responsive.css"; 23 | @import "layout.css"; 24 | @import "components.css"; 25 | @import "bookmark-details.css"; 26 | @import "bookmark-form.css"; 27 | @import "bookmark-page.css"; 28 | @import "markdown.css"; 29 | @import "reader-mode.css"; 30 | @import "settings.css"; 31 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 - 2020 Yan Zhu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/animations.css: -------------------------------------------------------------------------------- 1 | /* Animations */ 2 | @keyframes loading { 3 | 0% { 4 | transform: rotate(0deg); 5 | } 6 | 100% { 7 | transform: rotate(360deg); 8 | } 9 | } 10 | 11 | @keyframes slide-down { 12 | 0% { 13 | opacity: 0; 14 | transform: translateY(calc(-1 * var(--unit-8))); 15 | } 16 | 100% { 17 | opacity: 1; 18 | transform: translateY(0); 19 | } 20 | } 21 | 22 | @keyframes fade-in { 23 | 0% { 24 | opacity: 0; 25 | } 26 | 100% { 27 | opacity: 1; 28 | } 29 | } 30 | 31 | @keyframes fade-out { 32 | 0% { 33 | opacity: 1; 34 | } 35 | 100% { 36 | opacity: 0; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/asian.css: -------------------------------------------------------------------------------- 1 | /* Optimized for East Asian CJK */ 2 | html:lang(zh), 3 | html:lang(zh-Hans), 4 | .lang-zh, 5 | .lang-zh-hans { 6 | font-family: var(--cjk-zh-hans-font-family); 7 | } 8 | 9 | html:lang(zh-Hant), 10 | .lang-zh-hant { 11 | font-family: var(--cjk-zh-hant-font-family); 12 | } 13 | 14 | html:lang(ja), 15 | .lang-ja { 16 | font-family: var(--cjk-jp-font-family); 17 | } 18 | 19 | html:lang(ko), 20 | .lang-ko { 21 | font-family: var(--cjk-ko-font-family); 22 | } 23 | 24 | :lang(zh), 25 | :lang(ja), 26 | .lang-cjk { 27 | & ins, 28 | & u { 29 | border-bottom: var(--border-width) solid; 30 | text-decoration: none; 31 | } 32 | 33 | & del + del, 34 | & del + s, 35 | & ins + ins, 36 | & ins + u, 37 | & s + del, 38 | & s + s, 39 | & u + ins, 40 | & u + u { 41 | margin-left: 0.125em; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/autocomplete.css: -------------------------------------------------------------------------------- 1 | /* Autocomplete */ 2 | .form-autocomplete { 3 | position: relative; 4 | 5 | & .form-autocomplete-input { 6 | align-content: flex-start; 7 | display: flex; 8 | flex-wrap: wrap; 9 | height: auto; 10 | min-height: var(--unit-8); 11 | padding: var(--unit-h); 12 | background: var(--input-bg-color); 13 | 14 | &.is-focused { 15 | outline: var(--focus-outline); 16 | outline-offset: calc(var(--focus-outline-offset) * -1); 17 | } 18 | 19 | & .form-input { 20 | background: transparent; 21 | border-color: transparent; 22 | box-shadow: none; 23 | display: inline-block; 24 | flex: 1 0 auto; 25 | height: var(--unit-6); 26 | line-height: var(--unit-4); 27 | margin: var(--unit-h); 28 | width: auto; 29 | 30 | &:focus { 31 | outline: none; 32 | } 33 | } 34 | } 35 | 36 | & .menu { 37 | left: 0; 38 | position: absolute; 39 | top: 100%; 40 | width: 100%; 41 | 42 | & .menu-item.selected > a, 43 | & .menu-item > a:hover { 44 | background: var(--menu-item-hover-bg-color); 45 | color: var(--menu-item-hover-color); 46 | } 47 | 48 | & .group-item, 49 | & .group-item:hover { 50 | color: var(--tertiary-text-color); 51 | text-transform: uppercase; 52 | background: none; 53 | font-size: 0.6rem; 54 | font-weight: bold; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/badges.css: -------------------------------------------------------------------------------- 1 | /* Badges */ 2 | .badge { 3 | position: relative; 4 | white-space: nowrap; 5 | 6 | &[data-badge], 7 | &:not([data-badge]) { 8 | &::after { 9 | background: var(--primary-color); 10 | background-clip: padding-box; 11 | border-radius: 0.5rem; 12 | box-shadow: 0 0 0 1px var(--body-color); 13 | color: var(--contrast-text-color); 14 | content: attr(data-badge); 15 | display: inline-block; 16 | transform: translate(-0.05rem, -0.5rem); 17 | } 18 | } 19 | 20 | &[data-badge] { 21 | &::after { 22 | font-size: var(--font-size-sm); 23 | height: 0.9rem; 24 | line-height: 1; 25 | min-width: 0.9rem; 26 | padding: 0.1rem 0.2rem; 27 | text-align: center; 28 | white-space: nowrap; 29 | } 30 | } 31 | 32 | &:not([data-badge]), 33 | &[data-badge=""] { 34 | &::after { 35 | height: 6px; 36 | min-width: 6px; 37 | padding: 0; 38 | width: 6px; 39 | } 40 | } 41 | 42 | /* Badges for Buttons */ 43 | 44 | &.btn { 45 | &::after { 46 | position: absolute; 47 | top: 0; 48 | right: 0; 49 | transform: translate(50%, -50%); 50 | } 51 | } 52 | 53 | /* Badges for Avatars */ 54 | 55 | &.avatar { 56 | &::after { 57 | position: absolute; 58 | top: 14.64%; 59 | right: 14.64%; 60 | transform: translate(50%, -50%); 61 | z-index: var(--zindex-1); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/base.css: -------------------------------------------------------------------------------- 1 | /* Base */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: inherit; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | font-size: var(--html-font-size); 11 | line-height: var(--html-line-height); 12 | -webkit-tap-highlight-color: transparent; 13 | } 14 | 15 | /* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */ 16 | html { 17 | scrollbar-gutter: stable; 18 | } 19 | 20 | @media (pointer: coarse) { 21 | html { 22 | scrollbar-gutter: initial; 23 | } 24 | } 25 | 26 | body { 27 | background: var(--body-color); 28 | color: var(--text-color); 29 | font-family: var(--body-font-family); 30 | font-size: var(--font-size); 31 | overflow-x: hidden; 32 | text-rendering: optimizeLegibility; 33 | } 34 | 35 | a { 36 | color: var(--link-color); 37 | outline: none; 38 | text-decoration: none; 39 | } 40 | 41 | a:focus-visible { 42 | outline: var(--focus-outline); 43 | outline-offset: var(--focus-outline-offset); 44 | } 45 | 46 | a:focus, 47 | a:hover, 48 | a:active, 49 | a.active { 50 | text-decoration: underline; 51 | } 52 | 53 | summary { 54 | cursor: pointer; 55 | } 56 | 57 | summary:focus-visible { 58 | outline: var(--focus-outline); 59 | outline-offset: var(--focus-outline-offset); 60 | } 61 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/code.css: -------------------------------------------------------------------------------- 1 | /* Code */ 2 | :root { 3 | --code-bg-color: var(--body-color-contrast); 4 | --code-color: var(--text-color); 5 | } 6 | 7 | code { 8 | border-radius: var(--border-radius); 9 | line-height: 1.25; 10 | padding: 0.1rem 0.2rem; 11 | background: var(--code-bg-color); 12 | color: var(--code-color); 13 | font-size: 85%; 14 | } 15 | 16 | .code { 17 | border-radius: var(--border-radius); 18 | background: var(--code-bg-color); 19 | color: var(--text-color); 20 | position: relative; 21 | 22 | & code { 23 | color: inherit; 24 | display: block; 25 | line-height: 1.5; 26 | overflow-x: auto; 27 | padding: var(--unit-2); 28 | width: 100%; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/dropdowns.css: -------------------------------------------------------------------------------- 1 | /* Dropdown */ 2 | .dropdown { 3 | --dropdown-focus-display: block; 4 | 5 | display: inline-block; 6 | position: relative; 7 | 8 | .menu { 9 | animation: fade-in 0.15s ease 1; 10 | display: none; 11 | left: 0; 12 | max-height: 50vh; 13 | overflow-y: auto; 14 | position: absolute; 15 | top: 100%; 16 | } 17 | 18 | &.dropdown-right { 19 | .menu { 20 | left: auto; 21 | right: 0; 22 | } 23 | } 24 | 25 | &:focus-within .menu { 26 | /* Use custom CSS property to allow disabling opening on focus when using JS */ 27 | display: var(--dropdown-focus-display); 28 | } 29 | 30 | &.active .menu { 31 | /* Always show menu when class is added through JS */ 32 | display: block; 33 | } 34 | 35 | /* Fix dropdown-toggle border radius in button groups */ 36 | .btn-group { 37 | .dropdown-toggle:nth-last-child(2) { 38 | border-bottom-right-radius: var(--border-radius); 39 | border-top-right-radius: var(--border-radius); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/empty.css: -------------------------------------------------------------------------------- 1 | /* Empty states (or Blank slates) */ 2 | .empty { 3 | background: var(--body-color-contrast); 4 | border-radius: var(--border-radius); 5 | color: var(--secondary-text-color); 6 | text-align: center; 7 | padding: var(--unit-16) var(--unit-8); 8 | 9 | .empty-icon { 10 | margin-bottom: var(--layout-spacing-lg); 11 | } 12 | 13 | .empty-title, 14 | .empty-subtitle { 15 | margin: var(--layout-spacing) auto; 16 | } 17 | 18 | .empty-action { 19 | margin-top: var(--layout-spacing-lg); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/pagination.css: -------------------------------------------------------------------------------- 1 | /* Pagination */ 2 | .pagination { 3 | display: flex; 4 | list-style: none; 5 | margin: var(--unit-1) 0; 6 | padding: var(--unit-1) 0; 7 | 8 | & .page-item { 9 | margin: var(--unit-1) var(--unit-o); 10 | 11 | & span { 12 | display: inline-block; 13 | padding: var(--unit-1) var(--unit-1); 14 | } 15 | 16 | & a { 17 | border-radius: var(--border-radius); 18 | display: inline-block; 19 | padding: var(--unit-1) var(--unit-2); 20 | text-decoration: none; 21 | 22 | &:focus, 23 | &:hover { 24 | color: var(--primary-text-color); 25 | } 26 | } 27 | 28 | &.disabled { 29 | & a { 30 | cursor: default; 31 | opacity: 0.5; 32 | pointer-events: none; 33 | } 34 | } 35 | 36 | &.active { 37 | & a { 38 | background: var(--primary-color); 39 | color: var(--contrast-text-color); 40 | } 41 | } 42 | 43 | &.page-prev, 44 | &.page-next { 45 | flex: 1 0 50%; 46 | } 47 | 48 | &.page-next { 49 | text-align: right; 50 | } 51 | 52 | & .page-item-title { 53 | margin: 0; 54 | } 55 | 56 | & .page-item-subtitle { 57 | margin: 0; 58 | opacity: 0.5; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/tables.css: -------------------------------------------------------------------------------- 1 | /* Tables */ 2 | .table { 3 | border-collapse: collapse; 4 | border-spacing: 0; 5 | width: 100%; 6 | text-align: left; 7 | 8 | /* Scrollable tables */ 9 | 10 | &.table-scroll { 11 | display: block; 12 | overflow-x: auto; 13 | padding-bottom: 0.75rem; 14 | white-space: nowrap; 15 | } 16 | 17 | & td, 18 | & th { 19 | border-bottom: var(--border-width) solid var(--border-color); 20 | padding: var(--unit-3) var(--unit-2); 21 | } 22 | 23 | & th { 24 | border-bottom-width: var(--border-width-lg); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/tabs.css: -------------------------------------------------------------------------------- 1 | /* Tabs */ 2 | :root { 3 | --tab-color: var(--text-color); 4 | --tab-hover-color: var(--primary-text-color); 5 | --tab-active-color: var(--primary-text-color); 6 | --tab-highlight-color: var(--primary-color); 7 | } 8 | 9 | .tab { 10 | align-items: center; 11 | border-bottom: var(--border-width) solid var(--border-color); 12 | display: flex; 13 | flex-wrap: wrap; 14 | list-style: none; 15 | margin: var(--unit-1) 0 calc(var(--unit-1) - var(--border-width)) 0; 16 | 17 | & .tab-item { 18 | margin-top: 0; 19 | 20 | & a { 21 | border-bottom: var(--border-width-lg) solid transparent; 22 | color: var(--tab-color); 23 | display: block; 24 | margin: 0 var(--unit-2) 0 0; 25 | padding: var(--unit-2) var(--unit-1) 26 | calc(var(--unit-2) - var(--border-width-lg)) var(--unit-1); 27 | text-decoration: none; 28 | 29 | &:focus, 30 | &:hover { 31 | color: var(--tab-hover-color); 32 | } 33 | } 34 | 35 | &.active a, 36 | & a.active { 37 | border-bottom-color: var(--tab-highlight-color); 38 | color: var(--tab-active-color); 39 | } 40 | 41 | &.tab-action { 42 | flex: 1 0 auto; 43 | text-align: right; 44 | } 45 | 46 | & .btn-clear { 47 | margin-top: calc(-1 * var(--unit-1)); 48 | } 49 | } 50 | 51 | &.tab-block { 52 | & .tab-item { 53 | flex: 1 0 0; 54 | text-align: center; 55 | 56 | & a { 57 | margin: 0; 58 | } 59 | 60 | & .badge { 61 | &[data-badge]::after { 62 | position: absolute; 63 | right: var(--unit-h); 64 | top: var(--unit-h); 65 | transform: translate(0, 0); 66 | } 67 | } 68 | } 69 | } 70 | 71 | &:not(.tab-block) { 72 | & .badge { 73 | padding-right: 0; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/toasts.css: -------------------------------------------------------------------------------- 1 | /* Toasts */ 2 | .toast { 3 | background: var(--gray-600); 4 | border-radius: var(--border-radius); 5 | color: var(--contrast-text-color); 6 | display: block; 7 | padding: var(--layout-spacing); 8 | width: 100%; 9 | 10 | &.toast-primary { 11 | background: var(--primary-color); 12 | } 13 | 14 | &.toast-success { 15 | background: var(--success-color); 16 | } 17 | 18 | &.toast-warning { 19 | background: var(--warning-color); 20 | } 21 | 22 | &.toast-error { 23 | background: var(--error-color); 24 | } 25 | 26 | .btn-clear { 27 | margin: var(--unit-h); 28 | } 29 | 30 | p { 31 | &:last-child { 32 | margin-bottom: 0; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /bookmarks/styles/theme/typography.css: -------------------------------------------------------------------------------- 1 | /* Typography */ 2 | /* Headings */ 3 | h1, 4 | h2, 5 | h3, 6 | h4, 7 | h5, 8 | h6 { 9 | color: inherit; 10 | font-weight: 500; 11 | line-height: 1.2; 12 | margin-bottom: 0.5em; 13 | margin-top: 0; 14 | } 15 | .h1, 16 | .h2, 17 | .h3, 18 | .h4, 19 | .h5, 20 | .h6 { 21 | font-weight: 500; 22 | } 23 | h1, 24 | .h1 { 25 | font-size: 2rem; 26 | } 27 | h2, 28 | .h2 { 29 | font-size: 1.6rem; 30 | } 31 | h3, 32 | .h3 { 33 | font-size: 1.4rem; 34 | } 35 | h4, 36 | .h4 { 37 | font-size: 1.2rem; 38 | } 39 | h5, 40 | .h5 { 41 | font-size: 1rem; 42 | } 43 | h6, 44 | .h6 { 45 | font-size: 0.8rem; 46 | } 47 | 48 | /* Paragraphs */ 49 | p { 50 | margin: 0 0 var(--line-height); 51 | } 52 | 53 | /* Semantic text elements */ 54 | a, 55 | ins, 56 | u { 57 | text-decoration-skip-ink: auto; 58 | } 59 | 60 | abbr[title] { 61 | border-bottom: var(--border-width) dotted; 62 | cursor: help; 63 | text-decoration: none; 64 | } 65 | 66 | /* Blockquote */ 67 | blockquote { 68 | border-left: var(--border-width-lg) solid var(--border-color); 69 | margin-left: 0; 70 | padding: var(--unit-2) var(--unit-4); 71 | 72 | & p:last-child { 73 | margin-bottom: 0; 74 | } 75 | } 76 | 77 | /* Lists */ 78 | ul, 79 | ol { 80 | margin: var(--unit-4) 0 var(--unit-4) var(--unit-4); 81 | padding: 0; 82 | 83 | & ul, 84 | & ol { 85 | margin: var(--unit-4) 0 var(--unit-4) var(--unit-4); 86 | } 87 | 88 | & li { 89 | margin-top: var(--unit-2); 90 | } 91 | } 92 | 93 | ul { 94 | list-style: disc inside; 95 | 96 | & ul { 97 | list-style-type: circle; 98 | } 99 | } 100 | 101 | ol { 102 | list-style: decimal inside; 103 | 104 | & ol { 105 | list-style-type: lower-alpha; 106 | } 107 | } 108 | 109 | dl { 110 | & dt { 111 | font-weight: bold; 112 | } 113 | 114 | & dd { 115 | margin: var(--unit-1) 0 var(--unit-4) 0; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /bookmarks/tasks.py: -------------------------------------------------------------------------------- 1 | # Expose task modules to Huey Django extension 2 | import bookmarks.services.tasks 3 | -------------------------------------------------------------------------------- /bookmarks/templates/admin/background_tasks.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | 3 | {% block content %} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {% for task in tasks %} 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% endfor %} 22 | 23 |
IDNameArgsRetries
{{ task.id }}{{ task.name }}{{ task.args }}{{ task.retries }}
24 |

25 | {% if page.paginator.num_pages > 1 %} 26 | {% for page_number in page_range %} 27 | {% if page_number == page.number %} 28 | {{ page_number }} 29 | {% elif page_number == '…' %} 30 | 31 | {% else %} 32 | {{ page_number }} 33 | {% endif %} 34 | {% endfor %} 35 |   36 | {% endif %} 37 | {{ page.paginator.count }} tasks 38 |

39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/archive.html: -------------------------------------------------------------------------------- 1 | {% extends "bookmarks/layout.html" %} 2 | {% load static %} 3 | {% load shared %} 4 | {% load bookmarks %} 5 | 6 | {% block content %} 7 |
9 | 10 | {# Bookmark list #} 11 |
12 |
13 |

Archived bookmarks

14 |
15 | {% bookmark_search bookmark_list.search mode='archived' %} 16 | {% include 'bookmarks/bulk_edit/toggle.html' %} 17 | 18 |
19 |
20 | 21 |
24 | {% csrf_token %} 25 | {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_archive' %} 26 | 27 |
28 | {% include 'bookmarks/bookmark_list.html' %} 29 |
30 |
31 |
32 | 33 | {# Tag cloud #} 34 |
35 |
36 |
37 |

Tags

38 |
39 |
40 | {% include 'bookmarks/tag_cloud.html' %} 41 |
42 |
43 |
44 |
45 | {% endblock %} 46 | 47 | {% block overlays %} 48 | {# Bookmark details #} 49 | 50 | {% if details %} 51 | {% include 'bookmarks/details/modal.html' %} 52 | {% endif %} 53 | 54 | {% endblock %} 55 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/bookmarklet.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | var bookmarkUrl = window.location; 3 | var applicationUrl = '{{ application_url }}'; 4 | 5 | applicationUrl += '?url=' + encodeURIComponent(bookmarkUrl); 6 | applicationUrl += '&auto_close'; 7 | 8 | window.open(applicationUrl); 9 | })(); 10 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/bulk_edit/bar.html: -------------------------------------------------------------------------------- 1 | {% load shared %} 2 | {% htmlmin %} 3 |
4 |
5 | 9 | 27 |
28 | 29 |
30 | 33 | 34 | 39 |
40 |
41 | {% endhtmlmin %} 42 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/bulk_edit/toggle.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/close.html: -------------------------------------------------------------------------------- 1 | {% extends "bookmarks/layout.html" %} 2 | 3 | {% block content %} 4 | 7 |

You can now close this window.

8 | {% endblock %} 9 | 10 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/details/modal.html: -------------------------------------------------------------------------------- 1 | 44 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'bookmarks/layout.html' %} 2 | 3 | {% block head %} 4 | {% with page_title="Edit bookmark - Linkding" %} 5 | {{ block.super }} 6 | {% endwith %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |

Edit bookmark

14 |
15 |
17 | {% include 'bookmarks/form.html' %} 18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/empty_bookmarks.html: -------------------------------------------------------------------------------- 1 |
2 |

You have no bookmarks yet

3 |

4 | You can get started by adding bookmarks, 5 | importing your existing bookmarks or configuring the 6 | browser extension or the bookmarklet. 8 |

9 |
10 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/index.html: -------------------------------------------------------------------------------- 1 | {% extends "bookmarks/layout.html" %} 2 | {% load static %} 3 | {% load shared %} 4 | {% load bookmarks %} 5 | 6 | {% block title %}Bookmarks - Linkding{% endblock %} 7 | 8 | {% block content %} 9 |
11 | 12 | {# Bookmark list #} 13 |
14 |
15 |

Bookmarks

16 |
17 | {% bookmark_search bookmark_list.search %} 18 | {% include 'bookmarks/bulk_edit/toggle.html' %} 19 | 20 |
21 |
22 | 23 |
26 | {% csrf_token %} 27 | {% include 'bookmarks/bulk_edit/bar.html' with disable_actions='bulk_unarchive' %} 28 | 29 |
30 | {% include 'bookmarks/bookmark_list.html' %} 31 |
32 |
33 |
34 | 35 | {# Tag cloud #} 36 |
37 |
38 |
39 |

Tags

40 |
41 |
42 | {% include 'bookmarks/tag_cloud.html' %} 43 |
44 |
45 |
46 |
47 | {% endblock %} 48 | 49 | {% block overlays %} 50 | {# Bookmark details #} 51 | 52 | {% if details %} 53 | {% include 'bookmarks/details/modal.html' %} 54 | {% endif %} 55 | 56 | {% endblock %} -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/new.html: -------------------------------------------------------------------------------- 1 | {% extends 'bookmarks/layout.html' %} 2 | 3 | {% block head %} 4 | {% with page_title="New bookmark - Linkding" %} 5 | {{ block.super }} 6 | {% endwith %} 7 | {% endblock %} 8 | 9 | {% block content %} 10 |
11 |
12 |
13 |

New bookmark

14 |
15 |
16 | {% include 'bookmarks/form.html' %} 17 |
18 |
19 |
20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/pagination.html: -------------------------------------------------------------------------------- 1 | {% load shared %} 2 | 3 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/shared.html: -------------------------------------------------------------------------------- 1 | {% extends "bookmarks/layout.html" %} 2 | {% load static %} 3 | {% load shared %} 4 | {% load bookmarks %} 5 | 6 | {% block content %} 7 |
9 | 10 | {# Bookmark list #} 11 |
12 |
13 |

Shared bookmarks

14 |
15 | {% bookmark_search bookmark_list.search mode='shared' %} 16 | 17 |
18 |
19 | 20 |
23 | {% csrf_token %} 24 |
25 | {% include 'bookmarks/bookmark_list.html' %} 26 |
27 |
28 |
29 | 30 | {# Filters #} 31 |
32 |
33 |
34 |

User

35 |
36 |
37 | {% user_select bookmark_list.search users %} 38 |
39 |
40 |
41 |
42 |
43 |

Tags

44 |
45 |
46 | {% include 'bookmarks/tag_cloud.html' %} 47 |
48 |
49 |
50 |
51 | {% endblock %} 52 | 53 | {% block overlays %} 54 | {# Bookmark details #} 55 | 56 | {% if details %} 57 | {% include 'bookmarks/details/modal.html' %} 58 | {% endif %} 59 | 60 | {% endblock %} 61 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/tag_cloud.html: -------------------------------------------------------------------------------- 1 | {% load shared %} 2 | {% htmlmin %} 3 |
4 | {% if tag_cloud.has_selected_tags %} 5 |

6 | {% for tag in tag_cloud.selected_tags %} 7 | 9 | -{{ tag.name }} 10 | 11 | {% endfor %} 12 |

13 | {% endif %} 14 |
15 | {% for group in tag_cloud.groups %} 16 |

17 | {% for tag in group.tags %} 18 | {# Highlight first char of first tag in group #} 19 | {% if forloop.counter == 1 %} 20 | 22 | {{ tag.name|first_char }}{{ tag.name|remaining_chars:1 }} 24 | 25 | {% else %} 26 | {# Render remaining tags normally #} 27 | 29 | {{ tag.name }} 30 | 31 | {% endif %} 32 | {% endfor %} 33 |

34 | {% endfor %} 35 |
36 |
37 | {% endhtmlmin %} 38 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/updates/bookmark_view_stream.html: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 21 | 22 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/updates/details-modal-frame.html: -------------------------------------------------------------------------------- 1 | 2 | {% include 'bookmarks/head.html' %} 3 | 4 | 5 | {% if details %} 6 | {% include 'bookmarks/details/modal.html' %} 7 | {% endif %} 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/user_select.html: -------------------------------------------------------------------------------- 1 | {% load widget_tweaks %} 2 | 3 |
4 | {% for hidden_field in form.hidden_fields %} 5 | {{ hidden_field }} 6 | {% endfor %} 7 |
8 |
9 | {% render_field form.user class+="form-select" ld-auto-submit="" %} 10 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /bookmarks/templates/django_registration/registration_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'bookmarks/layout.html' %} 2 | {% load widget_tweaks %} 3 | 4 | {% block head %} 5 | {% with page_title="Registration complete - Linkding" %} 6 | {{ block.super }} 7 | {% endwith %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 |

Registration complete

14 |
15 |

16 | You can now use the application. 17 |

18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /bookmarks/templates/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | Linkding 3 | Linkding 4 | UTF-8 5 | {{base_url}}static/favicon.ico 6 | 7 | 8 | -------------------------------------------------------------------------------- /bookmarks/templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'bookmarks/layout.html' %} 2 | {% load widget_tweaks %} 3 | 4 | {% block head %} 5 | {% with page_title="Login - Linkding" %} 6 | {{ block.super }} 7 | {% endwith %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 |

Login

14 |
15 |
16 | {% csrf_token %} 17 | {% if form.errors %} 18 |
19 |

Your username and password didn't match. Please try again.

20 |
21 | {% endif %} 22 |
23 | 24 | {{ form.username|add_class:'form-input'|attr:'placeholder: ' }} 25 |
26 |
27 | 28 | {{ form.password|add_class:'form-input'|attr:'placeholder: ' }} 29 |
30 | 31 |
32 |
33 | 34 | 35 | {% if enable_oidc %} 36 | Login with OIDC 37 | {% endif %} 38 | {% if allow_registration %} 39 | Register 40 | {% endif %} 41 |
42 |
43 |
44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /bookmarks/templates/registration/password_change_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'bookmarks/layout.html' %} 2 | {% load widget_tweaks %} 3 | 4 | {% block head %} 5 | {% with page_title="Password changed - Linkding" %} 6 | {{ block.super }} 7 | {% endwith %} 8 | {% endblock %} 9 | 10 | {% block content %} 11 |
12 |
13 |

Password Changed

14 |
15 |

16 | Your password was changed successfully. 17 |

18 |
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /bookmarks/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/templatetags/__init__.py -------------------------------------------------------------------------------- /bookmarks/templatetags/bookmarks.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from django import template 4 | 5 | from bookmarks.models import ( 6 | BookmarkSearch, 7 | BookmarkSearchForm, 8 | User, 9 | ) 10 | 11 | register = template.Library() 12 | 13 | 14 | @register.inclusion_tag( 15 | "bookmarks/search.html", name="bookmark_search", takes_context=True 16 | ) 17 | def bookmark_search(context, search: BookmarkSearch, mode: str = ""): 18 | search_form = BookmarkSearchForm(search, editable_fields=["q"]) 19 | 20 | if mode == "shared": 21 | preferences_form = BookmarkSearchForm(search, editable_fields=["sort"]) 22 | else: 23 | preferences_form = BookmarkSearchForm( 24 | search, editable_fields=["sort", "shared", "unread"] 25 | ) 26 | return { 27 | "request": context["request"], 28 | "search": search, 29 | "search_form": search_form, 30 | "preferences_form": preferences_form, 31 | "mode": mode, 32 | } 33 | 34 | 35 | @register.inclusion_tag( 36 | "bookmarks/user_select.html", name="user_select", takes_context=True 37 | ) 38 | def user_select(context, search: BookmarkSearch, users: List[User]): 39 | sorted_users = sorted(users, key=lambda x: str.lower(x.username)) 40 | form = BookmarkSearchForm(search, editable_fields=["user"], users=sorted_users) 41 | return { 42 | "search": search, 43 | "users": sorted_users, 44 | "form": form, 45 | } 46 | -------------------------------------------------------------------------------- /bookmarks/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/tests/__init__.py -------------------------------------------------------------------------------- /bookmarks/tests/resources/invalid_import_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/tests/resources/invalid_import_file.png -------------------------------------------------------------------------------- /bookmarks/tests/resources/simple_valid_import_file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bookmarks 6 | 7 |

Bookmarks

8 | 9 |

10 | 11 |

test title 1 12 |
test description 1 13 | 14 |
test title 2 15 |
test description 2 16 | 17 |
test title 3 18 |
test description 3 19 | 20 |

-------------------------------------------------------------------------------- /bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bookmarks 6 | 7 |

Bookmarks

8 | 9 |

10 | 11 |

test title 1 12 |
test description 1 13 | 14 |
test title 2 15 |
test description 2 16 | 17 |
test title 3 18 |
test description 3 19 | 20 |

-------------------------------------------------------------------------------- /bookmarks/tests/test_app_options.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import os 3 | from unittest import mock 4 | 5 | from django.test import TestCase 6 | 7 | 8 | class AppOptionsTestCase(TestCase): 9 | def setUp(self) -> None: 10 | self.settings_module = importlib.import_module("bookmarks.settings.base") 11 | 12 | def test_empty_csrf_trusted_origins(self): 13 | module = importlib.reload(self.settings_module) 14 | 15 | self.assertFalse(hasattr(module, "CSRF_TRUSTED_ORIGINS")) 16 | 17 | @mock.patch.dict( 18 | os.environ, {"LD_CSRF_TRUSTED_ORIGINS": "https://linkding.example.com"} 19 | ) 20 | def test_single_csrf_trusted_origin(self): 21 | module = importlib.reload(self.settings_module) 22 | 23 | self.assertTrue(hasattr(module, "CSRF_TRUSTED_ORIGINS")) 24 | self.assertCountEqual( 25 | module.CSRF_TRUSTED_ORIGINS, ["https://linkding.example.com"] 26 | ) 27 | 28 | @mock.patch.dict( 29 | os.environ, 30 | { 31 | "LD_CSRF_TRUSTED_ORIGINS": "https://linkding.example.com,http://linkding.example.com" 32 | }, 33 | ) 34 | def test_multiple_csrf_trusted_origin(self): 35 | module = importlib.reload(self.settings_module) 36 | 37 | self.assertTrue(hasattr(module, "CSRF_TRUSTED_ORIGINS")) 38 | self.assertCountEqual( 39 | module.CSRF_TRUSTED_ORIGINS, 40 | ["https://linkding.example.com", "http://linkding.example.com"], 41 | ) 42 | -------------------------------------------------------------------------------- /bookmarks/tests/test_auth_api.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from rest_framework import status 3 | from rest_framework.authtoken.models import Token 4 | 5 | from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin 6 | 7 | 8 | class AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin): 9 | 10 | def authenticate(self, keyword): 11 | self.api_token = Token.objects.get_or_create( 12 | user=self.get_or_create_test_user() 13 | )[0] 14 | self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}") 15 | 16 | def test_auth_with_token_keyword(self): 17 | self.authenticate("Token") 18 | 19 | url = reverse("linkding:user-profile") 20 | self.get(url, expected_status_code=status.HTTP_200_OK) 21 | 22 | def test_auth_with_bearer_keyword(self): 23 | self.authenticate("Bearer") 24 | 25 | url = reverse("linkding:user-profile") 26 | self.get(url, expected_status_code=status.HTTP_200_OK) 27 | 28 | def test_auth_with_unknown_keyword(self): 29 | self.authenticate("Key") 30 | 31 | url = reverse("linkding:user-profile") 32 | self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED) 33 | -------------------------------------------------------------------------------- /bookmarks/tests/test_bookmarks_model.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from bookmarks.models import Bookmark 4 | 5 | 6 | class BookmarkTestCase(TestCase): 7 | 8 | def test_bookmark_resolved_title(self): 9 | bookmark = Bookmark( 10 | title="Custom title", 11 | url="https://example.com", 12 | ) 13 | self.assertEqual(bookmark.resolved_title, "Custom title") 14 | 15 | bookmark = Bookmark(title="", url="https://example.com") 16 | self.assertEqual(bookmark.resolved_title, "https://example.com") 17 | -------------------------------------------------------------------------------- /bookmarks/tests/test_create_initial_superuser_command.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | from django.test import TestCase 5 | 6 | from bookmarks.models import User 7 | from bookmarks.management.commands.create_initial_superuser import Command 8 | 9 | 10 | class TestCreateInitialSuperuserCommand(TestCase): 11 | 12 | @mock.patch.dict( 13 | os.environ, 14 | {"LD_SUPERUSER_NAME": "john", "LD_SUPERUSER_PASSWORD": "password123"}, 15 | ) 16 | def test_create_with_password(self): 17 | Command().handle() 18 | 19 | self.assertEqual(1, User.objects.count()) 20 | 21 | user = User.objects.first() 22 | self.assertEqual("john", user.username) 23 | self.assertTrue(user.has_usable_password()) 24 | self.assertTrue(user.check_password("password123")) 25 | 26 | @mock.patch.dict(os.environ, {"LD_SUPERUSER_NAME": "john"}) 27 | def test_create_without_password(self): 28 | Command().handle() 29 | 30 | self.assertEqual(1, User.objects.count()) 31 | 32 | user = User.objects.first() 33 | self.assertEqual("john", user.username) 34 | self.assertFalse(user.has_usable_password()) 35 | 36 | def test_create_without_options(self): 37 | Command().handle() 38 | 39 | self.assertEqual(0, User.objects.count()) 40 | 41 | @mock.patch.dict( 42 | os.environ, 43 | {"LD_SUPERUSER_NAME": "john", "LD_SUPERUSER_PASSWORD": "password123"}, 44 | ) 45 | def test_create_multiple_times(self): 46 | Command().handle() 47 | Command().handle() 48 | Command().handle() 49 | 50 | self.assertEqual(1, User.objects.count()) 51 | -------------------------------------------------------------------------------- /bookmarks/tests/test_custom_css_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from bookmarks.tests.helpers import BookmarkFactoryMixin 5 | 6 | 7 | class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin): 8 | def setUp(self) -> None: 9 | user = self.get_or_create_test_user() 10 | self.client.force_login(user) 11 | 12 | def test_with_empty_css(self): 13 | response = self.client.get(reverse("linkding:custom_css")) 14 | self.assertEqual(response.status_code, 200) 15 | self.assertEqual(response["Content-Type"], "text/css") 16 | self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000") 17 | self.assertEqual(response.content.decode(), "") 18 | 19 | def test_with_custom_css(self): 20 | css = "body { background-color: red; }" 21 | self.user.profile.custom_css = css 22 | self.user.profile.save() 23 | 24 | response = self.client.get(reverse("linkding:custom_css")) 25 | self.assertEqual(response.status_code, 200) 26 | self.assertEqual(response["Content-Type"], "text/css") 27 | self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000") 28 | self.assertEqual(response.content.decode(), css) 29 | -------------------------------------------------------------------------------- /bookmarks/tests/test_exporter_performance.py: -------------------------------------------------------------------------------- 1 | from django.db import connections 2 | from django.db.utils import DEFAULT_DB_ALIAS 3 | from django.test import TestCase 4 | from django.test.utils import CaptureQueriesContext 5 | from django.urls import reverse 6 | 7 | from bookmarks.tests.helpers import BookmarkFactoryMixin 8 | 9 | 10 | class ExporterPerformanceTestCase(TestCase, BookmarkFactoryMixin): 11 | 12 | def setUp(self) -> None: 13 | user = self.get_or_create_test_user() 14 | self.client.force_login(user) 15 | 16 | def get_connection(self): 17 | return connections[DEFAULT_DB_ALIAS] 18 | 19 | def test_export_max_queries(self): 20 | # set up some bookmarks with associated tags 21 | num_initial_bookmarks = 10 22 | for index in range(num_initial_bookmarks): 23 | self.setup_bookmark(tags=[self.setup_tag()]) 24 | 25 | # capture number of queries 26 | context = CaptureQueriesContext(self.get_connection()) 27 | with context: 28 | self.client.get(reverse("linkding:settings.export"), follow=True) 29 | 30 | number_of_queries = context.final_queries 31 | 32 | self.assertLess(number_of_queries, num_initial_bookmarks) 33 | -------------------------------------------------------------------------------- /bookmarks/tests/test_feeds_performance.py: -------------------------------------------------------------------------------- 1 | from django.db import connections 2 | from django.db.utils import DEFAULT_DB_ALIAS 3 | from django.test import TestCase 4 | from django.test.utils import CaptureQueriesContext 5 | from django.urls import reverse 6 | 7 | from bookmarks.models import FeedToken, GlobalSettings 8 | from bookmarks.tests.helpers import BookmarkFactoryMixin 9 | 10 | 11 | class FeedsPerformanceTestCase(TestCase, BookmarkFactoryMixin): 12 | 13 | def setUp(self) -> None: 14 | user = self.get_or_create_test_user() 15 | self.client.force_login(user) 16 | self.token = FeedToken.objects.get_or_create(user=user)[0] 17 | 18 | # create global settings 19 | GlobalSettings.get() 20 | 21 | def get_connection(self): 22 | return connections[DEFAULT_DB_ALIAS] 23 | 24 | def test_all_max_queries(self): 25 | # set up some bookmarks with associated tags 26 | num_initial_bookmarks = 10 27 | for _ in range(num_initial_bookmarks): 28 | self.setup_bookmark(tags=[self.setup_tag()]) 29 | 30 | # capture number of queries 31 | context = CaptureQueriesContext(self.get_connection()) 32 | with context: 33 | feed_url = reverse("linkding:feeds.all", args=[self.token.key]) 34 | self.client.get(feed_url) 35 | 36 | number_of_queries = context.final_queries 37 | 38 | self.assertLess(number_of_queries, num_initial_bookmarks) 39 | -------------------------------------------------------------------------------- /bookmarks/tests/test_health_view.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | from django.db import connections 4 | from django.test import TestCase 5 | 6 | from bookmarks.views.settings import app_version 7 | 8 | 9 | class HealthViewTestCase(TestCase): 10 | 11 | def test_health_healthy(self): 12 | response = self.client.get("/health") 13 | 14 | self.assertEqual(response.status_code, 200) 15 | 16 | response_body = response.json() 17 | expected_body = {"version": app_version, "status": "healthy"} 18 | self.assertDictEqual(response_body, expected_body) 19 | 20 | def test_health_unhealhty(self): 21 | with patch.object( 22 | connections["default"], "ensure_connection" 23 | ) as mock_ensure_connection: 24 | mock_ensure_connection.side_effect = Exception("Connection error") 25 | 26 | response = self.client.get("/health") 27 | 28 | self.assertEqual(response.status_code, 500) 29 | 30 | response_body = response.json() 31 | expected_body = {"version": app_version, "status": "unhealthy"} 32 | self.assertDictEqual(response_body, expected_body) 33 | -------------------------------------------------------------------------------- /bookmarks/tests/test_linkding_middleware.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from bookmarks.models import UserProfile, GlobalSettings 5 | from bookmarks.tests.helpers import BookmarkFactoryMixin 6 | from bookmarks.middlewares import standard_profile 7 | 8 | 9 | class LinkdingMiddlewareTestCase(TestCase, BookmarkFactoryMixin): 10 | def test_unauthenticated_user_should_use_standard_profile_by_default(self): 11 | response = self.client.get(reverse("login")) 12 | 13 | self.assertEqual(standard_profile, response.wsgi_request.user_profile) 14 | 15 | def test_unauthenticated_user_should_use_custom_configured_profile(self): 16 | guest_user = self.setup_user() 17 | guest_user_profile = guest_user.profile 18 | guest_user_profile.theme = UserProfile.THEME_DARK 19 | guest_user_profile.save() 20 | 21 | global_settings = GlobalSettings.get() 22 | global_settings.guest_profile_user = guest_user 23 | global_settings.save() 24 | 25 | response = self.client.get(reverse("login")) 26 | 27 | self.assertEqual(guest_user_profile, response.wsgi_request.user_profile) 28 | 29 | def test_authenticated_user_should_use_own_profile(self): 30 | guest_user = self.setup_user() 31 | guest_user_profile = guest_user.profile 32 | guest_user_profile.theme = UserProfile.THEME_DARK 33 | guest_user_profile.save() 34 | 35 | global_settings = GlobalSettings.get() 36 | global_settings.guest_profile_user = guest_user 37 | global_settings.save() 38 | 39 | user = self.get_or_create_test_user() 40 | user_profile = user.profile 41 | user_profile.theme = UserProfile.THEME_LIGHT 42 | user_profile.save() 43 | self.client.force_login(user) 44 | 45 | response = self.client.get(reverse("login"), follow=True) 46 | 47 | self.assertEqual(user_profile, response.wsgi_request.user_profile) 48 | -------------------------------------------------------------------------------- /bookmarks/tests/test_login_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | from django.urls import path, include 3 | 4 | from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin 5 | from bookmarks.urls import urlpatterns as base_patterns 6 | 7 | # Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled 8 | urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls"))] 9 | 10 | 11 | @override_settings(ROOT_URLCONF=__name__) 12 | class LoginViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): 13 | 14 | def test_failed_login_should_return_401(self): 15 | response = self.client.post("/login/", {"username": "test", "password": "test"}) 16 | self.assertEqual(response.status_code, 401) 17 | 18 | def test_successful_login_should_redirect(self): 19 | user = self.setup_user(name="test") 20 | user.set_password("test") 21 | user.save() 22 | 23 | response = self.client.post("/login/", {"username": "test", "password": "test"}) 24 | self.assertEqual(response.status_code, 302) 25 | 26 | def test_should_not_show_oidc_login_by_default(self): 27 | response = self.client.get("/login/") 28 | soup = self.make_soup(response.content.decode()) 29 | 30 | oidc_login_link = soup.find("a", string="Login with OIDC") 31 | 32 | self.assertIsNone(oidc_login_link) 33 | 34 | @override_settings(LD_ENABLE_OIDC=True) 35 | def test_should_show_oidc_login_when_enabled(self): 36 | response = self.client.get("/login/") 37 | soup = self.make_soup(response.content.decode()) 38 | 39 | oidc_login_link = soup.find("a", string="Login with OIDC") 40 | 41 | self.assertIsNotNone(oidc_login_link) 42 | 43 | # should have turbo disabled 44 | self.assertEqual("false", oidc_login_link.get("data-turbo")) 45 | -------------------------------------------------------------------------------- /bookmarks/tests/test_monolith_service.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import os 3 | from unittest import mock 4 | import subprocess 5 | 6 | from django.test import TestCase 7 | 8 | from bookmarks.services import monolith 9 | 10 | 11 | class MonolithServiceTestCase(TestCase): 12 | html_content = "

Hello, World!

" 13 | html_filepath = "temp.html.gz" 14 | temp_html_filepath = "temp.html.gz.tmp" 15 | 16 | def tearDown(self): 17 | if os.path.exists(self.html_filepath): 18 | os.remove(self.html_filepath) 19 | if os.path.exists(self.temp_html_filepath): 20 | os.remove(self.temp_html_filepath) 21 | 22 | def create_test_file(self, *args, **kwargs): 23 | with open(self.temp_html_filepath, "w") as file: 24 | file.write(self.html_content) 25 | 26 | def test_create_snapshot(self): 27 | with mock.patch("subprocess.run") as mock_run: 28 | mock_run.side_effect = self.create_test_file 29 | 30 | monolith.create_snapshot("http://example.com", self.html_filepath) 31 | 32 | self.assertTrue(os.path.exists(self.html_filepath)) 33 | self.assertFalse(os.path.exists(self.temp_html_filepath)) 34 | 35 | with gzip.open(self.html_filepath, "rt") as file: 36 | content = file.read() 37 | self.assertEqual(content, self.html_content) 38 | 39 | def test_create_snapshot_failure(self): 40 | with mock.patch("subprocess.run") as mock_run: 41 | mock_run.side_effect = subprocess.CalledProcessError(1, "command") 42 | 43 | with self.assertRaises(monolith.MonolithError): 44 | monolith.create_snapshot("http://example.com", self.html_filepath) 45 | -------------------------------------------------------------------------------- /bookmarks/tests/test_opensearch_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | 5 | class OpenSearchViewTestCase(TestCase): 6 | 7 | def test_opensearch_configuration(self): 8 | response = self.client.get(reverse("linkding:opensearch")) 9 | self.assertEqual(response.status_code, 200) 10 | self.assertEqual( 11 | response["content-type"], "application/opensearchdescription+xml" 12 | ) 13 | 14 | base_url = "http://testserver" 15 | expected_content = f""" 16 | 17 | Linkding 18 | Linkding 19 | UTF-8 20 | {base_url}/static/favicon.ico 21 | 22 | 23 | """ 24 | content = response.content.decode() 25 | self.assertXMLEqual(content, expected_content) 26 | -------------------------------------------------------------------------------- /bookmarks/tests/test_root_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.urls import reverse 3 | 4 | from bookmarks.models import GlobalSettings 5 | from bookmarks.tests.helpers import BookmarkFactoryMixin 6 | 7 | 8 | class RootViewTestCase(TestCase, BookmarkFactoryMixin): 9 | def test_unauthenticated_user_redirect_to_login_by_default(self): 10 | response = self.client.get(reverse("linkding:root")) 11 | self.assertRedirects(response, reverse("login")) 12 | 13 | def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings( 14 | self, 15 | ): 16 | settings = GlobalSettings.get() 17 | settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS 18 | settings.save() 19 | 20 | response = self.client.get(reverse("linkding:root")) 21 | self.assertRedirects(response, reverse("linkding:bookmarks.shared")) 22 | 23 | def test_authenticated_user_always_redirected_to_bookmarks(self): 24 | self.client.force_login(self.get_or_create_test_user()) 25 | 26 | response = self.client.get(reverse("linkding:root")) 27 | self.assertRedirects(response, reverse("linkding:bookmarks.index")) 28 | 29 | settings = GlobalSettings.get() 30 | settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS 31 | settings.save() 32 | 33 | response = self.client.get(reverse("linkding:root")) 34 | self.assertRedirects(response, reverse("linkding:bookmarks.index")) 35 | 36 | settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN 37 | settings.save() 38 | 39 | response = self.client.get(reverse("linkding:root")) 40 | self.assertRedirects(response, reverse("linkding:bookmarks.index")) 41 | -------------------------------------------------------------------------------- /bookmarks/tests/test_tags_model.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from bookmarks.models import parse_tag_string 4 | 5 | 6 | class TagTestCase(TestCase): 7 | 8 | def test_parse_tag_string_returns_list_of_tag_names(self): 9 | self.assertCountEqual( 10 | parse_tag_string("book, movie, album"), ["book", "movie", "album"] 11 | ) 12 | 13 | def test_parse_tag_string_respects_separator(self): 14 | self.assertCountEqual( 15 | parse_tag_string("book movie album", " "), ["book", "movie", "album"] 16 | ) 17 | 18 | def test_parse_tag_string_orders_tag_names_alphabetically(self): 19 | self.assertListEqual( 20 | parse_tag_string("book,movie,album"), ["album", "book", "movie"] 21 | ) 22 | self.assertListEqual( 23 | parse_tag_string("Book,movie,album"), ["album", "Book", "movie"] 24 | ) 25 | 26 | def test_parse_tag_string_handles_whitespace(self): 27 | self.assertCountEqual( 28 | parse_tag_string("\t book, movie \t, album, \n\r"), 29 | ["album", "book", "movie"], 30 | ) 31 | 32 | def test_parse_tag_string_handles_invalid_input(self): 33 | self.assertListEqual(parse_tag_string(None), []) 34 | self.assertListEqual(parse_tag_string(""), []) 35 | 36 | def test_parse_tag_string_deduplicates_tag_names(self): 37 | self.assertEqual(len(parse_tag_string("book,book,Book,BOOK")), 1) 38 | 39 | def test_parse_tag_string_handles_duplicate_separators(self): 40 | self.assertCountEqual( 41 | parse_tag_string("book,,movie,,,album"), ["album", "book", "movie"] 42 | ) 43 | 44 | def test_parse_tag_string_replaces_whitespace_within_names(self): 45 | self.assertCountEqual( 46 | parse_tag_string("travel guide, book recommendations"), 47 | ["travel-guide", "book-recommendations"], 48 | ) 49 | -------------------------------------------------------------------------------- /bookmarks/tests/test_user_profile_model.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import User 2 | from django.test import TestCase 3 | 4 | from bookmarks.models import UserProfile 5 | 6 | 7 | class UserProfileTestCase(TestCase): 8 | 9 | def test_create_user_should_init_profile(self): 10 | user = User.objects.create_user("testuser", "test@example.com", "password123") 11 | profile = UserProfile.objects.all().filter(user_id=user.id).first() 12 | self.assertIsNotNone(profile) 13 | 14 | def test_bookmark_sharing_is_disabled_by_default(self): 15 | user = User.objects.create_user("testuser", "test@example.com", "password123") 16 | profile = UserProfile.objects.all().filter(user_id=user.id).first() 17 | self.assertFalse(profile.enable_sharing) 18 | -------------------------------------------------------------------------------- /bookmarks/tests_e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/bookmarks/tests_e2e/__init__.py -------------------------------------------------------------------------------- /bookmarks/tests_e2e/e2e_test_bookmark_item.py: -------------------------------------------------------------------------------- 1 | from unittest import skip 2 | 3 | from django.urls import reverse 4 | from playwright.sync_api import sync_playwright, expect 5 | 6 | from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase 7 | 8 | 9 | class BookmarkItemE2ETestCase(LinkdingE2ETestCase): 10 | @skip("Fails in CI, needs investigation") 11 | def test_toggle_notes_should_show_hide_notes(self): 12 | bookmark = self.setup_bookmark(notes="Test notes") 13 | 14 | with sync_playwright() as p: 15 | page = self.open(reverse("linkding:bookmarks.index"), p) 16 | 17 | notes = self.locate_bookmark(bookmark.title).locator(".notes") 18 | expect(notes).to_be_hidden() 19 | 20 | toggle_notes = page.locator("li button.toggle-notes") 21 | toggle_notes.click() 22 | expect(notes).to_be_visible() 23 | 24 | toggle_notes.click() 25 | expect(notes).to_be_hidden() 26 | -------------------------------------------------------------------------------- /bookmarks/tests_e2e/e2e_test_collapse_side_panel.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from playwright.sync_api import sync_playwright, expect 3 | 4 | from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase 5 | 6 | 7 | class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase): 8 | 9 | def setUp(self) -> None: 10 | super().setUp() 11 | 12 | def assertSidePanelIsVisible(self): 13 | expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible() 14 | expect( 15 | self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]") 16 | ).not_to_be_visible() 17 | 18 | def assertSidePanelIsHidden(self): 19 | expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible() 20 | expect( 21 | self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]") 22 | ).to_be_visible() 23 | 24 | def test_side_panel_should_be_visible_by_default(self): 25 | with sync_playwright() as p: 26 | self.open(reverse("linkding:bookmarks.index"), p) 27 | self.assertSidePanelIsVisible() 28 | 29 | self.page.goto( 30 | self.live_server_url + reverse("linkding:bookmarks.archived") 31 | ) 32 | self.assertSidePanelIsVisible() 33 | 34 | self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared")) 35 | self.assertSidePanelIsVisible() 36 | 37 | def test_side_panel_should_be_hidden_when_collapsed(self): 38 | user = self.get_or_create_test_user() 39 | user.profile.collapse_side_panel = True 40 | user.profile.save() 41 | 42 | with sync_playwright() as p: 43 | self.open(reverse("linkding:bookmarks.index"), p) 44 | self.assertSidePanelIsHidden() 45 | 46 | self.page.goto( 47 | self.live_server_url + reverse("linkding:bookmarks.archived") 48 | ) 49 | self.assertSidePanelIsHidden() 50 | 51 | self.page.goto(self.live_server_url + reverse("linkding:bookmarks.shared")) 52 | self.assertSidePanelIsHidden() 53 | -------------------------------------------------------------------------------- /bookmarks/tests_e2e/e2e_test_global_shortcuts.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from playwright.sync_api import sync_playwright, expect 3 | 4 | from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase 5 | 6 | 7 | class GlobalShortcutsE2ETestCase(LinkdingE2ETestCase): 8 | def test_focus_search(self): 9 | with sync_playwright() as p: 10 | browser = self.setup_browser(p) 11 | page = browser.new_page() 12 | page.goto(self.live_server_url + reverse("linkding:bookmarks.index")) 13 | 14 | page.press("body", "s") 15 | 16 | expect(page.get_by_placeholder("Search for words or #tags")).to_be_focused() 17 | 18 | browser.close() 19 | 20 | def test_add_bookmark(self): 21 | with sync_playwright() as p: 22 | browser = self.setup_browser(p) 23 | page = browser.new_page() 24 | page.goto(self.live_server_url + reverse("linkding:bookmarks.index")) 25 | 26 | page.press("body", "n") 27 | 28 | expect(page).to_have_url( 29 | self.live_server_url + reverse("linkding:bookmarks.new") 30 | ) 31 | 32 | browser.close() 33 | -------------------------------------------------------------------------------- /bookmarks/type_defs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Stuff in here is only used for type hints 3 | """ 4 | 5 | from django import http 6 | from django.contrib.auth.models import AnonymousUser 7 | 8 | from bookmarks.models import GlobalSettings, UserProfile, User 9 | 10 | 11 | class HttpRequest(http.HttpRequest): 12 | global_settings: GlobalSettings 13 | user_profile: UserProfile 14 | user: User | AnonymousUser 15 | -------------------------------------------------------------------------------- /bookmarks/validators.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.core import validators 3 | 4 | 5 | class BookmarkURLValidator(validators.URLValidator): 6 | """ 7 | Extends default Django URLValidator and cancels validation if it is disabled in settings. 8 | This allows to switch URL validation on/off dynamically which helps with testing 9 | """ 10 | 11 | def __call__(self, value): 12 | if settings.LD_DISABLE_URL_VALIDATION: 13 | return 14 | 15 | super().__call__(value) 16 | -------------------------------------------------------------------------------- /bookmarks/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .assets import * 2 | from .auth import * 3 | from .bookmarks import * 4 | from .settings import * 5 | from .toasts import * 6 | from .health import health 7 | from .manifest import manifest 8 | from .custom_css import custom_css 9 | from .root import root 10 | from .opensearch import opensearch 11 | -------------------------------------------------------------------------------- /bookmarks/views/access.py: -------------------------------------------------------------------------------- 1 | from django.http import Http404 2 | 3 | from bookmarks.models import Bookmark, BookmarkAsset, Toast 4 | from bookmarks.type_defs import HttpRequest 5 | 6 | 7 | def bookmark_read(request: HttpRequest, bookmark_id: int | str): 8 | try: 9 | bookmark = Bookmark.objects.get(pk=int(bookmark_id)) 10 | except Bookmark.DoesNotExist: 11 | raise Http404("Bookmark does not exist") 12 | 13 | is_owner = bookmark.owner == request.user 14 | is_shared = ( 15 | request.user.is_authenticated 16 | and bookmark.shared 17 | and bookmark.owner.profile.enable_sharing 18 | ) 19 | is_public_shared = bookmark.shared and bookmark.owner.profile.enable_public_sharing 20 | if not is_owner and not is_shared and not is_public_shared: 21 | raise Http404("Bookmark does not exist") 22 | if request.method == "POST" and not is_owner: 23 | raise Http404("Bookmark does not exist") 24 | 25 | return bookmark 26 | 27 | 28 | def bookmark_write(request: HttpRequest, bookmark_id: int | str): 29 | try: 30 | return Bookmark.objects.get(pk=bookmark_id, owner=request.user) 31 | except Bookmark.DoesNotExist: 32 | raise Http404("Bookmark does not exist") 33 | 34 | 35 | def asset_read(request: HttpRequest, asset_id: int | str): 36 | try: 37 | asset = BookmarkAsset.objects.get(pk=asset_id) 38 | except BookmarkAsset.DoesNotExist: 39 | raise Http404("Asset does not exist") 40 | 41 | bookmark_read(request, asset.bookmark_id) 42 | return asset 43 | 44 | 45 | def asset_write(request: HttpRequest, asset_id: int | str): 46 | try: 47 | return BookmarkAsset.objects.get(pk=asset_id, bookmark__owner=request.user) 48 | except BookmarkAsset.DoesNotExist: 49 | raise Http404("Asset does not exist") 50 | 51 | 52 | def toast_write(request: HttpRequest, toast_id: int | str): 53 | try: 54 | return Toast.objects.get(pk=toast_id, owner=request.user) 55 | except Toast.DoesNotExist: 56 | raise Http404("Toast does not exist") 57 | -------------------------------------------------------------------------------- /bookmarks/views/assets.py: -------------------------------------------------------------------------------- 1 | import gzip 2 | import os 3 | 4 | from django.conf import settings 5 | from django.http import ( 6 | HttpResponse, 7 | Http404, 8 | ) 9 | from django.shortcuts import render 10 | 11 | from bookmarks.views import access 12 | 13 | 14 | def _get_asset_content(asset): 15 | filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file) 16 | 17 | if not os.path.exists(filepath): 18 | raise Http404("Asset file does not exist") 19 | 20 | if asset.gzip: 21 | with gzip.open(filepath, "rb") as f: 22 | content = f.read() 23 | else: 24 | with open(filepath, "rb") as f: 25 | content = f.read() 26 | 27 | return content 28 | 29 | 30 | def view(request, asset_id: int): 31 | asset = access.asset_read(request, asset_id) 32 | content = _get_asset_content(asset) 33 | 34 | return HttpResponse(content, content_type=asset.content_type) 35 | 36 | 37 | def read(request, asset_id: int): 38 | asset = access.asset_read(request, asset_id) 39 | content = _get_asset_content(asset) 40 | content = content.decode("utf-8") 41 | 42 | return render( 43 | request, 44 | "bookmarks/read.html", 45 | { 46 | "content": content, 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /bookmarks/views/auth.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.contrib.auth import views as auth_views 3 | 4 | 5 | class LinkdingLoginView(auth_views.LoginView): 6 | """ 7 | Custom login view to lazily add additional context data 8 | Allows to override settings in tests 9 | """ 10 | 11 | def get_context_data(self, **kwargs): 12 | context = super().get_context_data(**kwargs) 13 | 14 | context["allow_registration"] = settings.ALLOW_REGISTRATION 15 | context["enable_oidc"] = settings.LD_ENABLE_OIDC 16 | return context 17 | 18 | def form_invalid(self, form): 19 | """ 20 | Return 401 status code on failed login. Should allow integrating with 21 | tools like Fail2Ban. Also, Hotwired Turbo requires a non 2xx status 22 | code to handle failed form submissions. 23 | """ 24 | response = super().form_invalid(form) 25 | response.status_code = 401 26 | return response 27 | 28 | 29 | class LinkdingPasswordChangeView(auth_views.PasswordChangeView): 30 | def form_invalid(self, form): 31 | """ 32 | Hotwired Turbo requires a non 2xx status code to handle failed form 33 | submissions. 34 | """ 35 | response = super().form_invalid(form) 36 | response.status_code = 422 37 | return response 38 | -------------------------------------------------------------------------------- /bookmarks/views/custom_css.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | custom_css_cache_max_age = 2592000 # 30 days 4 | 5 | 6 | def custom_css(request): 7 | css = request.user_profile.custom_css 8 | response = HttpResponse(css, content_type="text/css") 9 | response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}" 10 | return response 11 | -------------------------------------------------------------------------------- /bookmarks/views/health.py: -------------------------------------------------------------------------------- 1 | from django.db import connections 2 | from django.http import JsonResponse 3 | 4 | from bookmarks.views.settings import app_version 5 | 6 | 7 | def health(request): 8 | code = 200 9 | response = {"version": app_version, "status": "healthy"} 10 | 11 | try: 12 | connections["default"].ensure_connection() 13 | except Exception: 14 | response["status"] = "unhealthy" 15 | code = 500 16 | 17 | return JsonResponse(response, status=code) 18 | -------------------------------------------------------------------------------- /bookmarks/views/opensearch.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.shortcuts import render 3 | 4 | 5 | def opensearch(request): 6 | base_url = request.build_absolute_uri(reverse("linkding:root")) 7 | bookmarks_url = request.build_absolute_uri(reverse("linkding:bookmarks.index")) 8 | 9 | return render( 10 | request, 11 | "opensearch.xml", 12 | { 13 | "base_url": base_url, 14 | "bookmarks_url": bookmarks_url, 15 | }, 16 | content_type="application/opensearchdescription+xml", 17 | status=200, 18 | ) 19 | -------------------------------------------------------------------------------- /bookmarks/views/partials.py: -------------------------------------------------------------------------------- 1 | from bookmarks.views import contexts, turbo 2 | 3 | 4 | def render_bookmark_update(request, bookmark_list, tag_cloud, details): 5 | return turbo.stream( 6 | request, 7 | "bookmarks/updates/bookmark_view_stream.html", 8 | { 9 | "bookmark_list": bookmark_list, 10 | "tag_cloud": tag_cloud, 11 | "details": details, 12 | }, 13 | ) 14 | 15 | 16 | def active_bookmark_update(request): 17 | bookmark_list = contexts.ActiveBookmarkListContext(request) 18 | tag_cloud = contexts.ActiveTagCloudContext(request) 19 | details = contexts.get_details_context( 20 | request, contexts.ActiveBookmarkDetailsContext 21 | ) 22 | return render_bookmark_update(request, bookmark_list, tag_cloud, details) 23 | 24 | 25 | def archived_bookmark_update(request): 26 | bookmark_list = contexts.ArchivedBookmarkListContext(request) 27 | tag_cloud = contexts.ArchivedTagCloudContext(request) 28 | details = contexts.get_details_context( 29 | request, contexts.ArchivedBookmarkDetailsContext 30 | ) 31 | return render_bookmark_update(request, bookmark_list, tag_cloud, details) 32 | 33 | 34 | def shared_bookmark_update(request): 35 | bookmark_list = contexts.SharedBookmarkListContext(request) 36 | tag_cloud = contexts.SharedTagCloudContext(request) 37 | details = contexts.get_details_context( 38 | request, contexts.SharedBookmarkDetailsContext 39 | ) 40 | return render_bookmark_update(request, bookmark_list, tag_cloud, details) 41 | -------------------------------------------------------------------------------- /bookmarks/views/root.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponseRedirect 2 | from django.urls import reverse 3 | 4 | from bookmarks.models import GlobalSettings 5 | 6 | 7 | def root(request): 8 | # Redirect unauthenticated users to the configured landing page 9 | if not request.user.is_authenticated: 10 | settings = request.global_settings 11 | 12 | if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS: 13 | return HttpResponseRedirect(reverse("linkding:bookmarks.shared")) 14 | else: 15 | return HttpResponseRedirect(reverse("login")) 16 | 17 | # Redirect authenticated users to the bookmarks page 18 | return HttpResponseRedirect(reverse("linkding:bookmarks.index")) 19 | -------------------------------------------------------------------------------- /bookmarks/views/toasts.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http import HttpResponseRedirect 3 | from django.urls import reverse 4 | 5 | from bookmarks.utils import get_safe_return_url 6 | from bookmarks.views import access 7 | 8 | 9 | @login_required 10 | def acknowledge(request): 11 | toast = access.toast_write(request, request.POST["toast"]) 12 | toast.acknowledged = True 13 | toast.save() 14 | 15 | return_url = get_safe_return_url( 16 | request.GET.get("return_url"), reverse("linkding:bookmarks.index") 17 | ) 18 | return HttpResponseRedirect(return_url) 19 | -------------------------------------------------------------------------------- /bookmarks/views/turbo.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest, HttpResponse 2 | from django.shortcuts import render as django_render 3 | 4 | 5 | def accept(request: HttpRequest): 6 | is_turbo_request = "text/vnd.turbo-stream.html" in request.headers.get("Accept", "") 7 | disable_turbo = request.POST.get("disable_turbo", "false") == "true" 8 | 9 | return is_turbo_request and not disable_turbo 10 | 11 | 12 | def is_frame(request: HttpRequest, frame: str) -> bool: 13 | return request.headers.get("Turbo-Frame") == frame 14 | 15 | 16 | def stream(request: HttpRequest, template_name: str, context: dict) -> HttpResponse: 17 | response = django_render(request, template_name, context) 18 | response["Content-Type"] = "text/vnd.turbo-stream.html" 19 | return response 20 | -------------------------------------------------------------------------------- /bookmarks/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for linkding. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | """ 6 | 7 | import os 8 | 9 | from django.core.wsgi import get_wsgi_application 10 | 11 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bookmarks.settings") 12 | 13 | application = get_wsgi_application() 14 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Bootstrap script that gets executed in new Docker containers 3 | 4 | LD_SERVER_HOST="${LD_SERVER_HOST:-[::]}" 5 | LD_SERVER_PORT="${LD_SERVER_PORT:-9090}" 6 | 7 | # Create data folder if it does not exist 8 | mkdir -p data 9 | # Create favicon folder if it does not exist 10 | mkdir -p data/favicons 11 | # Create previews folder if it does not exist 12 | mkdir -p data/previews 13 | # Create assets folder if it does not exist 14 | mkdir -p data/assets 15 | 16 | # Generate secret key file if it does not exist 17 | python manage.py generate_secret_key 18 | # Run database migration 19 | python manage.py migrate 20 | # Enable WAL journal mode for SQLite databases 21 | python manage.py enable_wal 22 | # Create initial superuser if defined in options / environment variables 23 | python manage.py create_initial_superuser 24 | # Migrate legacy background tasks to Huey 25 | python manage.py migrate_tasks 26 | 27 | # Ensure folders are owned by the right user 28 | chown -R www-data: /etc/linkding/data 29 | 30 | # Start background task processor using supervisord, unless explicitly disabled 31 | if [ "$LD_DISABLE_BACKGROUND_TASKS" != "True" ]; then 32 | supervisord -c supervisord.conf 33 | fi 34 | 35 | # Start uwsgi server 36 | exec uwsgi --http $LD_SERVER_HOST:$LD_SERVER_PORT uwsgi.ini 37 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | linkding: 3 | container_name: "${LD_CONTAINER_NAME:-linkding}" 4 | image: sissbruecker/linkding:latest 5 | ports: 6 | - "${LD_HOST_PORT:-9090}:9090" 7 | volumes: 8 | - "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data" 9 | env_file: 10 | - .env 11 | restart: unless-stopped -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | # generated types 4 | .astro/ 5 | 6 | # dependencies 7 | node_modules/ 8 | 9 | # logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | pnpm-debug.log* 14 | 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /docs/astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig } from "astro/config"; 3 | import starlight from "@astrojs/starlight"; 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [ 8 | starlight({ 9 | title: "linkding", 10 | logo: { 11 | src: "./src/assets/logo.svg", 12 | }, 13 | social: [ 14 | { 15 | icon: "github", 16 | label: "GitHub", 17 | href: "https://github.com/sissbruecker/linkding", 18 | }, 19 | ], 20 | sidebar: [ 21 | { 22 | label: "Getting Started", 23 | items: [ 24 | { label: "Installation", slug: "installation" }, 25 | { label: "Options", slug: "options" }, 26 | { label: "Managed Hosting", slug: "managed-hosting" }, 27 | { label: "Browser Extension", slug: "browser-extension" }, 28 | ], 29 | }, 30 | { 31 | label: "Guides", 32 | items: [ 33 | { label: "Backups", slug: "backups" }, 34 | { label: "Archiving", slug: "archiving" }, 35 | { label: "Auto Tagging", slug: "auto-tagging" }, 36 | { label: "Keyboard Shortcuts", slug: "shortcuts" }, 37 | { label: "How To", slug: "how-to" }, 38 | { label: "Troubleshooting", slug: "troubleshooting" }, 39 | { label: "Admin", slug: "admin" }, 40 | { label: "REST API", slug: "api" }, 41 | ], 42 | }, 43 | { 44 | label: "Resources", 45 | items: [ 46 | { label: "Community", slug: "community" }, 47 | { label: "Acknowledgements", slug: "acknowledgements" }, 48 | ], 49 | }, 50 | ], 51 | customCss: ["./src/styles/custom.css"], 52 | editLink: { 53 | baseUrl: "https://github.com/sissbruecker/linkding/edit/master/docs/", 54 | }, 55 | }), 56 | ], 57 | }); 58 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkding-docs", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "rm -rf dist && astro check && astro build", 9 | "preview": "astro preview", 10 | "astro": "astro" 11 | }, 12 | "dependencies": { 13 | "@astrojs/check": "^0.9.3", 14 | "@astrojs/starlight": "^0.34.3", 15 | "astro": "^5.7.13", 16 | "sharp": "^0.32.5", 17 | "typescript": "^5.6.2" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /docs/public/donations/2023-10-11-internet-archive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/donations/2023-10-11-internet-archive.png -------------------------------------------------------------------------------- /docs/public/donations/2024-10-04-django.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/donations/2024-10-04-django.png -------------------------------------------------------------------------------- /docs/public/donations/2024-10-04-internet-archive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/donations/2024-10-04-internet-archive.png -------------------------------------------------------------------------------- /docs/public/donations/2024-10-04-noyb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/donations/2024-10-04-noyb.png -------------------------------------------------------------------------------- /docs/public/donations/2024-10-04-singlefile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/donations/2024-10-04-singlefile.png -------------------------------------------------------------------------------- /docs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/public/linkding-screenshot-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/linkding-screenshot-dark.png -------------------------------------------------------------------------------- /docs/public/linkding-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/public/linkding-screenshot.png -------------------------------------------------------------------------------- /docs/src/assets/Add To Linkding.shortcut: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sissbruecker/linkding/bb796c9bdb9c31f26302cb575c8703d6b3bd8286/docs/src/assets/Add To Linkding.shortcut -------------------------------------------------------------------------------- /docs/src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/src/components/Card.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import {icons} from './icons'; 3 | interface Props { 4 | icon: keyof typeof icons; 5 | title: string; 6 | } 7 | 8 | const {icon, title} = Astro.props; 9 | --- 10 | 11 |
12 |

13 | {icon && } 14 | 15 |

16 |
17 | 18 |
19 |
20 | 21 | 46 | -------------------------------------------------------------------------------- /docs/src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection } from 'astro:content'; 2 | import { docsSchema } from '@astrojs/starlight/schema'; 3 | 4 | export const collections = { 5 | docs: defineCollection({ schema: docsSchema() }), 6 | }; 7 | -------------------------------------------------------------------------------- /docs/src/content/docs/acknowledgements.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Acknowledgements" 3 | description: "Acknowledgements and thanks to contributors and sponsors" 4 | --- 5 | 6 | ## PikaPods 7 | 8 | [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. Thanks to PikaPods for making this possible. 9 | 10 | See the table below for a list of donations. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 38 | 39 | 40 |
SourceDescriptionAmountDonated to
PikaPodsLinkding hosting June 2022 - September 2023$163.50Internet Archive
PikaPodsLinkding hosting October 2023 - September 2024$287.04 33 | Django
34 | SingleFile
35 | Internet Archive
36 | NOYB 37 |
41 | 42 | ## JetBrains 43 | 44 | JetBrains has previously provided an open-source license of [IntelliJ IDEA](https://www.jetbrains.com/idea/) for the development of linkding. Thanks! 45 | -------------------------------------------------------------------------------- /docs/src/content/docs/browser-extension.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Browser Extension" 3 | description: "Browser extension for linkding" 4 | --- 5 | 6 | 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: 7 | - [Mozilla Addon Store](https://addons.mozilla.org/firefox/addon/linkding-extension/) 8 | - [Chrome Web Store](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe) 9 | 10 | The extension is open-source as well, and can be found [here](https://github.com/sissbruecker/linkding-extension). 11 | -------------------------------------------------------------------------------- /docs/src/content/docs/managed-hosting.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Managed Hosting" 3 | description: "Managed hosting options for linkding" 4 | --- 5 | 6 | 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. 7 | 8 | ## Fully Managed 9 | 10 | The following services provide fully managed hosting for linkding, including automatic updates and backups: 11 | 12 | - [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](/acknowledgements#pikapods)) 13 | 14 | ## Self-Managed 15 | 16 | The following guides provide instructions for hosting a linkding installation on various platforms, however you are still responsible for updates and backups: 17 | 18 | - [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) 19 | - [CapRover](https://caprover.com/) - Linkding is included as a default one-click app 20 | - [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) 21 | -------------------------------------------------------------------------------- /docs/src/content/docs/shortcuts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Keyboard Shortcuts" 3 | description: "Keyboard Shortcuts" 4 | --- 5 | 6 | The following keyboard shortcuts are currently available: 7 | 8 | | Action | Shortcut | 9 | |-------------------------------------------------------------------------------------------|-------------------------------------| 10 | | Add new bookmark | n | 11 | | Focus search input | s | 12 | | Navigate bookmarks | , | 13 | | Toggle bookmark notes | e | 14 | -------------------------------------------------------------------------------- /docs/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict" 3 | } -------------------------------------------------------------------------------- /install-linkding.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Script for creating or updating a linkding installation / Docker container 4 | # The script uses a number of variables that control how the container is set up 5 | # and where the application data is stored on the host 6 | # The following variables are available: 7 | # 8 | # LD_CONTAINER_NAME - name of the Docker container that should be created or updated 9 | # LD_HOST_PORT - port on your system that the application will use 10 | # LD_HOST_DATA_DIR - directory on your system where the applications database will be stored 11 | # 12 | # Variables can be from your shell like this: 13 | # export LD_HOST_DATA_DIR=/etc/linkding/data 14 | 15 | # Provide default variable values 16 | if [ -z "${LD_CONTAINER_NAME}" ]; then 17 | LD_CONTAINER_NAME="linkding" 18 | fi 19 | if [ -z "${LD_HOST_PORT}" ]; then 20 | LD_HOST_PORT=9090 21 | fi 22 | if [ -z "${LD_HOST_DATA_DIR}" ]; then 23 | LD_HOST_DATA_DIR=/etc/linkding/data 24 | fi 25 | 26 | echo "Create or update linkding container" 27 | echo "Container name: ${LD_CONTAINER_NAME}" 28 | echo "Host port: ${LD_HOST_PORT}" 29 | echo "Host data dir: ${LD_HOST_DATA_DIR}" 30 | 31 | echo "Stop existing container..." 32 | docker stop ${LD_CONTAINER_NAME} || true 33 | echo "Remove existing container..." 34 | docker rm ${LD_CONTAINER_NAME} || true 35 | echo "Update image..." 36 | docker pull sissbruecker/linkding:latest 37 | echo "Start container..." 38 | docker run -d \ 39 | -p ${LD_HOST_PORT}:9090 \ 40 | --name ${LD_CONTAINER_NAME} \ 41 | -v ${LD_HOST_DATA_DIR}:/etc/linkding/data \ 42 | sissbruecker/linkding:latest 43 | echo "Done!" 44 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bookmarks.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linkding", 3 | "version": "1.40.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "npm run build-js && npm run build-theme-light && npm run build-theme-dark", 8 | "build-js": "rollup -c", 9 | "build-theme-light": "postcss -o bookmarks/static/theme-light.css bookmarks/styles/theme-light.css", 10 | "build-theme-dark": "postcss -o bookmarks/static/theme-dark.css bookmarks/styles/theme-dark.css", 11 | "dev": "rollup -c -w" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/sissbruecker/linkding.git" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "MIT", 20 | "bugs": { 21 | "url": "https://github.com/sissbruecker/linkding/issues" 22 | }, 23 | "homepage": "https://github.com/sissbruecker/linkding#readme", 24 | "dependencies": { 25 | "@hotwired/turbo": "^8.0.6", 26 | "@rollup/plugin-node-resolve": "^15.2.3", 27 | "@rollup/plugin-terser": "^0.4.4", 28 | "@rollup/wasm-node": "^4.13.0", 29 | "cssnano": "^7.0.6", 30 | "postcss": "^8.4.45", 31 | "postcss-cli": "^11.0.0", 32 | "postcss-import": "^16.1.0", 33 | "postcss-nesting": "^13.0.0", 34 | "rollup-plugin-svelte": "^7.2.0", 35 | "svelte": "^4.0.0" 36 | }, 37 | "devDependencies": { 38 | "prettier": "^3.3.3" 39 | }, 40 | "web-types": "./web-types.json" 41 | } 42 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const cssnano = require("cssnano"); 2 | const postcssImport = require("postcss-import"); 3 | const postcssNesting = require("postcss-nesting"); 4 | 5 | module.exports = { 6 | plugins: [ 7 | postcssImport, 8 | postcssNesting, 9 | cssnano({ 10 | preset: "default", 11 | }), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = bookmarks.settings.dev 3 | # -- recommended but optional: 4 | python_files = tests.py test_*.py *_tests.py 5 | -------------------------------------------------------------------------------- /requirements.dev.in: -------------------------------------------------------------------------------- 1 | black 2 | coverage 3 | django-debug-toolbar 4 | playwright 5 | pytest 6 | pytest-django 7 | pytest-xdist 8 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile requirements.dev.in 6 | # 7 | asgiref==3.8.1 8 | # via django 9 | black==24.8.0 10 | # via -r requirements.dev.in 11 | click==8.1.7 12 | # via black 13 | coverage==7.6.1 14 | # via -r requirements.dev.in 15 | django==5.1.9 16 | # via django-debug-toolbar 17 | django-debug-toolbar==4.4.6 18 | # via -r requirements.dev.in 19 | execnet==2.1.1 20 | # via pytest-xdist 21 | greenlet==3.0.3 22 | # via playwright 23 | iniconfig==2.0.0 24 | # via pytest 25 | mypy-extensions==1.0.0 26 | # via black 27 | packaging==24.1 28 | # via 29 | # black 30 | # pytest 31 | pathspec==0.12.1 32 | # via black 33 | platformdirs==4.3.6 34 | # via black 35 | playwright==1.47.0 36 | # via -r requirements.dev.in 37 | pluggy==1.5.0 38 | # via pytest 39 | pyee==12.0.0 40 | # via playwright 41 | pytest==8.3.3 42 | # via 43 | # -r requirements.dev.in 44 | # pytest-django 45 | # pytest-xdist 46 | pytest-django==4.9.0 47 | # via -r requirements.dev.in 48 | pytest-xdist==3.6.1 49 | # via -r requirements.dev.in 50 | sqlparse==0.5.1 51 | # via 52 | # django 53 | # django-debug-toolbar 54 | typing-extensions==4.12.2 55 | # via pyee 56 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | beautifulsoup4 2 | bleach 3 | bleach-allowlist 4 | Django 5 | django-registration 6 | django-widget-tweaks 7 | djangorestframework 8 | huey 9 | Markdown 10 | mozilla-django-oidc 11 | psycopg2-binary 12 | python-dateutil 13 | requests 14 | supervisor 15 | uWSGI 16 | waybackpy 17 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import svelte from 'rollup-plugin-svelte'; 2 | import resolve from '@rollup/plugin-node-resolve'; 3 | import terser from '@rollup/plugin-terser'; 4 | 5 | const production = !process.env.ROLLUP_WATCH; 6 | 7 | export default { 8 | input: 'bookmarks/frontend/index.js', 9 | output: { 10 | sourcemap: true, 11 | format: 'iife', 12 | name: 'linkding', 13 | // Generate bundle in static folder to that it is picked up by Django static files finder 14 | file: 'bookmarks/static/bundle.js', 15 | }, 16 | plugins: [ 17 | svelte({ 18 | emitCss: false, 19 | }), 20 | 21 | // If you have external dependencies installed from 22 | // npm, you'll most likely need these plugins. In 23 | // some cases you'll need additional configuration — 24 | // consult the documentation for details: 25 | // https://github.com/rollup/rollup-plugin-commonjs 26 | resolve({ 27 | browser: true, 28 | }), 29 | 30 | // If we're building for production (npm run build 31 | // instead of npm run dev), minify 32 | production && terser(), 33 | ], 34 | watch: { 35 | clearScreen: false, 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /scripts/build-docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | version=$( 0 20 | page = page + 1 21 | 22 | return releases 23 | 24 | 25 | def render_release_section(release): 26 | date = datetime.fromisoformat(release['published_at'].replace("Z", "+00:00")) 27 | formatted_date = date.strftime('%d/%m/%Y') 28 | section = f'## {release["name"]} ({formatted_date})\n\n' 29 | body = release['body'] 30 | # increase heading for body content 31 | body = body.replace("## What's Changed", "### What's Changed") 32 | body = body.replace("## New Contributors", "### New Contributors") 33 | section += body.strip() 34 | return section 35 | 36 | 37 | def generate_change_log(): 38 | releases = load_all_releases() 39 | 40 | change_log = '# Changelog\n\n' 41 | sections = [render_release_section(release) for release in releases] 42 | body = '\n\n---\n\n'.join(sections) 43 | change_log = change_log + body 44 | 45 | with open("CHANGELOG.md", "w") as file: 46 | file.write(change_log) 47 | 48 | 49 | generate_change_log() 50 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | version=$(&2 "$(date +%Y%m%dt%H%M%S) Waiting for postgres container" 17 | sleep 15 18 | 19 | # Start linkding dev server 20 | export LD_DB_ENGINE=postgres 21 | export LD_DB_USER=linkding 22 | export LD_DB_PASSWORD=linkding 23 | 24 | export LD_SUPERUSER_NAME=admin 25 | export LD_SUPERUSER_PASSWORD=admin 26 | 27 | python manage.py migrate 28 | python manage.py create_initial_superuser 29 | python manage.py runserver 30 | -------------------------------------------------------------------------------- /scripts/setup-oicd.sh: -------------------------------------------------------------------------------- 1 | # Example setup for OIDC with Zitadel 2 | export LD_ENABLE_OIDC=True 3 | export OIDC_USE_PKCE=True 4 | export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/oauth/v2/authorize 5 | export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/oauth/v2/token 6 | export OIDC_OP_USER_ENDPOINT=http://localhost:8080/oidc/v1/userinfo 7 | export OIDC_OP_JWKS_ENDPOINT=http://localhost:8080/oauth/v2/keys 8 | export OIDC_RP_CLIENT_ID= 9 | -------------------------------------------------------------------------------- /scripts/setup-ublock.sh: -------------------------------------------------------------------------------- 1 | rm -rf uBOLite.chromium.mv3 2 | 3 | # Download uBlock Origin Lite 4 | TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') 5 | DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip 6 | echo "Downloading $DOWNLOAD_URL" 7 | curl -L -o uBOLite.zip $DOWNLOAD_URL 8 | unzip uBOLite.zip -d uBOLite.chromium.mv3 9 | rm uBOLite.zip 10 | 11 | # Patch uBlock Origin Lite to respect rulesets enabled in manifest.json 12 | sed -i '' "s/const out = \[ 'default' \];/const out = await dnr.getEnabledRulesets();/" uBOLite.chromium.mv3/js/ruleset-manager.js 13 | 14 | # Enable annoyances rulesets in manifest.json 15 | jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' uBOLite.chromium.mv3/manifest.json > temp.json 16 | mv temp.json uBOLite.chromium.mv3/manifest.json 17 | 18 | mkdir -p chromium-profile 19 | -------------------------------------------------------------------------------- /scripts/test-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Make sure Chromium is installed 4 | playwright install chromium 5 | 6 | # Test server loads assets from static folder, so make sure files there are up-to-date 7 | rm -rf static 8 | npm run build 9 | python manage.py collectstatic 10 | 11 | # Run E2E tests 12 | python manage.py test bookmarks.tests_e2e --pattern="e2e_test_*.py" 13 | -------------------------------------------------------------------------------- /scripts/test-postgres.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | # Remove previous container if exists 6 | docker rm -f linkding-postgres-test || true 7 | 8 | # Run postgres container 9 | docker run -d \ 10 | -e POSTGRES_DB=linkding \ 11 | -e POSTGRES_USER=linkding \ 12 | -e POSTGRES_PASSWORD=linkding \ 13 | -p 5432:5432 \ 14 | --name linkding-postgres-test \ 15 | postgres 16 | 17 | # Wait until postgres has started 18 | echo >&2 "$(date +%Y%m%dt%H%M%S) Waiting for postgres container" 19 | sleep 15 20 | 21 | # Run tests using postgres 22 | export LD_DB_ENGINE=postgres 23 | export LD_DB_USER=linkding 24 | export LD_DB_PASSWORD=linkding 25 | 26 | ./scripts/test.sh 27 | 28 | # Remove postgres container 29 | docker rm -f linkding-postgres-test || true 30 | -------------------------------------------------------------------------------- /scripts/test-unit.sh: -------------------------------------------------------------------------------- 1 | python manage.py test bookmarks.tests 2 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | ./scripts/test-unit.sh 2 | ./scripts/test-e2e.sh 3 | -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | user=root 3 | loglevel=info 4 | 5 | [program:jobs] 6 | user=www-data 7 | # setup a temp home folder for the job, required by chromium 8 | environment=HOME=/tmp/home 9 | command=python manage.py run_huey -f 10 | stdout_logfile=background_tasks.log 11 | stdout_logfile_maxbytes=10MB 12 | stdout_logfile_backups=5 13 | redirect_stderr=true 14 | 15 | [unix_http_server] 16 | file=/var/run/supervisor.sock 17 | chmod=0700 18 | 19 | [rpcinterface:supervisor] 20 | supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface 21 | 22 | [supervisorctl] 23 | serverurl=unix:///var/run/supervisor.sock 24 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | module = bookmarks.wsgi:application 3 | env = DJANGO_SETTINGS_MODULE=bookmarks.settings.prod 4 | static-map = /static=static 5 | static-map = /static=data/favicons 6 | static-map = /static=data/previews 7 | static-map = /robots.txt=static/robots.txt 8 | processes = 2 9 | threads = 2 10 | pidfile = /tmp/linkding.pid 11 | vacuum=True 12 | stats = 127.0.0.1:9191 13 | uid = www-data 14 | gid = www-data 15 | buffer-size = 8192 16 | die-on-term = true 17 | 18 | if-env = LD_CONTEXT_PATH 19 | static-map = /%(_)static=static 20 | static-map = /%(_)static=data/favicons 21 | static-map = /%(_)static=data/previews 22 | static-map = /%(_)robots.txt=static/robots.txt 23 | endif = 24 | 25 | if-env = LD_REQUEST_TIMEOUT 26 | http-timeout = %(_) 27 | socket-timeout = %(_) 28 | harakiri = %(_) 29 | endif = 30 | 31 | if-env = LD_REQUEST_MAX_CONTENT_LENGTH 32 | limit-post = %(_) 33 | endif = 34 | 35 | if-env = LD_LOG_X_FORWARDED_FOR 36 | log-x-forwarded-for = %(_) 37 | endif = 38 | 39 | if-env = LD_DISABLE_REQUEST_LOGS=true 40 | disable-logging = true 41 | log-4xx = true 42 | log-5xx = true 43 | endif = 44 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | 1.40.0 2 | --------------------------------------------------------------------------------