1
0
mirror of https://github.com/aquatix/digimarks.git synced 2025-12-06 23:05:10 +01:00

18 Commits

Author SHA1 Message Date
79be98abea docstrings for module files 2025-11-06 13:53:00 +01:00
a7498a2fba Formatting and docstring improvements to the DB migrations 2025-11-06 13:50:48 +01:00
8810a47faa Rounder chips 2025-11-02 17:50:15 +01:00
cae9ebf3ef Made code more robust against missing cache items 2025-10-30 16:18:54 +01:00
5eb9c606f0 button-groups as component for grouping 'tab-like' buttons 2025-10-28 21:06:34 +01:00
894f97a25e Adjust to project nesting 2025-10-28 17:20:00 +01:00
8ccb18839f digimarks itself is a module too, make it so 2025-09-27 18:52:35 +02:00
dda209fa96 Make imports from the digimarks module more explicit 2025-09-26 22:02:57 +02:00
dcac963fa6 A bunch is already implemented 2025-09-23 21:58:38 +02:00
da28f2f781 Ensure empty form data 2025-09-23 15:36:08 +02:00
987a030c4f venv should be active for this command 2025-09-23 15:35:45 +02:00
bf6cd081f9 Typing and docstring improvements 2025-09-22 12:26:57 +02:00
651a7e4ece Fixed imports from same dir/module 2025-09-21 22:54:44 +02:00
63ebc33b04 Implementing add/edit bookmark form with auto-complete 2025-09-21 22:32:14 +02:00
5f2e2c37fa Be more explicit about some dependencies 2025-09-21 21:24:50 +02:00
21306f030e More uv usage 2025-09-21 18:31:40 +02:00
f05525a9cd Made .txt the same as .in for now 2025-09-20 22:09:54 +02:00
ac4ae2edd0 Refactoring tags endpoints to use tags_service 2025-09-14 22:10:40 +02:00
22 changed files with 402 additions and 528 deletions

View File

@@ -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

View File

@@ -30,7 +30,10 @@ necessary packages:
git clone https://github.com/aquatix/digimarks.git
cd digimarks
mkvirtualenv digimarks # or whatever project you are working on
pip install -r requirements.txt
# If you just want to run it, no need for development dependencies
uv sync --active --no-dev
# Otherwise, install everything in the active virtualenv
uv sync --active
Migrating from version 1

View File

@@ -0,0 +1 @@
"""Digimarks project."""

View File

@@ -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,

View File

@@ -1,15 +1,14 @@
"""Initial migration
"""Initial migration.
Revision ID: 115bcd2e1a38
Revises:
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 ###

View File

@@ -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 ###

View File

@@ -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'

View File

