mirror of
https://codeberg.org/diginaut/digimarks.git
synced 2026-02-04 18:30:26 +01:00
Compare commits
32 Commits
651a7e4ece
...
fastapi
| Author | SHA1 | Date | |
|---|---|---|---|
| dae6c5da18 | |||
| f68daf4ac0 | |||
| be34c6e88f | |||
| 47a0f31ec3 | |||
| 05fa94ef41 | |||
| b4aff120c8 | |||
| 82e4202482 | |||
| 9b03d51276 | |||
| fe734d6dd8 | |||
| 2936a4815a | |||
| 09c685f2aa | |||
| 0b08f0fa81 | |||
| 77dd621280 | |||
| a9f8236ee6 | |||
| ac9e010808 | |||
| 21f5f34e4f | |||
| 971ede6067 | |||
| 96a8946a9a | |||
| 14f09a2dfb | |||
| 9d813b7ea6 | |||
| 79be98abea | |||
| a7498a2fba | |||
| 8810a47faa | |||
| cae9ebf3ef | |||
| 5eb9c606f0 | |||
| 894f97a25e | |||
| 8ccb18839f | |||
| dda209fa96 | |||
| dcac963fa6 | |||
| da28f2f781 | |||
| 987a030c4f | |||
| bf6cd081f9 |
1
.envrc.example
Normal file
1
.envrc.example
Normal file
@@ -0,0 +1 @@
|
||||
layout uv
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -77,10 +77,15 @@ celerybeat-schedule
|
||||
|
||||
# dotenv
|
||||
.env
|
||||
*.env
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
|
||||
# virtualenv
|
||||
venv/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
@@ -93,6 +98,10 @@ ENV/
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Zed editor
|
||||
.zed
|
||||
|
||||
# digimarks
|
||||
static/favicons
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## TODO
|
||||
|
||||
- Sorting of bookmarks
|
||||
- Sort by title
|
||||
- Sort by date
|
||||
- Logging of actions
|
||||
- Add new way of authentication and editing bookmark collections:
|
||||
https://github.com/aquatix/digimarks/issues/8 and https://github.com/aquatix/digimarks/issues/9
|
||||
- Change adding tags to use the MaterializeCSS tags: https://materializecss.com/chips.html
|
||||
- Change adding tags to use ~~the MaterializeCSS tags: https://materializecss.com/chips.html~~ a nice tags lib/styling
|
||||
- Do calls to the API endpoint of an existing bookmark when editing properties
|
||||
(for example to update tags, title and such, also to already suggest title)
|
||||
- Look into compatibility with del.icio.us, so we can make use of existing browser integration
|
||||
@@ -23,7 +19,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Settings through Pydantic Settings
|
||||
- Settings now work through Pydantic Settings
|
||||
- New UI theme(s) through digui
|
||||
- Caching of the bookmarks, tags and more in the browser, for fast filtering and lookups
|
||||
- Sorting of bookmarks
|
||||
- Sort by title
|
||||
- Sort by date
|
||||
- Logging of actions
|
||||
- Recognise when a url already is in the list of known bookmarks and fill in the form with already-known data
|
||||
|
||||
### Changed
|
||||
- Moved from Flask to FastAPI
|
||||
|
||||
23
README.rst
23
README.rst
@@ -27,12 +27,13 @@ necessary packages:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/aquatix/digimarks.git
|
||||
git clone https://codeberg.org/diginaut/digimarks.git
|
||||
cd digimarks
|
||||
mkvirtualenv digimarks # or whatever project you are working on
|
||||
# direnv will now create or activate a virtualenv
|
||||
# See https://codeberg.org/diginaut/dotfiles/src/branch/master/.config/direnv/direnvrc for direnv uv config
|
||||
# If you just want to run it, no need for development dependencies
|
||||
uv sync --active --no-dev
|
||||
# Otherwise, install everything
|
||||
# Otherwise, install everything in the active virtualenv
|
||||
uv sync --active
|
||||
|
||||
|
||||
@@ -124,7 +125,7 @@ Attributions
|
||||
'M' favicon by `Freepik`_.
|
||||
|
||||
|
||||
.. _digimarks: https://github.com/aquatix/digimarks
|
||||
.. _digimarks: https://codeberg.org/diginaut/digimarks
|
||||
.. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
|
||||
:target: https://pypi.python.org/pypi/digimarks/
|
||||
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
|
||||
@@ -135,11 +136,11 @@ Attributions
|
||||
.. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b
|
||||
:alt: Codacy Badge
|
||||
:target: https://app.codacy.com/app/aquatix/digimarks?utm_source=github.com&utm_medium=referral&utm_content=aquatix/digimarks&utm_campaign=badger
|
||||
.. _hook settings: https://github.com/aquatix/digimarks/blob/master/example_config/examples.yaml
|
||||
.. _vhost for Apache2.4: https://github.com/aquatix/digimarks/blob/master/example_config/apache_vhost.conf
|
||||
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini
|
||||
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md
|
||||
.. _hook settings: https://codeberg.org/diginaut/digimarks/blob/master/example_config/examples.yaml
|
||||
.. _vhost for Apache2.4: https://codeberg.org/diginaut/digimarks/blob/master/example_config/apache_vhost.conf
|
||||
.. _uwsgi.ini: https://codeberg.org/diginaut/digimarks/blob/master/example_config/uwsgi.ini
|
||||
.. _Changelog: https://codeberg.org/diginaut/digimarks/blob/master/CHANGELOG.md
|
||||
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041
|
||||
.. _systemd for digimarks API: https://github.com/aquatix/digimarks/blob/master/example_config/systemd/digimarks.service
|
||||
.. _gunicorn config: https://github.com/aquatix/digimarks/blob/master/example_config/gunicorn_digimarks_conf.py
|
||||
.. _more config: https://github.com/aquatix/digimarks/tree/master/example_config
|
||||
.. _systemd for digimarks API: https://codeberg.org/diginaut/digimarks/blob/master/example_config/systemd/digimarks.service
|
||||
.. _gunicorn config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config/uwsgi.ini
|
||||
.. _more config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Digimarks project."""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Alembic environment file for SQLAlchemy."""
|
||||
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
@@ -7,7 +9,7 @@ from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from src.digimarks.models import Bookmark, PublicTag, User
|
||||
from src.digimarks.models import Bookmark, PublicTag, User # noqa
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
@@ -56,6 +58,7 @@ def run_migrations_offline() -> None:
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run the migrations."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""Initial migration
|
||||
"""Initial migration.
|
||||
|
||||
Revision ID: 115bcd2e1a38
|
||||
Revises:
|
||||
Create Date: 2025-09-12 16:06:16.479075
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '115bcd2e1a38'
|
||||
@@ -21,38 +20,41 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('bookmark',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('url', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('tags', sa.String(length=255), nullable=False),
|
||||
sa.Column('http_status', sa.Integer(), nullable=False),
|
||||
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('favicon', sa.String(length=255), nullable=True),
|
||||
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'bookmark',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('url', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('url_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('tags', sa.String(length=255), nullable=False),
|
||||
sa.Column('http_status', sa.Integer(), nullable=False),
|
||||
sa.Column('modified_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('favicon', sa.String(length=255), nullable=True),
|
||||
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
|
||||
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table('publictag',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('tag', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'publictag',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('tagkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('userkey', sa.String(length=255), nullable=False),
|
||||
sa.Column('tag', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_table('user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=255), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
op.create_table(
|
||||
'user',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('username', sa.String(length=255), nullable=False),
|
||||
sa.Column('key', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
Revision ID: a8d8e45f60a1
|
||||
Revises: 115bcd2e1a38
|
||||
Create Date: 2025-09-12 16:10:41.378716
|
||||
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from datetime import UTC, datetime
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8d8e45f60a1'
|
||||
@@ -24,72 +23,74 @@ def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('bookmark', schema=None) as batch_op:
|
||||
batch_op.alter_column('note',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
batch_op.alter_column('starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column('modified_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True)
|
||||
batch_op.alter_column('deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
batch_op.alter_column('status',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text('0'))
|
||||
batch_op.alter_column(
|
||||
'note',
|
||||
existing_type=sa.TEXT(),
|
||||
type_=sqlmodel.sql.sqltypes.AutoString(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'starred', existing_type=sa.BOOLEAN(), nullable=False, existing_server_default=sa.text('0')
|
||||
)
|
||||
batch_op.alter_column('modified_date', existing_type=sa.DATETIME(), nullable=True)
|
||||
batch_op.alter_column(
|
||||
'deleted_date', existing_type=sa.DATETIME(), nullable=True, existing_server_default=sa.text('(null)')
|
||||
)
|
||||
batch_op.alter_column(
|
||||
'status', existing_type=sa.INTEGER(), nullable=False, existing_server_default=sa.text('0')
|
||||
)
|
||||
batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
|
||||
with op.batch_alter_table('publictag', schema=None) as batch_op:
|
||||
batch_op.alter_column('created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text(str(datetime.now(UTC))))
|
||||
batch_op.alter_column(
|
||||
'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text(str(datetime.now(UTC))),
|
||||
)
|
||||
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
|
||||
with op.batch_alter_table('user', schema=None) as batch_op:
|
||||
batch_op.alter_column('theme',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=False,
|
||||
existing_server_default=sa.text("'green'"))
|
||||
batch_op.alter_column(
|
||||
'theme', existing_type=sa.VARCHAR(length=20), nullable=False, existing_server_default=sa.text("'green'")
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('user', 'theme',
|
||||
existing_type=sa.VARCHAR(length=20),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text("'green'"))
|
||||
op.alter_column(
|
||||
'user', 'theme', existing_type=sa.VARCHAR(length=20), nullable=True, existing_server_default=sa.text("'green'")
|
||||
)
|
||||
op.drop_constraint(None, 'publictag', type_='foreignkey')
|
||||
op.alter_column('publictag', 'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column(
|
||||
'publictag',
|
||||
'created_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
op.drop_constraint(None, 'bookmark', type_='foreignkey')
|
||||
op.alter_column('bookmark', 'status',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
op.alter_column('bookmark', 'deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column('bookmark', 'modified_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True)
|
||||
op.alter_column('bookmark', 'starred',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('0'))
|
||||
op.alter_column('bookmark', 'note',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
type_=sa.TEXT(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'))
|
||||
op.alter_column(
|
||||
'bookmark', 'status', existing_type=sa.INTEGER(), nullable=True, existing_server_default=sa.text('0')
|
||||
)
|
||||
op.alter_column(
|
||||
'bookmark',
|
||||
'deleted_date',
|
||||
existing_type=sa.DATETIME(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
op.alter_column('bookmark', 'modified_date', existing_type=sa.DATETIME(), nullable=True)
|
||||
op.alter_column(
|
||||
'bookmark', 'starred', existing_type=sa.BOOLEAN(), nullable=True, existing_server_default=sa.text('0')
|
||||
)
|
||||
op.alter_column(
|
||||
'bookmark',
|
||||
'note',
|
||||
existing_type=sqlmodel.sql.sqltypes.AutoString(),
|
||||
type_=sa.TEXT(),
|
||||
nullable=True,
|
||||
existing_server_default=sa.text('(null)'),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"""Renamed keys
|
||||
"""Renamed keys.
|
||||
|
||||
Revision ID: b8cbc6957df5
|
||||
Revises: a8d8e45f60a1
|
||||
Create Date: 2025-09-12 22:26:38.684120
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b8cbc6957df5'
|
||||
|
||||
@@ -10,7 +10,7 @@ authors = [
|
||||
]
|
||||
description = 'Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.'
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["bookmarks", "api"]
|
||||
license = { text = "Apache" }
|
||||
classifiers = [
|
||||
@@ -30,11 +30,16 @@ dependencies = [
|
||||
"feedgen",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
server = [
|
||||
"uvicorn",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
{include-group = "lint"},
|
||||
{include-group = "pub"},
|
||||
{include-group = "test"}
|
||||
{ include-group = "lint" },
|
||||
{ include-group = "pub" },
|
||||
{ include-group = "test" }
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
@@ -42,7 +47,7 @@ test = [
|
||||
]
|
||||
lint = [
|
||||
"ruff>=0.1.0",
|
||||
"mypy>=1.0.0",
|
||||
"pyrefly",
|
||||
]
|
||||
# Publishing on PyPI
|
||||
pub = [
|
||||
@@ -58,8 +63,8 @@ server = [
|
||||
my-script = "digimarks:app"
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://github.com/aquatix/digimarks"
|
||||
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues"
|
||||
"Homepage" = "https://codeberg.org/diginaut/digimarks"
|
||||
"Bug Tracker" = "https://codeberg.org/diginaut/digimarks/issues"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
# Linting and fixing, including isort
|
||||
ruff
|
||||
# Typing
|
||||
pyrefly
|
||||
|
||||
# Test suite
|
||||
pytest
|
||||
pytest-cov
|
||||
|
||||
# Publishing on PyPI
|
||||
build
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Core application
|
||||
fastapi[all]
|
||||
sqlmodel
|
||||
sqlalchemy
|
||||
sqlalchemy[asyncio]
|
||||
pydantic
|
||||
pydantic_settings
|
||||
alembic
|
||||
|
||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""digimarks main module."""
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Top-level package for Digimarks."""
|
||||
|
||||
__author__ = """Michiel Scholten"""
|
||||
|
||||
@@ -8,15 +8,16 @@ from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import bs4
|
||||
import httpx
|
||||
import tags_service
|
||||
import utils
|
||||
from exceptions import BookmarkNotFound
|
||||
from extract_favicon import from_html
|
||||
from fastapi import Query, Request
|
||||
from models import Bookmark, Visibility
|
||||
from pydantic import AnyUrl
|
||||
from fastapi.exceptions import HTTPException
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
from sqlmodel import select
|
||||
|
||||
from digimarks import tags_service, utils
|
||||
from digimarks.exceptions import BookmarkNotFound
|
||||
from digimarks.models import Bookmark, Visibility
|
||||
|
||||
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
|
||||
|
||||
logger = logging.getLogger('digimarks')
|
||||
@@ -34,8 +35,11 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
"""Request the title by requesting the source url."""
|
||||
logger.info('Extracting information from url %s', bookmark.url)
|
||||
try:
|
||||
result = await request.app.requests_client.get(bookmark.url, headers={'User-Agent': DIGIMARKS_USER_AGENT})
|
||||
result = await request.app.state.requests_client.get(
|
||||
str(bookmark.url), headers={'User-Agent': DIGIMARKS_USER_AGENT}
|
||||
)
|
||||
bookmark.http_status = result.status_code
|
||||
logger.info('HTTP status code %s for %s', bookmark.http_status, bookmark.url)
|
||||
except httpx.HTTPError as err:
|
||||
# For example, "MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?"
|
||||
logger.error('Exception when trying to retrieve title for %s. Error: %s', bookmark.url, str(err))
|
||||
@@ -43,11 +47,12 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
bookmark.title = ''
|
||||
return bookmark
|
||||
if bookmark.http_status == 200 or bookmark.http_status == 202:
|
||||
html = bs4.BeautifulSoup(result.text, 'html.parser')
|
||||
html_content = bs4.BeautifulSoup(result.text, 'html.parser')
|
||||
try:
|
||||
bookmark.title = html.title.text.strip()
|
||||
except AttributeError:
|
||||
bookmark.title = ''
|
||||
bookmark.title = html_content.title.text.strip()
|
||||
except AttributeError as exc:
|
||||
logger.error('Error while trying to extract title from URL %s: %s', str(bookmark.url), str(exc))
|
||||
raise HTTPException(status_code=400, detail='Error while trying to extract title')
|
||||
|
||||
url_parts = urlparse(str(bookmark.url))
|
||||
root_url = url_parts.scheme + '://' + url_parts.netloc
|
||||
@@ -56,8 +61,8 @@ async def set_information_from_source(bookmark: Bookmark, request: Request) -> B
|
||||
# with open(filename, 'wb') as out_file:
|
||||
# shutil.copyfileobj(response.raw, out_file)
|
||||
|
||||
# Extraction was successful
|
||||
logger.info('Extracting information was successful')
|
||||
# Extraction was successful
|
||||
logger.info('Extracting information was successful')
|
||||
return bookmark
|
||||
|
||||
|
||||
@@ -72,11 +77,15 @@ def strip_url_params(url: str) -> str:
|
||||
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment))
|
||||
|
||||
|
||||
def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
|
||||
async def update_bookmark_with_info(bookmark: Bookmark, request: Request, strip_params: bool = False):
|
||||
"""Automatically update title, favicon, etc."""
|
||||
if isinstance(bookmark.url, str):
|
||||
# If type of the url is a 'simple' string, ensure it to be an AnyUrl
|
||||
bookmark.url = AnyUrl(bookmark.url)
|
||||
|
||||
if not bookmark.title:
|
||||
# Title was empty, automatically fetch it from the url, will also update the status code
|
||||
set_information_from_source(bookmark, request)
|
||||
await set_information_from_source(bookmark, request)
|
||||
|
||||
if strip_params:
|
||||
# Strip URL parameters, e.g., tracking params
|
||||
@@ -92,7 +101,10 @@ async def list_bookmarks_for_user(
|
||||
offset: int = 0,
|
||||
limit: Annotated[int, Query(le=10000)] = 100,
|
||||
) -> Sequence[Bookmark]:
|
||||
"""List all bookmarks in the database. By default, 100 items are returned."""
|
||||
"""List all bookmarks in the database. By default, 100 items are returned.
|
||||
|
||||
There is a limit of 10000 items.
|
||||
"""
|
||||
result = await session.exec(
|
||||
select(Bookmark)
|
||||
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
|
||||
@@ -121,12 +133,17 @@ async def autocomplete_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
||||
bookmark.user_key = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
try:
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
except ValidationError as exc:
|
||||
logger.error('ValidationError while autocompleting bookmark with URL %s', bookmark.url)
|
||||
logger.error('Error was: %s', str(exc))
|
||||
raise HTTPException(status_code=400, detail='Error while autocompleting, likely the URL contained an error')
|
||||
|
||||
url_hash = utils.generate_hash(str(bookmark.url))
|
||||
result = await session.exec(
|
||||
@@ -149,12 +166,12 @@ async def add_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Add new bookmark for user `user_key`."""
|
||||
bookmark.user_key = user_key
|
||||
|
||||
# Auto-fill title, fix tags etc.
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
bookmark.url_hash = utils.generate_hash(str(bookmark.url))
|
||||
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
|
||||
|
||||
@@ -171,7 +188,7 @@ async def update_bookmark(
|
||||
bookmark: Bookmark,
|
||||
url_hash: str,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Update existing bookmark `bookmark_key` for user `user_key`."""
|
||||
result = await session.exec(
|
||||
select(Bookmark).where(
|
||||
@@ -190,7 +207,7 @@ async def update_bookmark(
|
||||
bookmark_db.sqlmodel_update(bookmark_data)
|
||||
|
||||
# Autofill title, fix tags, etc. where (still) needed
|
||||
update_bookmark_with_info(bookmark, request, strip_params)
|
||||
await update_bookmark_with_info(bookmark, request, strip_params)
|
||||
|
||||
session.add(bookmark_db)
|
||||
await session.commit()
|
||||
@@ -202,7 +219,7 @@ async def delete_bookmark(
|
||||
session,
|
||||
user_key: str,
|
||||
url_hash: str,
|
||||
):
|
||||
) -> None:
|
||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
|
||||
bookmark = result
|
||||
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
|
||||
class BookmarkNotFound(Exception):
|
||||
def __init__(self, message='Bookmark not found'):
|
||||
"""A bookmark was not found."""
|
||||
|
||||
def __init__(self, message: str ='Bookmark not found'):
|
||||
"""Initialise the exception.
|
||||
|
||||
:param str message: The message for the exception
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.message: str = message
|
||||
|
||||
|
||||
class BookmarkAlreadyExists(Exception):
|
||||
def __init__(self, message='Bookmark already exists'):
|
||||
"""A bookmark already exists for this URL and this user."""
|
||||
|
||||
def __init__(self, message: str ='Bookmark already exists'):
|
||||
"""Initialise the exception.
|
||||
|
||||
:param str message: The message for the exception
|
||||
"""
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.message: str = message
|
||||
|
||||
6
src/digimarks/extract.py
Normal file
6
src/digimarks/extract.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pydantic import AnyUrl
|
||||
|
||||
|
||||
def extract_contents(title: str, url: AnyUrl, note: str):
|
||||
"""Extract contents from a URL."""
|
||||
return
|
||||
@@ -4,18 +4,14 @@ import logging
|
||||
from collections.abc import Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
from typing import Annotated, AsyncGenerator, cast
|
||||
|
||||
import bookmarks_service
|
||||
import httpx
|
||||
import tags_service
|
||||
from exceptions import BookmarkNotFound
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from models import DEFAULT_THEME, Bookmark, User, Visibility
|
||||
from pydantic import DirectoryPath, FilePath
|
||||
from pydantic_settings import BaseSettings
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
@@ -23,6 +19,10 @@ from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import desc, select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from digimarks import bookmarks_service, tags_service
|
||||
from digimarks.exceptions import BookmarkNotFound
|
||||
from digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
|
||||
|
||||
DIGIMARKS_VERSION = '2.0.0a1'
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ class Settings(BaseSettings):
|
||||
favicons_dir: DirectoryPath
|
||||
|
||||
# inside the codebase
|
||||
static_dir: DirectoryPath = 'static'
|
||||
template_dir: DirectoryPath = 'templates'
|
||||
# static_dir: DirectoryPath = Path('digimarks/static')
|
||||
# template_dir: DirectoryPath = Path('digimarks/templates')
|
||||
static_dir: DirectoryPath = DirectoryPath('digimarks/static')
|
||||
template_dir: DirectoryPath = DirectoryPath('digimarks/templates')
|
||||
|
||||
media_url: str = '/static/'
|
||||
|
||||
@@ -52,22 +54,32 @@ engine = create_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', co
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""SQLAlchemy session factory."""
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async_session = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
|
||||
# Shorter way of getting the DB session in an endpoint
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(the_app: FastAPI):
|
||||
async def lifespan(the_app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Upon start, initialise an AsyncClient and assign it to an attribute named requests_client on the app object."""
|
||||
the_app.requests_client = httpx.AsyncClient()
|
||||
yield
|
||||
await the_app.requests_client.aclose()
|
||||
async with httpx.AsyncClient() as requests_client:
|
||||
the_app.state.requests_client = requests_client
|
||||
yield
|
||||
await the_app.state.requests_client.aclose()
|
||||
|
||||
|
||||
async def get_requests_client(request: Request) -> httpx.AsyncClient:
|
||||
"""Get the httpx client from the application object."""
|
||||
return cast(httpx.AsyncClient, request.app.state.requests_client)
|
||||
|
||||
|
||||
# Shorter way of getting the httpx client in an endpoint
|
||||
RequestsDep = Annotated[AsyncSession, Depends(get_requests_client)]
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
|
||||
app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons')
|
||||
@@ -125,7 +137,7 @@ def index(request: Request):
|
||||
|
||||
|
||||
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User)
|
||||
async def get_user(session: SessionDep, system_key: str, user_id: int) -> type[User]:
|
||||
async def get_user(session: SessionDep, system_key: str, user_id: int) -> User:
|
||||
"""Show user information."""
|
||||
logger.info('User %d requested', user_id)
|
||||
if system_key != settings.system_key:
|
||||
@@ -200,7 +212,7 @@ async def autocomplete_bookmark(
|
||||
user_key: str,
|
||||
bookmark: Bookmark,
|
||||
strip_params: bool = False,
|
||||
):
|
||||
) -> Bookmark:
|
||||
"""Autofill some fields for this (new) bookmark for user `user_key`."""
|
||||
logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, user_key)
|
||||
return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
|
||||
@@ -246,7 +258,7 @@ async def delete_bookmark(
|
||||
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
|
||||
logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
|
||||
try:
|
||||
result = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
|
||||
_ = await bookmarks_service.delete_bookmark(session, user_key, url_hash)
|
||||
return {'ok': True}
|
||||
except Exception:
|
||||
logger.exception('Failed to delete bookmark %s', url_hash)
|
||||
@@ -273,12 +285,24 @@ async def bookmarks_changed_since(
|
||||
)
|
||||
latest_created_bookmark = result.first()
|
||||
|
||||
latest_modification = max(latest_modified_bookmark.modified_date, latest_created_bookmark.created_date)
|
||||
# There needs to be at least one bookmark of course
|
||||
if latest_created_bookmark:
|
||||
latest_created_datetime = latest_created_bookmark.created_date
|
||||
else:
|
||||
latest_created_datetime = datetime.min
|
||||
|
||||
# We only have a modified datetime when at least one has been edited
|
||||
if latest_modified_bookmark:
|
||||
latest_modified_datetime = latest_modified_bookmark.modified_date
|
||||
else:
|
||||
latest_modified_datetime = datetime.min
|
||||
|
||||
latest_modification = max(latest_modified_datetime, latest_created_datetime)
|
||||
|
||||
return {
|
||||
'current_time': datetime.now(UTC),
|
||||
'latest_change': latest_modified_bookmark.modified_date,
|
||||
'latest_created': latest_created_bookmark.created_date,
|
||||
'latest_change': latest_modified_datetime,
|
||||
'latest_created': latest_created_datetime,
|
||||
'latest_modification': latest_modification,
|
||||
}
|
||||
|
||||
@@ -299,7 +323,7 @@ async def list_bookmarks_for_tag_for_user(
|
||||
tag_key: str,
|
||||
) -> list[str]:
|
||||
"""List all tags in use by the user."""
|
||||
logger.info('List bookmarks for tag %s user %s', user_key)
|
||||
logger.info('List bookmarks for tag "%s" by user %s', tag_key, user_key)
|
||||
return await tags_service.list_bookmarks_for_tag_for_user(session, user_key, tag_key)
|
||||
|
||||
|
||||
@@ -308,7 +332,7 @@ async def page_user_landing(
|
||||
session: SessionDep,
|
||||
request: Request,
|
||||
user_key: str,
|
||||
):
|
||||
) -> HTMLResponse:
|
||||
"""HTML page with the main view for the user."""
|
||||
result = await session.exec(select(User).where(User.key == user_key))
|
||||
user = result.first()
|
||||
|
||||
@@ -5,7 +5,7 @@ Contains the bookmarks administration, users, tags, public tags and more.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Optional, Type, TypeVar
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import AnyUrl, computed_field
|
||||
from sqlmodel import AutoString, Field, SQLModel
|
||||
@@ -16,8 +16,6 @@ DEFAULT_THEME = 'freshgreen'
|
||||
class User(SQLModel, table=True):
|
||||
"""User account."""
|
||||
|
||||
__tablename__ = 'user'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
username: str
|
||||
key: str
|
||||
@@ -28,23 +26,23 @@ class User(SQLModel, table=True):
|
||||
class Visibility:
|
||||
"""Options for visibility of an object."""
|
||||
|
||||
VISIBLE = 0
|
||||
DELETED = 1
|
||||
HIDDEN = 2
|
||||
VISIBLE: int = 0
|
||||
DELETED: int = 1
|
||||
HIDDEN: int = 2
|
||||
|
||||
|
||||
# Type var used for building custom types for the DB
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
def build_custom_type(internal_type: type[T]) -> type[AutoString]:
|
||||
"""Create a type that is compatible with the database.
|
||||
|
||||
Based on https://github.com/fastapi/sqlmodel/discussions/847
|
||||
"""
|
||||
|
||||
class CustomType(AutoString):
|
||||
def process_bind_param(self, value, dialect) -> Optional[str]:
|
||||
def process_bind_param(self, value, dialect) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -57,7 +55,7 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect) -> Optional[T]:
|
||||
def process_result_value(self, value, dialect) -> T | None:
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
@@ -69,8 +67,6 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
|
||||
class Bookmark(SQLModel, table=True):
|
||||
"""Bookmark object."""
|
||||
|
||||
__tablename__ = 'bookmark'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
user_key: str = Field(foreign_key='user.key', nullable=False)
|
||||
title: str = Field(default='', nullable=False)
|
||||
@@ -93,7 +89,7 @@ class Bookmark(SQLModel, table=True):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tag_list(self) -> list:
|
||||
def tag_list(self) -> list[str]:
|
||||
"""The tags but as a proper list."""
|
||||
if self.tags:
|
||||
return self.tags.split(',')
|
||||
@@ -104,8 +100,6 @@ class Bookmark(SQLModel, table=True):
|
||||
class PublicTag(SQLModel, table=True):
|
||||
"""Public tag object."""
|
||||
|
||||
__tablename__ = 'publictag'
|
||||
|
||||
id: int = Field(primary_key=True)
|
||||
tag_key: str
|
||||
user_key: str = Field(foreign_key='user.key')
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
.thumbnail {
|
||||
/*width: 80px;*/
|
||||
width: 66;
|
||||
width: 66px;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* v0.0.2
|
||||
*
|
||||
* Created by: Michiel Scholten
|
||||
* Source: https://github.com/aquatix/digui
|
||||
* Source: https://codeberg.org/diginaut/digui
|
||||
*/
|
||||
|
||||
/** Colours and themes */
|
||||
@@ -30,6 +30,7 @@
|
||||
--border-color: #d5d9d9;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--chip-border-radius: 2rem;
|
||||
--shadow-color: rgba(213, 217, 217, .5);
|
||||
--global-theme-toggle-content: ' 🌞';
|
||||
|
||||
@@ -77,6 +78,7 @@ html[data-theme='nebula-dark'] {
|
||||
--border-color: #333;
|
||||
--border-width: 1px;
|
||||
--border-radius: 8px;
|
||||
--chip-border-radius: 2rem;
|
||||
--shadow-color: rgba(3, 3, 3, .5);
|
||||
--global-theme-toggle-content: ' 🌝';
|
||||
}
|
||||
@@ -99,6 +101,7 @@ html[data-theme='bbs'] {
|
||||
--border-color: #333;
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--chip-border-radius: 0;
|
||||
--global-theme-toggle-content: ' 🖥️';
|
||||
}
|
||||
|
||||
@@ -124,6 +127,7 @@ html[data-theme='silo'] {
|
||||
/*--border-color: #003eaa;*/
|
||||
--border-width: 2px;
|
||||
--border-radius: 0;
|
||||
--chip-border-radius: 0;
|
||||
--global-theme-toggle-content: ' ⌨️';
|
||||
}
|
||||
|
||||
@@ -229,6 +233,7 @@ ol li::marker, ul li::marker {
|
||||
.active {
|
||||
background-color: var(--color-highlight);
|
||||
color: var(--text-color);
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
/* Special button */
|
||||
@@ -254,6 +259,7 @@ button, .button, input, select, textarea {
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
vertical-align: middle;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
button, .button, input, select, textarea, table {
|
||||
@@ -267,6 +273,7 @@ button:hover, .button:hover {
|
||||
/*background-color: #d57803;*/
|
||||
background-color: var(--color-highlight);
|
||||
filter: brightness(80%);
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
button:focus, .button:focus {
|
||||
@@ -303,6 +310,22 @@ button:focus, .button:focus {
|
||||
filter: brightness(80%);
|
||||
}
|
||||
|
||||
/* Toggle buttons */
|
||||
|
||||
.button-group {
|
||||
display: inline-flex;
|
||||
overflow: hidden;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
/* Reset borders because the buttons are mashed together and the group has its own border */
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
|
||||
th {
|
||||
@@ -334,6 +357,7 @@ th, td {
|
||||
[data-theme='nebula'] .card,
|
||||
[data-theme='nebula'] button,
|
||||
[data-theme='nebula'] .button,
|
||||
[data-theme='nebula'] .button-group,
|
||||
[data-theme='nebula'] input,
|
||||
[data-theme='nebula'] select,
|
||||
[data-theme='nebula'] textarea,
|
||||
@@ -342,6 +366,7 @@ th, td {
|
||||
[data-theme='nebula-dark'] .card,
|
||||
[data-theme='nebula-dark'] button,
|
||||
[data-theme='nebula-dark'] .button,
|
||||
[data-theme='nebula-dark'] .button-group,
|
||||
[data-theme='nebula-dark'] input,
|
||||
[data-theme='nebula-dark'] select,
|
||||
[data-theme='nebula-dark'] textarea,
|
||||
@@ -419,7 +444,7 @@ th, td {
|
||||
|
||||
.chip {
|
||||
font-size: .8rem;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: var(--chip-border-radius);
|
||||
background-color: var(--background-color-secondary);
|
||||
color: var(--text-color-secondary);
|
||||
/*color: var(--text-color);*/
|
||||
@@ -428,7 +453,7 @@ th, td {
|
||||
}
|
||||
|
||||
.chip .button {
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: var(--chip-border-radius);
|
||||
}
|
||||
|
||||
/* Status */
|
||||
|
||||
@@ -16,8 +16,9 @@ document.addEventListener('alpine:init', () => {
|
||||
showBookmarksCards: Alpine.$persist(false).as('showBookmarksCards'),
|
||||
showTags: Alpine.$persist(false).as('showTags'),
|
||||
/* Bookmark that is being edited, used to fill the form, etc. */
|
||||
bookmarkToEdit: Alpine.$persist(null).as('bookmarkToEdit'),
|
||||
bookmarkToEdit: Alpine.$persist({}).as('bookmarkToEdit'),
|
||||
bookmarkToEditError: null,
|
||||
bookmarkToEditVisible: false,
|
||||
|
||||
/* Loading indicator */
|
||||
loading: false,
|
||||
@@ -39,6 +40,9 @@ document.addEventListener('alpine:init', () => {
|
||||
/** Initialise the application after loading */
|
||||
document.documentElement.setAttribute('data-theme', this.theme);
|
||||
console.log('Set theme', this.theme);
|
||||
|
||||
/* Make sure the edit/add bookmark form has a fresh empty object */
|
||||
this.resetEditBookmark();
|
||||
/* Bookmarks are refreshed through the getBookmarks() call in the HTML page */
|
||||
/* await this.getBookmarks(); */
|
||||
setInterval(() => {
|
||||
@@ -101,7 +105,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.cache[this.userKey]['tags'] = await tagsResponse.json();
|
||||
|
||||
/* Filter bookmarks by (blacklisted) tags */
|
||||
await this.filterBookmarksByTags();
|
||||
this.filterBookmarksByTags();
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
@@ -144,6 +148,10 @@ document.addEventListener('alpine:init', () => {
|
||||
)
|
||||
},
|
||||
get filteredTags() {
|
||||
if (this.cache[this.userKey].tags === undefined) {
|
||||
console.log('Tags not yet cached');
|
||||
return [];
|
||||
}
|
||||
/* Search in the list of all tags */
|
||||
return this.cache[this.userKey].tags.filter(
|
||||
i => i.match(new RegExp(this.search, "i"))
|
||||
@@ -204,24 +212,78 @@ document.addEventListener('alpine:init', () => {
|
||||
this.showBookmarksCards = !this.showBookmarksList;
|
||||
},
|
||||
|
||||
async startAddingBookmark() {
|
||||
/* Open 'add bookmark' page */
|
||||
console.log('Start adding bookmark');
|
||||
resetEditBookmark() {
|
||||
this.bookmarkToEdit = {
|
||||
'url_hash': '',
|
||||
'url': '',
|
||||
'title': '',
|
||||
'note': '',
|
||||
'tags': ''
|
||||
'tags': '',
|
||||
'http_status': 0,
|
||||
'strip_params': false
|
||||
}
|
||||
},
|
||||
async startAddingBookmark() {
|
||||
/* Open 'add bookmark' page */
|
||||
console.log('Open "add bookmark" modal');
|
||||
this.resetEditBookmark();
|
||||
// this.show_bookmark_details = true;
|
||||
const editFormDialog = document.getElementById("editFormDialog");
|
||||
this.bookmarkToEditVisible = true;
|
||||
editFormDialog.showModal();
|
||||
},
|
||||
async bookmarkURLChanged() {
|
||||
console.log('Bookmark URL changed');
|
||||
// let response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/');
|
||||
try {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/', {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/autocomplete_bookmark/?strip_params=' + this.bookmarkToEdit.strip_params, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
// Bookmark form data
|
||||
url_hash: this.bookmarkToEdit.url_hash,
|
||||
url: this.bookmarkToEdit.url,
|
||||
title: this.bookmarkToEdit.title,
|
||||
note: this.bookmarkToEdit.note,
|
||||
tags: this.bookmarkToEdit.tags
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
// TODO: update form fields if needed (auto-fetched title for example
|
||||
console.log('Got response');
|
||||
console.log(response);
|
||||
console.log(data);
|
||||
if (response.ok) {
|
||||
this.bookmarkToEdit.url_hash = data.url_hash;
|
||||
this.bookmarkToEdit.url = data.url;
|
||||
this.bookmarkToEdit.title = data.title;
|
||||
this.bookmarkToEdit.note = data.note;
|
||||
this.bookmarkToEdit.tags = data.tags;
|
||||
this.bookmarkToEdit.http_status = data.http_status;
|
||||
} else {
|
||||
console.log('Error occurred');
|
||||
this.bookmarkToEditError = data.detail;
|
||||
}
|
||||
} catch (error) {
|
||||
// enter logic for when there is an error (ex. error toast)
|
||||
console.log('error occurred');
|
||||
console.log(error);
|
||||
this.bookmarkToEditError = error.detail;
|
||||
console.log('yesssh?');
|
||||
}
|
||||
},
|
||||
async saveBookmark() {
|
||||
console.log('Saving bookmark');
|
||||
// this.bookmarkToEditVisible = false;
|
||||
// this.show_bookmark_details = false;
|
||||
},
|
||||
async addBookmark() {
|
||||
/* Post new bookmark to the backend */
|
||||
console.log('Adding bookmark');
|
||||
try {
|
||||
const response = await fetch('/api/v1/' + this.userKey + '/add_bookmark/?strip_params=' + this.bookmarkToEdit.strip_params, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
@@ -237,20 +299,12 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await response.json();
|
||||
// TODO: update form fields if needed (auto-fetched title for example
|
||||
console.log(data);
|
||||
this.bookmarkToEditError = 'lolwut';
|
||||
// this.bookmarkToEditError = 'lolwut';
|
||||
} catch (error) {
|
||||
// enter your logic for when there is an error (ex. error toast)
|
||||
|
||||
console.log(error)
|
||||
}
|
||||
},
|
||||
async saveBookmark() {
|
||||
console.log('Saving bookmark');
|
||||
// this.show_bookmark_details = false;
|
||||
},
|
||||
async addBookmark() {
|
||||
/* Post new bookmark to the backend */
|
||||
//
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Helper functions for tags used with Bookmark models."""
|
||||
|
||||
from models import Bookmark, Visibility
|
||||
from sqlalchemy import Sequence
|
||||
from sqlmodel import select
|
||||
|
||||
from digimarks.models import Bookmark, Visibility
|
||||
|
||||
|
||||
def i_filter_false(predicate, iterable):
|
||||
"""Filter an iterable if predicate returns True.
|
||||
@@ -53,7 +54,10 @@ def clean_tags(tags_list: list) -> list[str]:
|
||||
|
||||
|
||||
def list_tags_for_bookmarks(bookmarks: Sequence[Bookmark]) -> list[str]:
|
||||
"""Generate a unique list of the tags from the list of bookmarks."""
|
||||
"""Generate a unique list of the tags from the list of bookmarks.
|
||||
|
||||
:param Sequence[Bookmark] bookmarks: List of bookmarks to create the list of tags from
|
||||
"""
|
||||
tags = []
|
||||
for bookmark in bookmarks:
|
||||
tags += bookmark.tag_list
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ul>
|
||||
<li><h1>digimarks</h1></li>
|
||||
<li>
|
||||
<a class="button" href="https://github.com/aquatix/digimarks">digimarks project page</a>
|
||||
<a class="button" href="https://codeberg.org/diginaut/digimarks">digimarks project page</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -18,7 +18,7 @@
|
||||
<main>
|
||||
<h1>Welcome to digimarks, your online bookmarking and notes tool</h1>
|
||||
|
||||
<p>Please visit your personal url, or <a href="https://github.com/aquatix/digimarks">see the digimarks
|
||||
<p>Please visit your personal url, or <a href="https://codeberg.org/diginaut/digimarks">see the digimarks
|
||||
project page</a>.</p>
|
||||
|
||||
<p>If you forgot/lost your personal url, contact your digimarks
|
||||
|
||||
@@ -11,9 +11,14 @@
|
||||
<ul>
|
||||
<li><h1>digimarks</h1></li>
|
||||
<li>
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="$store.digimarks.showTags && 'active'">tags
|
||||
</button>
|
||||
<div class="button-group">
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="!$store.digimarks.showTags && 'active'">bookmarks
|
||||
</button>
|
||||
<button x-data @click="$store.digimarks.toggleTagPage()"
|
||||
:class="$store.digimarks.showTags && 'active'">tags
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="$store.digimarks.startAddingBookmark()">add bookmark</button>
|
||||
@@ -32,6 +37,7 @@
|
||||
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
|
||||
|
||||
<p>
|
||||
<div class="button-group">
|
||||
<button @click="$store.digimarks.sortAlphabetically()"
|
||||
:class="$store.digimarks.sortTitleAsc && 'active'">a-z ↓
|
||||
</button>
|
||||
@@ -44,9 +50,15 @@
|
||||
<button @click="$store.digimarks.sortCreated('desc')"
|
||||
:class="$store.digimarks.sortCreatedDesc && 'active'">date ↑
|
||||
</button>
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<button @click="$store.digimarks.toggleListOrGrid()"
|
||||
:class="$store.digimarks.showBookmarksCards && 'active'">list or grid
|
||||
:class="$store.digimarks.showBookmarksCards && 'active'">grid
|
||||
</button>
|
||||
<button @click="$store.digimarks.toggleListOrGrid()"
|
||||
:class="!$store.digimarks.showBookmarksCards && 'active'">list
|
||||
</button>
|
||||
</div>
|
||||
</p>
|
||||
|
||||
<table x-cloak x-show="$store.digimarks.showBookmarksList">
|
||||
@@ -98,15 +110,18 @@
|
||||
<div class="card-thumb" x-show="bookmark.favicon"><img
|
||||
x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
|
||||
<div class="statuses">
|
||||
<div x-show="bookmark.starred" class="star"><i class="fa-fw fa-solid fa-star"></i>
|
||||
<div x-show="bookmark.starred" class="star"><i
|
||||
class="fa-fw fa-solid fa-star"></i>
|
||||
</div>
|
||||
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
|
||||
class="error"><i
|
||||
class="fa-fw fa-solid fa-triangle-exclamation"></i>
|
||||
</div>
|
||||
<div x-show="bookmark.note"><i class="fa-fw fa-regular fa-note-sticky"></i></div>
|
||||
<div x-show="bookmark.note"><i class="fa-fw fa-regular fa-note-sticky"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a>
|
||||
</div>
|
||||
<div><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button title="show actions"><i class="fa-solid fa-square-caret-down"></i></button>
|
||||
@@ -170,43 +185,51 @@
|
||||
</span>
|
||||
</div>
|
||||
#}
|
||||
<form method="dialog" id="bookmarkEditForm">
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_url">URL</label>
|
||||
<input id="bookmark_url" type="text" name="bookmark_url" placeholder="url"
|
||||
x-on:change.debounce="$store.digimarks.bookmarkURLChanged()"
|
||||
x-model="$store.digimarks.bookmarkToEdit.url">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_title">Title</label>
|
||||
<input id="bookmark_title" type="text" name="bookmark_title"
|
||||
placeholder="title (leave empty for autofetch)"
|
||||
x-model="$store.digimarks.bookmarkToEdit.title">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_note">Note</label>
|
||||
<textarea id="bookmark_note" type="text" name="bookmark_note"
|
||||
x-model="$store.digimarks.bookmarkToEdit.note">
|
||||
<template x-if="$store.digimarks.bookmarkToEditVisible">
|
||||
<form method="dialog" id="bookmarkEditForm">
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_url">URL</label>
|
||||
<input id="bookmark_url" type="text" name="bookmark_url" placeholder="url"
|
||||
x-on:change.debounce="$store.digimarks.bookmarkURLChanged()"
|
||||
x-model="$store.digimarks.bookmarkToEdit.url">
|
||||
<p x-show="$store.digimarks.bookmarkToEdit.http_status > 202"
|
||||
x-text="'HTTP statuscode: ' + $store.digimarks.bookmarkToEdit.http_status" x-cloak
|
||||
class="error"></p>
|
||||
<p>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_title">Title</label>
|
||||
<input id="bookmark_title" type="text" name="bookmark_title"
|
||||
placeholder="title (leave empty for autofetch)"
|
||||
x-model="$store.digimarks.bookmarkToEdit.title">
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_note">Note</label>
|
||||
<textarea id="bookmark_note" type="text" name="bookmark_note"
|
||||
x-model="$store.digimarks.bookmarkToEdit.note">
|
||||
</textarea>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_tags">Tags</label>
|
||||
<input id="bookmark_tags" type="text" name="bookmark_tags"
|
||||
placeholder="tags, divided bij comma's"
|
||||
x-model="$store.digimarks.bookmarkToEdit.tags">
|
||||
</fieldset>
|
||||
<p x-show="$store.digimarks.bookmarkToEditError" x-data="$store.digimarks.bookmarkToEditError"></p>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" name="strip" id="strip"/>
|
||||
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
|
||||
</label>
|
||||
</p>
|
||||
<div>
|
||||
<button value="cancel">Cancel</button>
|
||||
<button @click="$store.digimarks.saveBookmark()">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</fieldset>
|
||||
<fieldset class="form-group">
|
||||
<label for="bookmark_tags">Tags</label>
|
||||
<input id="bookmark_tags" type="text" name="bookmark_tags"
|
||||
placeholder="tags, divided bij comma's"
|
||||
x-model="$store.digimarks.bookmarkToEdit.tags">
|
||||
</fieldset>
|
||||
<p x-show="$store.digimarks.bookmarkToEditError"
|
||||
x-text="$store.digimarks.bookmarkToEditError" x-cloak class="error"></p>
|
||||
<p>
|
||||
<label>
|
||||
<input type="checkbox" x-model="$store.digimarks.bookmarkToEdit.strip_params"/>
|
||||
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
|
||||
</label>
|
||||
</p>
|
||||
<div>
|
||||
<button value="cancel">Cancel</button>
|
||||
<button @click="$store.digimarks.saveBookmark()">Save</button>
|
||||
<button @click="$store.digimarks.addBookmark()">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</dialog>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user