1
0
mirror of https://codeberg.org/diginaut/digimarks.git synced 2026-02-04 20:50:26 +01:00

36 Commits

Author SHA1 Message Date
dae6c5da18 Correctly update bookmark form, and show HTTP status on error 2026-01-04 21:43:21 +01:00
f68daf4ac0 Handle autocompletion, raise appropriate errors 2026-01-04 16:17:48 +01:00
be34c6e88f More complete direnv config 2026-01-04 14:59:50 +01:00
47a0f31ec3 Ignore env files, like dev.env 2026-01-04 12:11:00 +01:00
05fa94ef41 uvicorn is dep from fastapi already, we also need gunicorn for server 2026-01-04 11:41:18 +01:00
b4aff120c8 Dependency updates 2026-01-03 23:45:56 +01:00
82e4202482 No need to await this 2026-01-03 23:45:50 +01:00
9b03d51276 Better declaration of the httpx client 2026-01-03 23:45:39 +01:00
fe734d6dd8 Keep in account that there might be no modifications yet 2026-01-03 23:14:26 +01:00
2936a4815a Ignore some environment 2026-01-03 20:29:54 +01:00
09c685f2aa Typing fixes 2026-01-03 20:27:41 +01:00
0b08f0fa81 Codeberg and dependency config 2026-01-03 12:40:42 +01:00
77dd621280 Don't put the direnv config in Git, just provide example 2026-01-02 22:06:58 +01:00
a9f8236ee6 uv direnv 2025-12-31 14:19:34 +01:00
ac9e010808 uv direnv 2025-12-31 14:19:24 +01:00
21f5f34e4f 20250912: stub for content extraction 2025-12-24 17:33:19 +01:00
971ede6067 Dependency declarations; moved project to Codeberg 2025-12-11 17:58:21 +01:00
96a8946a9a Small fixes 2025-12-11 17:57:59 +01:00
14f09a2dfb Small fixes 2025-12-11 17:45:25 +01:00
9d813b7ea6 Added type checker 2025-12-11 17:27:31 +01:00
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
26 changed files with 503 additions and 221 deletions

1
.envrc.example Normal file
View File

@@ -0,0 +1 @@
layout uv

9
.gitignore vendored
View File

@@ -77,10 +77,15 @@ celerybeat-schedule
# dotenv # dotenv
.env .env
*.env
# direnv
.envrc
# virtualenv # virtualenv
venv/ venv/
ENV/ ENV/
.venv
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
@@ -93,6 +98,10 @@ ENV/
# vim # vim
*.swp *.swp
*.swo
# Zed editor
.zed
# digimarks # digimarks
static/favicons static/favicons

View File

@@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## TODO ## TODO
- Sorting of bookmarks
- Sort by title
- Sort by date
- Logging of actions
- Add new way of authentication and editing bookmark collections: - Add new way of authentication and editing bookmark collections:
https://github.com/aquatix/digimarks/issues/8 and https://github.com/aquatix/digimarks/issues/9 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 - 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) (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 - 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] ## [Unreleased]
### Added ### 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 ### Changed
- Moved from Flask to FastAPI - Moved from Flask to FastAPI

View File

@@ -27,10 +27,14 @@ necessary packages:
.. code-block:: bash .. code-block:: bash
git clone https://github.com/aquatix/digimarks.git git clone https://codeberg.org/diginaut/digimarks.git
cd digimarks cd digimarks
mkvirtualenv digimarks # or whatever project you are working on # direnv will now create or activate a virtualenv
pip install -r requirements.txt # 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 in the active virtualenv
uv sync --active
Migrating from version 1 Migrating from version 1
@@ -121,7 +125,7 @@ Attributions
'M' favicon by `Freepik`_. '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 .. |PyPI version| image:: https://img.shields.io/pypi/v/digimarks.svg
:target: https://pypi.python.org/pypi/digimarks/ :target: https://pypi.python.org/pypi/digimarks/
.. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg .. |PyPI license| image:: https://img.shields.io/github/license/aquatix/digimarks.svg
@@ -132,11 +136,11 @@ Attributions
.. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b .. |Codacy| image:: https://api.codacy.com/project/badge/Grade/9a34319d917b43219a29e59e9ac75e3b
:alt: Codacy Badge :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 :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 .. _hook settings: https://codeberg.org/diginaut/digimarks/blob/master/example_config/examples.yaml
.. _vhost for Apache2.4: https://github.com/aquatix/digimarks/blob/master/example_config/apache_vhost.conf .. _vhost for Apache2.4: https://codeberg.org/diginaut/digimarks/blob/master/example_config/apache_vhost.conf
.. _uwsgi.ini: https://github.com/aquatix/digimarks/blob/master/example_config/uwsgi.ini .. _uwsgi.ini: https://codeberg.org/diginaut/digimarks/blob/master/example_config/uwsgi.ini
.. _Changelog: https://github.com/aquatix/digimarks/blob/master/CHANGELOG.md .. _Changelog: https://codeberg.org/diginaut/digimarks/blob/master/CHANGELOG.md
.. _Freepik: http://www.flaticon.com/free-icon/letter-m_2041 .. _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 .. _systemd for digimarks API: https://codeberg.org/diginaut/digimarks/blob/master/example_config/systemd/digimarks.service
.. _gunicorn config: https://github.com/aquatix/digimarks/blob/master/example_config/gunicorn_digimarks_conf.py .. _gunicorn config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config/uwsgi.ini
.. _more config: https://github.com/aquatix/digimarks/tree/master/example_config .. _more config: https://codeberg.org/diginaut/digimarks/src/branch/master/example_config

