13 | {icon && } 14 | 15 |
16 |├── .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 | 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("
") 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'
") 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 |
ID | 8 |Name | 9 |Args | 10 |Retries | 11 |
---|---|---|---|
{{ task.id }} | 17 |{{ task.name }} | 18 |{{ task.args }} | 19 |{{ task.retries }} | 20 |
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 |You can now close this window.
8 | {% endblock %} 9 | 10 | -------------------------------------------------------------------------------- /bookmarks/templates/bookmarks/details/modal.html: -------------------------------------------------------------------------------- 1 |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 |16 | You can now use the application. 17 |
18 |16 | Your password was changed successfully. 17 |
18 |10 | 11 |
-------------------------------------------------------------------------------- /bookmarks/tests/resources/simple_valid_import_file_with_one_invalid_bookmark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |
10 | 11 |
-------------------------------------------------------------------------------- /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 = "
13 | {icon && } 14 | 15 |
16 |Source | 16 |Description | 17 |Amount | 18 |Donated to | 19 |
---|---|---|---|
PikaPods | 24 |Linkding hosting June 2022 - September 2023 | 25 |$163.50 | 26 |Internet Archive | 27 |
PikaPods | 30 |Linkding hosting October 2023 - September 2024 | 31 |$287.04 | 32 |
33 | Django 34 | SingleFile 35 | Internet Archive 36 | NOYB 37 | |
38 |