@@ -6,20 +6,19 @@ build-backend = "setuptools.build_meta"
name = "digimarks"
version = "1.1.99"
authors = [
{name = "Michiel Scholten", email = "michiel@diginaut.net"},
{ name = "Michiel Scholten", email = "michiel@diginaut.net" },
]
description='Simple bookmarking service, using a SQLite database to store bookmarks, supporting tags, automatic title fetching and REST API calls.'
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.7"
requires-python = ">=3.10"
keywords = ["bookmarks", "api"]
license = {text = "Apache"}
license = { text = "Apache" }
classifiers = [
"Framework :: FastAPI",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
]
dependencies = [
"importlib-metadata; python_version<'3.8'",
"fastapi[all]",
"sqlmodel",
"alembic",
@@ -30,6 +29,29 @@ dependencies = [
"extract_favicon",
"feedgen",
]
[dependency-groups]
dev = [
{include-group = "lint"},
{include-group = "pub"},
{include-group = "test"}
]
test = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
lint = [
"ruff>=0.1.0",
"mypy>=1.0.0",
]
# Publishing on PyPI
pub = [
"build",
"twine"
]
server = [
"gunicorn>=23.0.0",
]
# dynamic = ["version"]
[project.scripts]

View File

@@ -1,223 +1,11 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile requirements-dev.in
#
annotated-types==0.5.0
# via pydantic
anyio==3.7.1
# via
# httpcore
# starlette
# watchfiles
astroid==2.15.6
# via pylint
beautifulsoup4==4.12.2
# via bs4
black==23.7.0
# via -r requirements-dev.in
bleach==6.0.0
# via readme-renderer
bs4==0.0.1
# via -r requirements.in
build==0.10.0
# via -r requirements-dev.in
certifi==2023.7.22
# via
# httpcore
# httpx
# requests
cffi==1.15.1
# via cryptography
charset-normalizer==3.2.0
# via requests
click==8.1.6
# via
# black
# uvicorn
cryptography==41.0.2
# via secretstorage
dill==0.3.7
# via pylint
dnspython==2.4.1
# via email-validator
docutils==0.20.1
# via readme-renderer
email-validator==2.0.0.post2
# via fastapi
exceptiongroup==1.1.2
# via anyio
fastapi[all]==0.100.1
# via -r requirements.in
feedgen==0.9.0
# via -r requirements.in
greenlet==2.0.2
# via sqlalchemy
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==0.17.3
# via httpx
httptools==0.6.0
# via uvicorn
httpx==0.24.1
# via fastapi
idna==3.4
# via
# anyio
# email-validator
# httpx
# requests
importlib-metadata==6.8.0
# via
# keyring
# twine
isort==5.12.0
# via pylint
itsdangerous==2.1.2
# via fastapi
jaraco-classes==3.3.0
# via keyring
jeepney==0.8.0
# via
# keyring
# secretstorage
jinja2==3.1.2
# via fastapi
keyring==24.2.0
# via twine
lazy-object-proxy==1.9.0
# via astroid
lxml==4.9.3
# via feedgen
markdown-it-py==3.0.0
# via rich
markupsafe==2.1.3
# via jinja2
mccabe==0.7.0
# via pylint
mdurl==0.1.2
# via markdown-it-py
more-itertools==10.0.0
# via jaraco-classes
mypy-extensions==1.0.0
# via black
orjson==3.9.2
# via fastapi
packaging==23.1
# via
# black
# build
pathspec==0.11.2
# via black
pkginfo==1.9.6
# via twine
platformdirs==3.10.0
# via
# black
# pylint
pycparser==2.21
# via cffi
pydantic==2.1.1
# via
# fastapi
# pydantic-extra-types
# pydantic-settings
pydantic-core==2.4.0
# via pydantic
pydantic-extra-types==2.0.0
# via fastapi
pydantic-settings==2.0.2
# via fastapi
pygments==2.15.1
# via
# readme-renderer
# rich
pylint==2.17.5
# via -r requirements-dev.in
pyproject-hooks==1.0.0
# via build
python-dateutil==2.8.2
# via feedgen
python-dotenv==1.0.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.6
# via fastapi
pyyaml==6.0.1
# via
# fastapi
# uvicorn
readme-renderer==40.0
# via twine
requests==2.31.0
# via
# -r requirements.in
# requests-toolbelt
# twine
requests-toolbelt==1.0.0
# via twine
rfc3986==2.0.0
# via twine
rich==13.5.0
# via twine
ruff==0.0.280
# via -r requirements-dev.in
secretstorage==3.3.3
# via keyring
six==1.16.0
# via
# bleach
# python-dateutil
sniffio==1.3.0
# via
# anyio
# httpcore
# httpx
soupsieve==2.4.1
# via beautifulsoup4
sqlalchemy==2.0.19
# via -r requirements.in
starlette==0.27.0
# via fastapi
tomli==2.0.1
# via
# black
# build
# pylint
# pyproject-hooks
tomlkit==0.12.1
# via pylint
twine==4.0.2
# via -r requirements-dev.in
typing-extensions==4.7.1
# via
# astroid
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# uvicorn
ujson==5.8.0
# via fastapi
urllib3==2.0.4
# via
# requests
# twine
uvicorn[standard]==0.23.1
# via fastapi
uvloop==0.17.0
# via uvicorn
watchfiles==0.19.0
# via uvicorn
webencodings==0.5.1
# via bleach
websockets==11.0.3
# via uvicorn
wrapt==1.15.0
# via astroid
zipp==3.16.2
# via importlib-metadata
-r requirements.txt
# Linting and fixing, including isort
ruff
# Test suite
pytest
# Publishing on PyPI
build
twine

View File

@@ -1,9 +1,15 @@
# Core application
fastapi[all]
sqlmodel
sqlalchemy
pydantic
pydantic_settings
alembic
aiosqlite
# Fetch external resources
httpx
# Fetch title etc from links
beautifulsoup4

View File

@@ -1,121 +1,20 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
#
# pip-compile requirements.in
#
annotated-types==0.5.0
# via pydantic
anyio==3.7.1
# via
# httpcore
# starlette
# watchfiles
beautifulsoup4==4.12.2
# via bs4
bs4==0.0.1
# via -r requirements.in
certifi==2023.7.22
# via
# httpcore
# httpx
# requests
charset-normalizer==3.2.0
# via requests
click==8.1.6
# via uvicorn
dnspython==2.4.1
# via email-validator
email-validator==2.0.0.post2
# via fastapi
exceptiongroup==1.1.2
# via anyio
fastapi[all]==0.100.1
# via -r requirements.in
feedgen==0.9.0
# via -r requirements.in
greenlet==2.0.2
# via sqlalchemy
h11==0.14.0
# via
# httpcore
# uvicorn
httpcore==0.17.3
# via httpx
httptools==0.6.0
# via uvicorn
httpx==0.24.1
# via fastapi
idna==3.4
# via
# anyio
# email-validator
# httpx
# requests
itsdangerous==2.1.2
# via fastapi
jinja2==3.1.2
# via fastapi
lxml==4.9.3
# via feedgen
markupsafe==2.1.3
# via jinja2
orjson==3.9.2
# via fastapi
pydantic==2.1.1
# via
# fastapi
# pydantic-extra-types
# pydantic-settings
pydantic-core==2.4.0
# via pydantic
pydantic-extra-types==2.0.0
# via fastapi
pydantic-settings==2.0.2
# via fastapi
python-dateutil==2.8.2
# via feedgen
python-dotenv==1.0.0
# via
# pydantic-settings
# uvicorn
python-multipart==0.0.6
# via fastapi
pyyaml==6.0.1
# via
# fastapi
# uvicorn
requests==2.31.0
# via -r requirements.in
six==1.16.0
# via python-dateutil
sniffio==1.3.0
# via
# anyio
# httpcore
# httpx
soupsieve==2.4.1
# via beautifulsoup4
sqlalchemy==2.0.19
# via -r requirements.in
starlette==0.27.0
# via fastapi
typing-extensions==4.7.1
# via
# fastapi
# pydantic
# pydantic-core
# sqlalchemy
# uvicorn
ujson==5.8.0
# via fastapi
urllib3==2.0.4
# via requests
uvicorn[standard]==0.23.1
# via fastapi
uvloop==0.17.0
# via uvicorn
watchfiles==0.19.0
# via uvicorn
websockets==11.0.3
# via uvicorn
# Core application
fastapi[all]
sqlmodel
sqlalchemy
pydantic
pydantic_settings
alembic
aiosqlite
# Fetch external resources
httpx
# Fetch title etc from links
beautifulsoup4
# Fetch favicons
extract_favicon
# Generate (atom) feeds for tags and such
feedgen

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""digimarks main module."""

View File

@@ -0,0 +1,3 @@
"""Top-level package for Digimarks."""
__author__ = """Michiel Scholten"""

View File

@@ -1,8 +1,9 @@
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
import logging
from collections.abc import Sequence
from datetime import UTC, datetime
from typing import Annotated, Sequence
from typing import Annotated
from urllib.parse import urlparse, urlunparse
import bs4
@@ -12,9 +13,9 @@ from fastapi import Query, Request
from pydantic import AnyUrl
from sqlmodel import select
from src.digimarks import tags_service, utils
from src.digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import Bookmark, Visibility
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'
@@ -29,7 +30,7 @@ def get_favicon(html_content: str, root_url: str) -> str:
# TODO: save the preferred image to file and return
async def set_information_from_source(logger, bookmark: Bookmark, request: Request) -> Bookmark:
async def set_information_from_source(bookmark: Bookmark, request: Request) -> Bookmark:
"""Request the title by requesting the source url."""
logger.info('Extracting information from url %s', bookmark.url)
try:

View File

@@ -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

View File

@@ -1,9 +1,10 @@
"""digimarks main module."""
import logging
from collections.abc import Sequence
from contextlib import asynccontextmanager
from datetime import UTC, datetime
from typing import Annotated, Sequence, Type
from typing import Annotated
import httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request
@@ -18,9 +19,9 @@ from sqlalchemy.orm import sessionmaker
from sqlmodel import desc, select
from sqlmodel.ext.asyncio.session import AsyncSession
from src.digimarks import bookmarks_service, tags_service
from src.digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
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'
@@ -33,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 = 'digimarks/static'
template_dir: DirectoryPath = 'digimarks/templates'
media_url: str = '/static/'
@@ -124,7 +127,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) -> type[User]:
"""Show user information."""
logger.info('User %d requested', user_id)
if system_key != settings.system_key:
@@ -288,25 +291,18 @@ async def list_tags_for_user(
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(
select(Bookmark).where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
)
bookmarks = result.all()
tags = []
for bookmark in bookmarks:
tags += bookmark.tag_list
return tags_service.clean_tags(tags)
return await tags_service.list_tags_for_user(session, user_key)
@app.get('/api/v1/{user_key}/tags/{tag_key}')
async def list_bookmarks_for_tag_for_user(
session: SessionDep,
user_key: str,
tag_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(select(Bookmark).where(Bookmark.user_key == user_key))
bookmarks = result.all()
return tags_service.list_tags_for_bookmarks(bookmarks)
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)
@app.get('/{user_key}', response_class=HTMLResponse)

View File

@@ -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')

View File

@@ -22,4 +22,12 @@
.thumbnail img {
/*width: 72px;*/
width: 60px;
}
}
#bookmarkEditForm fieldset {
border: none;
}
#bookmarkEditForm fieldset input, #bookmarkEditForm textarea, #bookmarkEditForm select, #bookmarkEditForm label {
width: 100%;
}

View File

@@ -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 */

View File

@@ -16,7 +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,
@@ -38,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(() => {
@@ -143,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"))
@@ -203,18 +212,53 @@ document.addEventListener('alpine:init', () => {
this.showBookmarksCards = !this.showBookmarksList;
},
resetEditBookmark() {
this.bookmarkToEdit = {
'url': '',
'title': '',
'note': '',
'tags': ''
}
},
async startAddingBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
this.bookmarkToEdit = {
'url': ''
}
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/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
// Bookmark form data
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(data);
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.bookmarkToEditVisible = false;
// this.show_bookmark_details = false;
},
async addBookmark() {

View File

@@ -1,8 +1,9 @@
"""Helper functions for tags used with Bookmark models."""
from sqlalchemy import Sequence
from sqlmodel import select
from src.digimarks.models import Bookmark
from digimarks.models import Bookmark, Visibility
def i_filter_false(predicate, iterable):
@@ -53,10 +54,13 @@ 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.tags_list
tags += bookmark.tag_list
return clean_tags(tags)
@@ -69,3 +73,30 @@ def set_tags(bookmark: Bookmark, new_tags: str) -> None:
tags_split = new_tags.split(',')
tags_clean = clean_tags(tags_split)
bookmark.tags = ','.join(tags_clean)
async def list_tags_for_user(
session,
user_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(
select(Bookmark).where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
)
bookmarks = result.all()
tags = []
for bookmark in bookmarks:
tags += bookmark.tag_list
return clean_tags(tags)
async def list_bookmarks_for_tag_for_user(
session,
user_key: str,
tag_key: str,
) -> list[str]:
"""List all tags in use by the user."""
result = await session.exec(select(Bookmark).where(Bookmark.user_key == user_key))
# TODO: filter on tag_key
bookmarks = result.all()
return list_tags_for_bookmarks(bookmarks)

View File

@@ -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 &darr;
</button>
@@ -44,9 +50,15 @@
<button @click="$store.digimarks.sortCreated('desc')"
:class="$store.digimarks.sortCreatedDesc && 'active'">date &uarr;
</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,19 +185,46 @@
</span>
</div>
#}
<form method="dialog">
<input type="text" name="">
<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>
<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">
</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>
</template>
</dialog>
</main>