View File

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

View File

@@ -1,3 +1,5 @@
"""Alembic environment file for SQLAlchemy."""
import asyncio import asyncio
from logging.config import fileConfig from logging.config import fileConfig
@@ -7,7 +9,7 @@ from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlmodel import SQLModel 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 # this is the Alembic Config object, which provides
# access to the values within the .ini file in use. # 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: def do_run_migrations(connection: Connection) -> None:
"""Run the migrations."""
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,

View File

@@ -1,15 +1,14 @@
"""Initial migration """Initial migration.
Revision ID: 115bcd2e1a38 Revision ID: 115bcd2e1a38
Revises: Revises:
Create Date: 2025-09-12 16:06:16.479075 Create Date: 2025-09-12 16:06:16.479075
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '115bcd2e1a38' revision: str = '115bcd2e1a38'
@@ -21,7 +20,8 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.create_table('bookmark', op.create_table(
'bookmark',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('userkey', sa.String(length=255), nullable=False), sa.Column('userkey', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False), sa.Column('title', sa.String(length=255), nullable=False),
@@ -36,23 +36,25 @@ def upgrade() -> None:
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), 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('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True), sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id'),
) )
op.create_table('publictag', op.create_table(
'publictag',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tagkey', sa.String(length=255), nullable=False), sa.Column('tagkey', sa.String(length=255), nullable=False),
sa.Column('userkey', 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('tag', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True), sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id'),
) )
op.create_table('user', op.create_table(
'user',
sa.Column('id', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=255), nullable=False), sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('key', 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('created_date', sa.DateTime(), nullable=False),
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True), sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
sa.PrimaryKeyConstraint('id') sa.PrimaryKeyConstraint('id'),
) )
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -3,15 +3,14 @@
Revision ID: a8d8e45f60a1 Revision ID: a8d8e45f60a1
Revises: 115bcd2e1a38 Revises: 115bcd2e1a38
Create Date: 2025-09-12 16:10:41.378716 Create Date: 2025-09-12 16:10:41.378716
""" """
from datetime import UTC, datetime
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
from datetime import UTC, datetime
import sqlalchemy as sa import sqlalchemy as sa
import sqlmodel import sqlmodel
from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'a8d8e45f60a1' revision: str = 'a8d8e45f60a1'
@@ -24,72 +23,74 @@ def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('bookmark', schema=None) as batch_op: with op.batch_alter_table('bookmark', schema=None) as batch_op:
batch_op.alter_column('note', batch_op.alter_column(
'note',
existing_type=sa.TEXT(), existing_type=sa.TEXT(),
type_=sqlmodel.sql.sqltypes.AutoString(), type_=sqlmodel.sql.sqltypes.AutoString(),
nullable=True, nullable=True,
existing_server_default=sa.text('(null)')) existing_server_default=sa.text('(null)'),
batch_op.alter_column('starred', )
existing_type=sa.BOOLEAN(), batch_op.alter_column(
nullable=False, 'starred', existing_type=sa.BOOLEAN(), nullable=False, existing_server_default=sa.text('0')
existing_server_default=sa.text('0')) )
batch_op.alter_column('modified_date', batch_op.alter_column('modified_date', existing_type=sa.DATETIME(), nullable=True)
existing_type=sa.DATETIME(), batch_op.alter_column(
nullable=True) 'deleted_date', existing_type=sa.DATETIME(), nullable=True, existing_server_default=sa.text('(null)')
batch_op.alter_column('deleted_date', )
existing_type=sa.DATETIME(), batch_op.alter_column(
nullable=True, 'status', existing_type=sa.INTEGER(), nullable=False, existing_server_default=sa.text('0')
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']) batch_op.create_foreign_key('bookmark_user', 'user', ['userkey'], ['key'])
with op.batch_alter_table('publictag', schema=None) as batch_op: with op.batch_alter_table('publictag', schema=None) as batch_op:
batch_op.alter_column('created_date', batch_op.alter_column(
'created_date',
existing_type=sa.DATETIME(), existing_type=sa.DATETIME(),
nullable=True, nullable=True,
existing_server_default=sa.text(str(datetime.now(UTC)))) existing_server_default=sa.text(str(datetime.now(UTC))),
)
batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key']) batch_op.create_foreign_key('publictag_user', 'user', ['userkey'], ['key'])
with op.batch_alter_table('user', schema=None) as batch_op: with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('theme', batch_op.alter_column(
existing_type=sa.VARCHAR(length=20), 'theme', existing_type=sa.VARCHAR(length=20), nullable=False, existing_server_default=sa.text("'green'")
nullable=False, )
existing_server_default=sa.text("'green'"))
# ### end Alembic commands ### # ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema.""" """Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
op.alter_column('user', 'theme', op.alter_column(
existing_type=sa.VARCHAR(length=20), 'user', 'theme', existing_type=sa.VARCHAR(length=20), nullable=True, existing_server_default=sa.text("'green'")
nullable=True, )
existing_server_default=sa.text("'green'"))
op.drop_constraint(None, 'publictag', type_='foreignkey') op.drop_constraint(None, 'publictag', type_='foreignkey')
op.alter_column('publictag', 'created_date', op.alter_column(
'publictag',
'created_date',
existing_type=sa.DATETIME(), existing_type=sa.DATETIME(),
nullable=True, nullable=True,
existing_server_default=sa.text('(null)')) existing_server_default=sa.text('(null)'),
)
op.drop_constraint(None, 'bookmark', type_='foreignkey') op.drop_constraint(None, 'bookmark', type_='foreignkey')
op.alter_column('bookmark', 'status', op.alter_column(
existing_type=sa.INTEGER(), 'bookmark', 'status', existing_type=sa.INTEGER(), nullable=True, existing_server_default=sa.text('0')
nullable=True, )
existing_server_default=sa.text('0')) op.alter_column(
op.alter_column('bookmark', 'deleted_date', 'bookmark',
'deleted_date',
existing_type=sa.DATETIME(), existing_type=sa.DATETIME(),
nullable=True, nullable=True,
existing_server_default=sa.text('(null)')) existing_server_default=sa.text('(null)'),
op.alter_column('bookmark', 'modified_date', )
existing_type=sa.DATETIME(), op.alter_column('bookmark', 'modified_date', existing_type=sa.DATETIME(), nullable=True)
nullable=True) op.alter_column(
op.alter_column('bookmark', 'starred', 'bookmark', 'starred', existing_type=sa.BOOLEAN(), nullable=True, existing_server_default=sa.text('0')
existing_type=sa.BOOLEAN(), )
nullable=True, op.alter_column(
existing_server_default=sa.text('0')) 'bookmark',
op.alter_column('bookmark', 'note', 'note',
existing_type=sqlmodel.sql.sqltypes.AutoString(), existing_type=sqlmodel.sql.sqltypes.AutoString(),
type_=sa.TEXT(), type_=sa.TEXT(),
nullable=True, nullable=True,
existing_server_default=sa.text('(null)')) existing_server_default=sa.text('(null)'),
)
# ### end Alembic commands ### # ### end Alembic commands ###

View File

@@ -1,16 +1,13 @@
"""Renamed keys """Renamed keys.
Revision ID: b8cbc6957df5 Revision ID: b8cbc6957df5
Revises: a8d8e45f60a1 Revises: a8d8e45f60a1
Create Date: 2025-09-12 22:26:38.684120 Create Date: 2025-09-12 22:26:38.684120
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'b8cbc6957df5' revision: str = 'b8cbc6957df5'

View File

@@ -6,20 +6,19 @@ build-backend = "setuptools.build_meta"
name = "digimarks" name = "digimarks"
version = "1.1.99" version = "1.1.99"
authors = [ 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" readme = "README.rst"
requires-python = ">=3.7" requires-python = ">=3.11"
keywords = ["bookmarks", "api"] keywords = ["bookmarks", "api"]
license = {text = "Apache"} license = { text = "Apache" }
classifiers = [ classifiers = [
"Framework :: FastAPI", "Framework :: FastAPI",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
] ]
dependencies = [ dependencies = [
"importlib-metadata; python_version<'3.8'",
"fastapi[all]", "fastapi[all]",
"sqlmodel", "sqlmodel",
"alembic", "alembic",
@@ -30,14 +29,42 @@ dependencies = [
"extract_favicon", "extract_favicon",
"feedgen", "feedgen",
] ]
[project.optional-dependencies]
server = [
"uvicorn",
]
[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",
"pyrefly",
]
# Publishing on PyPI
pub = [
"build",
"twine"
]
server = [
"gunicorn>=23.0.0",
]
# dynamic = ["version"] # dynamic = ["version"]
[project.scripts] [project.scripts]
my-script = "digimarks:app" my-script = "digimarks:app"
[project.urls] [project.urls]
"Homepage" = "https://github.com/aquatix/digimarks" "Homepage" = "https://codeberg.org/diginaut/digimarks"
"Bug Tracker" = "https://github.com/aquatix/digimarks/issues" "Bug Tracker" = "https://codeberg.org/diginaut/digimarks/issues"
[tool.black] [tool.black]
line-length = 120 line-length = 120

View File

@@ -2,9 +2,12 @@
# Linting and fixing, including isort # Linting and fixing, including isort
ruff ruff
# Typing
pyrefly
# Test suite # Test suite
pytest pytest
pytest-cov
# Publishing on PyPI # Publishing on PyPI
build build

View File

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

View File

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

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,20 +1,22 @@
"""Bookmark helper functions, like content scrapers, favicon extractor, updater functions.""" """Bookmark helper functions, like content scrapers, favicon extractor, updater functions."""
import logging import logging
from collections.abc import Sequence
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Sequence from typing import Annotated
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
import bs4 import bs4
import httpx import httpx
from extract_favicon import from_html from extract_favicon import from_html
from fastapi import Query, Request from fastapi import Query, Request
from pydantic import AnyUrl from fastapi.exceptions import HTTPException
from pydantic import AnyUrl, ValidationError
from sqlmodel import select from sqlmodel import select
from src.digimarks import tags_service, utils from digimarks import tags_service, utils
from src.digimarks.exceptions import BookmarkNotFound from digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import Bookmark, Visibility from digimarks.models import Bookmark, Visibility
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev' DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
@@ -29,12 +31,15 @@ def get_favicon(html_content: str, root_url: str) -> str:
# TODO: save the preferred image to file and return # 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.""" """Request the title by requesting the source url."""
logger.info('Extracting information from url %s', bookmark.url) logger.info('Extracting information from url %s', bookmark.url)
try: 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 bookmark.http_status = result.status_code
logger.info('HTTP status code %s for %s', bookmark.http_status, bookmark.url)
except httpx.HTTPError as err: except httpx.HTTPError as err:
# For example, "MissingSchema: Invalid URL 'abc': No schema supplied. Perhaps you meant http://abc?" # 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)) logger.error('Exception when trying to retrieve title for %s. Error: %s', bookmark.url, str(err))
@@ -42,11 +47,12 @@ async def set_information_from_source(logger, bookmark: Bookmark, request: Reque
bookmark.title = '' bookmark.title = ''
return bookmark return bookmark
if bookmark.http_status == 200 or bookmark.http_status == 202: 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: try:
bookmark.title = html.title.text.strip() bookmark.title = html_content.title.text.strip()
except AttributeError: except AttributeError as exc:
bookmark.title = '' 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)) url_parts = urlparse(str(bookmark.url))
root_url = url_parts.scheme + '://' + url_parts.netloc root_url = url_parts.scheme + '://' + url_parts.netloc
@@ -71,11 +77,15 @@ def strip_url_params(url: str) -> str:
return urlunparse((parsed.scheme, parsed.netloc, parsed.path, parsed.params, '', parsed.fragment)) 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.""" """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: if not bookmark.title:
# Title was empty, automatically fetch it from the url, will also update the status code # 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: if strip_params:
# Strip URL parameters, e.g., tracking params # Strip URL parameters, e.g., tracking params
@@ -91,7 +101,10 @@ async def list_bookmarks_for_user(
offset: int = 0, offset: int = 0,
limit: Annotated[int, Query(le=10000)] = 100, limit: Annotated[int, Query(le=10000)] = 100,
) -> Sequence[Bookmark]: ) -> 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( result = await session.exec(
select(Bookmark) select(Bookmark)
.where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED) .where(Bookmark.user_key == user_key, Bookmark.status != Visibility.DELETED)
@@ -120,12 +133,17 @@ async def autocomplete_bookmark(
user_key: str, user_key: str,
bookmark: Bookmark, bookmark: Bookmark,
strip_params: bool = False, strip_params: bool = False,
): ) -> Bookmark:
"""Autofill some fields for this (new) bookmark for user `user_key`.""" """Autofill some fields for this (new) bookmark for user `user_key`."""
bookmark.user_key = user_key bookmark.user_key = user_key
# Auto-fill title, fix tags etc. # 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)) url_hash = utils.generate_hash(str(bookmark.url))
result = await session.exec( result = await session.exec(
@@ -148,12 +166,12 @@ async def add_bookmark(
user_key: str, user_key: str,
bookmark: Bookmark, bookmark: Bookmark,
strip_params: bool = False, strip_params: bool = False,
): ) -> Bookmark:
"""Add new bookmark for user `user_key`.""" """Add new bookmark for user `user_key`."""
bookmark.user_key = user_key bookmark.user_key = user_key
# Auto-fill title, fix tags etc. # 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)) bookmark.url_hash = utils.generate_hash(str(bookmark.url))
logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key) logger.info('Adding bookmark %s for user %s', bookmark.url_hash, user_key)
@@ -170,7 +188,7 @@ async def update_bookmark(
bookmark: Bookmark, bookmark: Bookmark,
url_hash: str, url_hash: str,
strip_params: bool = False, strip_params: bool = False,
): ) -> Bookmark:
"""Update existing bookmark `bookmark_key` for user `user_key`.""" """Update existing bookmark `bookmark_key` for user `user_key`."""
result = await session.exec( result = await session.exec(
select(Bookmark).where( select(Bookmark).where(
@@ -189,7 +207,7 @@ async def update_bookmark(
bookmark_db.sqlmodel_update(bookmark_data) bookmark_db.sqlmodel_update(bookmark_data)
# Autofill title, fix tags, etc. where (still) needed # 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) session.add(bookmark_db)
await session.commit() await session.commit()
@@ -201,7 +219,7 @@ async def delete_bookmark(
session, session,
user_key: str, user_key: str,
url_hash: str, url_hash: str,
): ) -> None:
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`.""" """(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key}) result = await session.get(Bookmark, {'url_hash': url_hash, 'user_key': user_key})
bookmark = result bookmark = result

View File

@@ -2,20 +2,24 @@
class BookmarkNotFound(Exception): 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. """Initialise the exception.
:param str message: The message for the exception :param str message: The message for the exception
""" """
super().__init__(message) super().__init__(message)
self.message = message self.message: str = message
class BookmarkAlreadyExists(Exception): 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. """Initialise the exception.
:param str message: The message for the exception :param str message: The message for the exception
""" """
super().__init__(message) super().__init__(message)
self.message = message self.message: str = message

6
src/digimarks/extract.py Normal file
View File

@@ -0,0 +1,6 @@
from pydantic import AnyUrl
def extract_contents(title: str, url: AnyUrl, note: str):
"""Extract contents from a URL."""
return

View File

@@ -1,9 +1,10 @@
"""digimarks main module.""" """digimarks main module."""
import logging import logging
from collections.abc import Sequence
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated, Sequence, Type from typing import Annotated, AsyncGenerator, cast
import httpx import httpx
from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi import Depends, FastAPI, HTTPException, Query, Request
@@ -18,9 +19,9 @@ from sqlalchemy.orm import sessionmaker
from sqlmodel import desc, select from sqlmodel import desc, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from src.digimarks import bookmarks_service, tags_service from digimarks import bookmarks_service, tags_service
from src.digimarks.exceptions import BookmarkNotFound from digimarks.exceptions import BookmarkNotFound
from src.digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility from digimarks.models import DEFAULT_THEME, Bookmark, User, Visibility
DIGIMARKS_VERSION = '2.0.0a1' DIGIMARKS_VERSION = '2.0.0a1'
@@ -33,8 +34,10 @@ class Settings(BaseSettings):
favicons_dir: DirectoryPath favicons_dir: DirectoryPath
# inside the codebase # inside the codebase
static_dir: DirectoryPath = 'static' # static_dir: DirectoryPath = Path('digimarks/static')
template_dir: DirectoryPath = 'templates' # template_dir: DirectoryPath = Path('digimarks/templates')
static_dir: DirectoryPath = DirectoryPath('digimarks/static')
template_dir: DirectoryPath = DirectoryPath('digimarks/templates')
media_url: str = '/static/' media_url: str = '/static/'
@@ -51,22 +54,32 @@ engine = create_async_engine(f'sqlite+aiosqlite:///{settings.database_file}', co
async def get_session() -> AsyncSession: async def get_session() -> AsyncSession:
"""SQLAlchemy session factory.""" """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: async with async_session() as session:
yield session yield session
# Shorter way of getting the DB session in an endpoint
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@asynccontextmanager @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.""" """Upon start, initialise an AsyncClient and assign it to an attribute named requests_client on the app object."""
the_app.requests_client = httpx.AsyncClient() async with httpx.AsyncClient() as requests_client:
the_app.state.requests_client = requests_client
yield yield
await the_app.requests_client.aclose() 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 = FastAPI(lifespan=lifespan)
app.mount('/static', StaticFiles(directory=settings.static_dir), name='static') app.mount('/static', StaticFiles(directory=settings.static_dir), name='static')
app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons') app.mount('/content/favicons', StaticFiles(directory=settings.favicons_dir), name='favicons')
@@ -124,7 +137,7 @@ def index(request: Request):
@app.get('/api/v1/admin/{system_key}/users/{user_id}', response_model=User) @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.""" """Show user information."""
logger.info('User %d requested', user_id) logger.info('User %d requested', user_id)
if system_key != settings.system_key: if system_key != settings.system_key:
@@ -199,7 +212,7 @@ async def autocomplete_bookmark(
user_key: str, user_key: str,
bookmark: Bookmark, bookmark: Bookmark,
strip_params: bool = False, strip_params: bool = False,
): ) -> Bookmark:
"""Autofill some fields for this (new) bookmark for user `user_key`.""" """Autofill some fields for this (new) bookmark for user `user_key`."""
logger.info('Autocompleting bookmark %s for user %s', bookmark.url_hash, 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) return await bookmarks_service.autocomplete_bookmark(session, request, user_key, bookmark, strip_params)
@@ -245,7 +258,7 @@ async def delete_bookmark(
"""(Soft)Delete bookmark `bookmark_key` for user `user_key`.""" """(Soft)Delete bookmark `bookmark_key` for user `user_key`."""
logger.info('Deleting bookmark %s for user %s', url_hash, user_key) logger.info('Deleting bookmark %s for user %s', url_hash, user_key)
try: 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} return {'ok': True}
except Exception: except Exception:
logger.exception('Failed to delete bookmark %s', url_hash) logger.exception('Failed to delete bookmark %s', url_hash)
@@ -272,12 +285,24 @@ async def bookmarks_changed_since(
) )
latest_created_bookmark = result.first() 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 { return {
'current_time': datetime.now(UTC), 'current_time': datetime.now(UTC),
'latest_change': latest_modified_bookmark.modified_date, 'latest_change': latest_modified_datetime,
'latest_created': latest_created_bookmark.created_date, 'latest_created': latest_created_datetime,
'latest_modification': latest_modification, 'latest_modification': latest_modification,
} }
@@ -298,7 +323,7 @@ async def list_bookmarks_for_tag_for_user(
tag_key: str, tag_key: str,
) -> list[str]: ) -> list[str]:
"""List all tags in use by the user.""" """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) return await tags_service.list_bookmarks_for_tag_for_user(session, user_key, tag_key)
@@ -307,7 +332,7 @@ async def page_user_landing(
session: SessionDep, session: SessionDep,
request: Request, request: Request,
user_key: str, user_key: str,
): ) -> HTMLResponse:
"""HTML page with the main view for the user.""" """HTML page with the main view for the user."""
result = await session.exec(select(User).where(User.key == user_key)) result = await session.exec(select(User).where(User.key == user_key))
user = result.first() user = result.first()

View File

@@ -5,7 +5,7 @@ Contains the bookmarks administration, users, tags, public tags and more.
from datetime import UTC, datetime from datetime import UTC, datetime
from http import HTTPStatus from http import HTTPStatus
from typing import Optional, Type, TypeVar from typing import TypeVar
from pydantic import AnyUrl, computed_field from pydantic import AnyUrl, computed_field
from sqlmodel import AutoString, Field, SQLModel from sqlmodel import AutoString, Field, SQLModel
@@ -16,8 +16,6 @@ DEFAULT_THEME = 'freshgreen'
class User(SQLModel, table=True): class User(SQLModel, table=True):
"""User account.""" """User account."""
__tablename__ = 'user'
id: int = Field(primary_key=True) id: int = Field(primary_key=True)
username: str username: str
key: str key: str
@@ -28,23 +26,23 @@ class User(SQLModel, table=True):
class Visibility: class Visibility:
"""Options for visibility of an object.""" """Options for visibility of an object."""
VISIBLE = 0 VISIBLE: int = 0
DELETED = 1 DELETED: int = 1
HIDDEN = 2 HIDDEN: int = 2
# Type var used for building custom types for the DB # Type var used for building custom types for the DB
T = TypeVar('T') 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. """Create a type that is compatible with the database.
Based on https://github.com/fastapi/sqlmodel/discussions/847 Based on https://github.com/fastapi/sqlmodel/discussions/847
""" """
class CustomType(AutoString): 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: if value is None:
return None return None
@@ -57,7 +55,7 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
return str(value) 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: if value is None:
return None return None
@@ -69,8 +67,6 @@ def build_custom_type(internal_type: Type[T]) -> Type[AutoString]:
class Bookmark(SQLModel, table=True): class Bookmark(SQLModel, table=True):
"""Bookmark object.""" """Bookmark object."""
__tablename__ = 'bookmark'
id: int = Field(primary_key=True) id: int = Field(primary_key=True)
user_key: str = Field(foreign_key='user.key', nullable=False) user_key: str = Field(foreign_key='user.key', nullable=False)
title: str = Field(default='', nullable=False) title: str = Field(default='', nullable=False)
@@ -93,7 +89,7 @@ class Bookmark(SQLModel, table=True):
@computed_field @computed_field
@property @property
def tag_list(self) -> list: def tag_list(self) -> list[str]:
"""The tags but as a proper list.""" """The tags but as a proper list."""
if self.tags: if self.tags:
return self.tags.split(',') return self.tags.split(',')
@@ -104,8 +100,6 @@ class Bookmark(SQLModel, table=True):
class PublicTag(SQLModel, table=True): class PublicTag(SQLModel, table=True):
"""Public tag object.""" """Public tag object."""
__tablename__ = 'publictag'
id: int = Field(primary_key=True) id: int = Field(primary_key=True)
tag_key: str tag_key: str
user_key: str = Field(foreign_key='user.key') user_key: str = Field(foreign_key='user.key')

View File

@@ -16,10 +16,18 @@
.thumbnail { .thumbnail {
/*width: 80px;*/ /*width: 80px;*/
width: 66; width: 66px;
} }
.thumbnail img { .thumbnail img {
/*width: 72px;*/ /*width: 72px;*/
width: 60px; width: 60px;
} }
#bookmarkEditForm fieldset {
border: none;
}
#bookmarkEditForm fieldset input, #bookmarkEditForm textarea, #bookmarkEditForm select, #bookmarkEditForm label {
width: 100%;
}

View File

@@ -3,7 +3,7 @@
* v0.0.2 * v0.0.2
* *
* Created by: Michiel Scholten * Created by: Michiel Scholten
* Source: https://github.com/aquatix/digui * Source: https://codeberg.org/diginaut/digui
*/ */
/** Colours and themes */ /** Colours and themes */
@@ -30,6 +30,7 @@
--border-color: #d5d9d9; --border-color: #d5d9d9;
--border-width: 1px; --border-width: 1px;
--border-radius: 8px; --border-radius: 8px;
--chip-border-radius: 2rem;
--shadow-color: rgba(213, 217, 217, .5); --shadow-color: rgba(213, 217, 217, .5);
--global-theme-toggle-content: ' 🌞'; --global-theme-toggle-content: ' 🌞';
@@ -77,6 +78,7 @@ html[data-theme='nebula-dark'] {
--border-color: #333; --border-color: #333;
--border-width: 1px; --border-width: 1px;
--border-radius: 8px; --border-radius: 8px;
--chip-border-radius: 2rem;
--shadow-color: rgba(3, 3, 3, .5); --shadow-color: rgba(3, 3, 3, .5);
--global-theme-toggle-content: ' 🌝'; --global-theme-toggle-content: ' 🌝';
} }
@@ -99,6 +101,7 @@ html[data-theme='bbs'] {
--border-color: #333; --border-color: #333;
--border-width: 2px; --border-width: 2px;
--border-radius: 0; --border-radius: 0;
--chip-border-radius: 0;
--global-theme-toggle-content: ' 🖥️'; --global-theme-toggle-content: ' 🖥️';
} }
@@ -124,6 +127,7 @@ html[data-theme='silo'] {
/*--border-color: #003eaa;*/ /*--border-color: #003eaa;*/
--border-width: 2px; --border-width: 2px;
--border-radius: 0; --border-radius: 0;
--chip-border-radius: 0;
--global-theme-toggle-content: ' ⌨️'; --global-theme-toggle-content: ' ⌨️';
} }
@@ -229,6 +233,7 @@ ol li::marker, ul li::marker {
.active { .active {
background-color: var(--color-highlight); background-color: var(--color-highlight);
color: var(--text-color); color: var(--text-color);
transition-duration: 0.2s;
} }
/* Special button */ /* Special button */
@@ -254,6 +259,7 @@ button, .button, input, select, textarea {
-webkit-user-select: none; -webkit-user-select: none;
touch-action: manipulation; touch-action: manipulation;
vertical-align: middle; vertical-align: middle;
transition-duration: 0.2s;
} }
button, .button, input, select, textarea, table { button, .button, input, select, textarea, table {
@@ -267,6 +273,7 @@ button:hover, .button:hover {
/*background-color: #d57803;*/ /*background-color: #d57803;*/
background-color: var(--color-highlight); background-color: var(--color-highlight);
filter: brightness(80%); filter: brightness(80%);
transition-duration: 0.2s;
} }
button:focus, .button:focus { button:focus, .button:focus {
@@ -303,6 +310,22 @@ button:focus, .button:focus {
filter: brightness(80%); 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 */ /* Table */
th { th {
@@ -334,6 +357,7 @@ th, td {
[data-theme='nebula'] .card, [data-theme='nebula'] .card,
[data-theme='nebula'] button, [data-theme='nebula'] button,
[data-theme='nebula'] .button, [data-theme='nebula'] .button,
[data-theme='nebula'] .button-group,
[data-theme='nebula'] input, [data-theme='nebula'] input,
[data-theme='nebula'] select, [data-theme='nebula'] select,
[data-theme='nebula'] textarea, [data-theme='nebula'] textarea,
@@ -342,6 +366,7 @@ th, td {
[data-theme='nebula-dark'] .card, [data-theme='nebula-dark'] .card,
[data-theme='nebula-dark'] button, [data-theme='nebula-dark'] button,
[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'] input,
[data-theme='nebula-dark'] select, [data-theme='nebula-dark'] select,
[data-theme='nebula-dark'] textarea, [data-theme='nebula-dark'] textarea,
@@ -419,7 +444,7 @@ th, td {
.chip { .chip {
font-size: .8rem; font-size: .8rem;
border-radius: var(--border-radius); border-radius: var(--chip-border-radius);
background-color: var(--background-color-secondary); background-color: var(--background-color-secondary);
color: var(--text-color-secondary); color: var(--text-color-secondary);
/*color: var(--text-color);*/ /*color: var(--text-color);*/
@@ -428,7 +453,7 @@ th, td {
} }
.chip .button { .chip .button {
border-radius: var(--border-radius); border-radius: var(--chip-border-radius);
} }
/* Status */ /* Status */

View File

@@ -16,7 +16,9 @@ document.addEventListener('alpine:init', () => {
showBookmarksCards: Alpine.$persist(false).as('showBookmarksCards'), showBookmarksCards: Alpine.$persist(false).as('showBookmarksCards'),
showTags: Alpine.$persist(false).as('showTags'), showTags: Alpine.$persist(false).as('showTags'),
/* Bookmark that is being edited, used to fill the form, etc. */ /* 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 indicator */
loading: false, loading: false,
@@ -38,6 +40,9 @@ document.addEventListener('alpine:init', () => {
/** Initialise the application after loading */ /** Initialise the application after loading */
document.documentElement.setAttribute('data-theme', this.theme); document.documentElement.setAttribute('data-theme', this.theme);
console.log('Set 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 */ /* Bookmarks are refreshed through the getBookmarks() call in the HTML page */
/* await this.getBookmarks(); */ /* await this.getBookmarks(); */
setInterval(() => { setInterval(() => {
@@ -100,7 +105,7 @@ document.addEventListener('alpine:init', () => {
this.cache[this.userKey]['tags'] = await tagsResponse.json(); this.cache[this.userKey]['tags'] = await tagsResponse.json();
/* Filter bookmarks by (blacklisted) tags */ /* Filter bookmarks by (blacklisted) tags */
await this.filterBookmarksByTags(); this.filterBookmarksByTags();
this.loading = false; this.loading = false;
}, },
@@ -143,6 +148,10 @@ document.addEventListener('alpine:init', () => {
) )
}, },
get filteredTags() { get filteredTags() {
if (this.cache[this.userKey].tags === undefined) {
console.log('Tags not yet cached');
return [];
}
/* Search in the list of all tags */ /* Search in the list of all tags */
return this.cache[this.userKey].tags.filter( return this.cache[this.userKey].tags.filter(
i => i.match(new RegExp(this.search, "i")) i => i.match(new RegExp(this.search, "i"))
@@ -203,23 +212,99 @@ document.addEventListener('alpine:init', () => {
this.showBookmarksCards = !this.showBookmarksList; this.showBookmarksCards = !this.showBookmarksList;
}, },
resetEditBookmark() {
this.bookmarkToEdit = {
'url_hash': '',
'url': '',
'title': '',
'note': '',
'tags': '',
'http_status': 0,
'strip_params': false
}
},
async startAddingBookmark() { async startAddingBookmark() {
/* Open 'add bookmark' page */ /* Open 'add bookmark' page */
console.log('Start adding bookmark'); console.log('Open "add bookmark" modal');
this.bookmarkToEdit = { this.resetEditBookmark();
'url': ''
}
// this.show_bookmark_details = true; // this.show_bookmark_details = true;
const editFormDialog = document.getElementById("editFormDialog"); const editFormDialog = document.getElementById("editFormDialog");
this.bookmarkToEditVisible = true;
editFormDialog.showModal(); 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/?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() { async saveBookmark() {
console.log('Saving bookmark'); console.log('Saving bookmark');
// this.bookmarkToEditVisible = false;
// this.show_bookmark_details = false; // this.show_bookmark_details = false;
}, },
async addBookmark() { async addBookmark() {
/* Post new bookmark to the backend */ /* 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'
},
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)
}
} }
}) })
}); });

View File

@@ -3,7 +3,7 @@
from sqlalchemy import Sequence from sqlalchemy import Sequence
from sqlmodel import select from sqlmodel import select
from src.digimarks.models import Bookmark, Visibility from digimarks.models import Bookmark, Visibility
def i_filter_false(predicate, iterable): def i_filter_false(predicate, iterable):
@@ -54,7 +54,10 @@ def clean_tags(tags_list: list) -> list[str]:
def list_tags_for_bookmarks(bookmarks: Sequence[Bookmark]) -> 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 = [] tags = []
for bookmark in bookmarks: for bookmark in bookmarks:
tags += bookmark.tag_list tags += bookmark.tag_list

View File

@@ -9,7 +9,7 @@
<ul> <ul>
<li><h1>digimarks</h1></li> <li><h1>digimarks</h1></li>
<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> </li>
</ul> </ul>
</nav> </nav>
@@ -18,7 +18,7 @@
<main> <main>
<h1>Welcome to digimarks, your online bookmarking and notes tool</h1> <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> project page</a>.</p>
<p>If you forgot/lost your personal url, contact your digimarks <p>If you forgot/lost your personal url, contact your digimarks

View File

@@ -11,9 +11,14 @@
<ul> <ul>
<li><h1>digimarks</h1></li> <li><h1>digimarks</h1></li>
<li> <li>
<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()" <button x-data @click="$store.digimarks.toggleTagPage()"
:class="$store.digimarks.showTags && 'active'">tags :class="$store.digimarks.showTags && 'active'">tags
</button> </button>
</div>
</li> </li>
<li> <li>
<button @click="$store.digimarks.startAddingBookmark()">add bookmark</button> <button @click="$store.digimarks.startAddingBookmark()">add bookmark</button>
@@ -32,6 +37,7 @@
<h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1> <h1 x-bind:title="$store.digimarks.userKey">Bookmarks</h1>
<p> <p>
<div class="button-group">
<button @click="$store.digimarks.sortAlphabetically()" <button @click="$store.digimarks.sortAlphabetically()"
:class="$store.digimarks.sortTitleAsc && 'active'">a-z &darr; :class="$store.digimarks.sortTitleAsc && 'active'">a-z &darr;
</button> </button>
@@ -44,9 +50,15 @@
<button @click="$store.digimarks.sortCreated('desc')" <button @click="$store.digimarks.sortCreated('desc')"
:class="$store.digimarks.sortCreatedDesc && 'active'">date &uarr; :class="$store.digimarks.sortCreatedDesc && 'active'">date &uarr;
</button> </button>
</div>
<div class="button-group">
<button @click="$store.digimarks.toggleListOrGrid()" <button @click="$store.digimarks.toggleListOrGrid()"
:class="$store.digimarks.showBookmarksCards && 'active'">list or grid :class="$store.digimarks.showBookmarksCards && 'active'">grid
</button> </button>
<button @click="$store.digimarks.toggleListOrGrid()"
:class="!$store.digimarks.showBookmarksCards && 'active'">list
</button>
</div>
</p> </p>
<table x-cloak x-show="$store.digimarks.showBookmarksList"> <table x-cloak x-show="$store.digimarks.showBookmarksList">
@@ -98,15 +110,18 @@
<div class="card-thumb" x-show="bookmark.favicon"><img <div class="card-thumb" x-show="bookmark.favicon"><img
x-bind:src="'/content/favicons/' + bookmark.favicon"></div> x-bind:src="'/content/favicons/' + bookmark.favicon"></div>
<div class="statuses"> <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>
<div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304" <div x-show="bookmark.http_status !== 200 && bookmark.http_status !== 304"
class="error"><i class="error"><i
class="fa-fw fa-solid fa-triangle-exclamation"></i> class="fa-fw fa-solid fa-triangle-exclamation"></i>
</div> </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>
<div><a x-text="bookmark.title" x-bind:href="bookmark.url" target="_blank"></a></div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button title="show actions"><i class="fa-solid fa-square-caret-down"></i></button> <button title="show actions"><i class="fa-solid fa-square-caret-down"></i></button>
@@ -170,19 +185,51 @@
</span> </span>
</div> </div>
#} #}
<form method="dialog"> <template x-if="$store.digimarks.bookmarkToEditVisible">
<input type="text" name=""> <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-text="$store.digimarks.bookmarkToEditError" x-cloak class="error"></p>
<p> <p>
<label> <label>
<input type="checkbox" name="strip" id="strip"/> <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> <span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</label> </label>
</p> </p>
<div> <div>
<button value="cancel">Cancel</button> <button value="cancel">Cancel</button>
<button @click="$store.digimarks.saveBookmark()">Save</button> <button @click="$store.digimarks.saveBookmark()">Save</button>
<button @click="$store.digimarks.addBookmark()">Add</button>
</div> </div>
</form> </form>
</template>
</dialog> </dialog>
</main> </main>