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

12 Commits

17 changed files with 259 additions and 188 deletions

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

@@ -32,7 +32,7 @@ necessary packages:
mkvirtualenv digimarks # or whatever project you are working on mkvirtualenv digimarks # or whatever project you are working on
# If you just want to run it, no need for development dependencies # If you just want to run it, no need for development dependencies
uv sync --active --no-dev uv sync --active --no-dev
# Otherwise, install everything # Otherwise, install everything in the active virtualenv
uv sync --active uv sync --active

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,38 +20,41 @@ 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(
sa.Column('id', sa.Integer(), nullable=False), 'bookmark',
sa.Column('userkey', sa.String(length=255), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False), sa.Column('userkey', sa.String(length=255), nullable=False),
sa.Column('url', sa.String(length=255), nullable=False), sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), nullable=False), sa.Column('url', sa.String(length=255), nullable=False),
sa.Column('url_hash', sa.String(length=255), nullable=False), sa.Column('created_date', sa.DateTime(), nullable=False),
sa.Column('tags', sa.String(length=255), nullable=False), sa.Column('url_hash', sa.String(length=255), nullable=False),
sa.Column('http_status', sa.Integer(), nullable=False), sa.Column('tags', sa.String(length=255), nullable=False),
sa.Column('modified_date', sa.DateTime(), nullable=True), sa.Column('http_status', sa.Integer(), nullable=False),
sa.Column('favicon', sa.String(length=255), nullable=True), sa.Column('modified_date', sa.DateTime(), nullable=True),
sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True), sa.Column('favicon', sa.String(length=255), nullable=True),
sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True), sa.Column('starred', sa.Boolean(), server_default=sa.text('0'), nullable=True),
sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True), sa.Column('deleted_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True), sa.Column('status', sa.Integer(), server_default=sa.text('0'), nullable=True),
sa.PrimaryKeyConstraint('id') sa.Column('note', sa.Text(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id'),
) )
op.create_table('publictag', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), 'publictag',
sa.Column('tagkey', sa.String(length=255), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('userkey', sa.String(length=255), nullable=False), sa.Column('tagkey', sa.String(length=255), nullable=False),
sa.Column('tag', sa.String(length=255), nullable=False), sa.Column('userkey', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True), sa.Column('tag', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('created_date', sa.DateTime(), server_default=sa.text('(null)'), nullable=True),
sa.PrimaryKeyConstraint('id'),
) )
op.create_table('user', op.create_table(
sa.Column('id', sa.Integer(), nullable=False), 'user',
sa.Column('username', sa.String(length=255), nullable=False), sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=255), nullable=False), sa.Column('username', sa.String(length=255), nullable=False),
sa.Column('created_date', sa.DateTime(), nullable=False), sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True), sa.Column('created_date', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id') sa.Column('theme', sa.String(length=20), server_default=sa.text("'green'"), nullable=True),
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(
existing_type=sa.TEXT(), 'note',
type_=sqlmodel.sql.sqltypes.AutoString(), existing_type=sa.TEXT(),
nullable=True, type_=sqlmodel.sql.sqltypes.AutoString(),
existing_server_default=sa.text('(null)')) nullable=True,
batch_op.alter_column('starred', existing_server_default=sa.text('(null)'),
existing_type=sa.BOOLEAN(), )
nullable=False, batch_op.alter_column(
existing_server_default=sa.text('0')) 'starred', existing_type=sa.BOOLEAN(), nullable=False, existing_server_default=sa.text('0')
batch_op.alter_column('modified_date', )
existing_type=sa.DATETIME(), batch_op.alter_column('modified_date', existing_type=sa.DATETIME(), nullable=True)
nullable=True) batch_op.alter_column(
batch_op.alter_column('deleted_date', 'deleted_date', existing_type=sa.DATETIME(), nullable=True, existing_server_default=sa.text('(null)')
existing_type=sa.DATETIME(), )
nullable=True, batch_op.alter_column(
existing_server_default=sa.text('(null)')) 'status', existing_type=sa.INTEGER(), nullable=False, existing_server_default=sa.text('0')
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(
existing_type=sa.DATETIME(), 'created_date',
nullable=True, existing_type=sa.DATETIME(),
existing_server_default=sa.text(str(datetime.now(UTC)))) nullable=True,
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(
existing_type=sa.DATETIME(), 'publictag',
nullable=True, 'created_date',
existing_server_default=sa.text('(null)')) existing_type=sa.DATETIME(),
nullable=True,
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',
existing_type=sa.DATETIME(), 'deleted_date',
nullable=True, existing_type=sa.DATETIME(),
existing_server_default=sa.text('(null)')) nullable=True,
op.alter_column('bookmark', 'modified_date', existing_server_default=sa.text('(null)'),
existing_type=sa.DATETIME(), )
nullable=True) op.alter_column('bookmark', 'modified_date', existing_type=sa.DATETIME(), nullable=True)
op.alter_column('bookmark', 'starred', op.alter_column(
existing_type=sa.BOOLEAN(), 'bookmark', 'starred', existing_type=sa.BOOLEAN(), nullable=True, existing_server_default=sa.text('0')
nullable=True, )
existing_server_default=sa.text('0')) op.alter_column(
op.alter_column('bookmark', 'note', 'bookmark',
existing_type=sqlmodel.sql.sqltypes.AutoString(), 'note',
type_=sa.TEXT(), existing_type=sqlmodel.sql.sqltypes.AutoString(),
nullable=True, type_=sa.TEXT(),
existing_server_default=sa.text('(null)')) nullable=True,
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'

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

@@ -8,15 +8,15 @@ from urllib.parse import urlparse, urlunparse
import bs4 import bs4
import httpx import httpx
import tags_service
import utils
from exceptions import BookmarkNotFound
from extract_favicon import from_html from extract_favicon import from_html
from fastapi import Query, Request from fastapi import Query, Request
from models import Bookmark, Visibility
from pydantic import AnyUrl from pydantic import AnyUrl
from sqlmodel import select from sqlmodel import select
from digimarks import tags_service, utils
from digimarks.exceptions import BookmarkNotFound
from digimarks.models import Bookmark, Visibility
DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev' DIGIMARKS_USER_AGENT = 'digimarks/2.0.0-dev'
logger = logging.getLogger('digimarks') logger = logging.getLogger('digimarks')

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

View File

@@ -6,16 +6,12 @@ from contextlib import asynccontextmanager
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Annotated from typing import Annotated
import bookmarks_service
import httpx import httpx
import tags_service
from exceptions import BookmarkNotFound
from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from models import DEFAULT_THEME, Bookmark, User, Visibility
from pydantic import DirectoryPath, FilePath from pydantic import DirectoryPath, FilePath
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
@@ -23,6 +19,10 @@ 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 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' DIGIMARKS_VERSION = '2.0.0a1'
@@ -34,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 = 'digimarks/static'
template_dir: DirectoryPath = 'digimarks/templates'
media_url: str = '/static/' media_url: str = '/static/'
@@ -299,7 +301,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)

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

@@ -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,8 +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, bookmarkToEditError: null,
bookmarkToEditVisible: false,
/* Loading indicator */ /* Loading indicator */
loading: false, loading: false,
@@ -39,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(() => {
@@ -144,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"))
@@ -204,17 +212,21 @@ document.addEventListener('alpine:init', () => {
this.showBookmarksCards = !this.showBookmarksList; this.showBookmarksCards = !this.showBookmarksList;
}, },
async startAddingBookmark() { resetEditBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
this.bookmarkToEdit = { this.bookmarkToEdit = {
'url': '', 'url': '',
'title': '', 'title': '',
'note': '', 'note': '',
'tags': '' 'tags': ''
} }
},
async startAddingBookmark() {
/* Open 'add bookmark' page */
console.log('Start adding bookmark');
this.resetEditBookmark();
// 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() { async bookmarkURLChanged() {
@@ -246,6 +258,7 @@ document.addEventListener('alpine:init', () => {
}, },
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() {

View File

@@ -1,9 +1,10 @@
"""Helper functions for tags used with Bookmark models.""" """Helper functions for tags used with Bookmark models."""
from models import Bookmark, Visibility
from sqlalchemy import Sequence from sqlalchemy import Sequence
from sqlmodel import select from sqlmodel import select
from digimarks.models import Bookmark, Visibility
def i_filter_false(predicate, iterable): def i_filter_false(predicate, iterable):
"""Filter an iterable if predicate returns True. """Filter an iterable if predicate returns True.
@@ -53,7 +54,10 @@ def clean_tags(tags_list: list) -> list[str]:
def list_tags_for_bookmarks(bookmarks: Sequence[Bookmark]) -> list[str]: 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

@@ -11,9 +11,14 @@
<ul> <ul>
<li><h1>digimarks</h1></li> <li><h1>digimarks</h1></li>
<li> <li>
<button x-data @click="$store.digimarks.toggleTagPage()" <div class="button-group">
:class="$store.digimarks.showTags && 'active'">tags <button x-data @click="$store.digimarks.toggleTagPage()"
</button> :class="!$store.digimarks.showTags && 'active'">bookmarks
</button>
<button x-data @click="$store.digimarks.toggleTagPage()"
:class="$store.digimarks.showTags && 'active'">tags
</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,43 +185,46 @@
</span> </span>
</div> </div>
#} #}
<form method="dialog" id="bookmarkEditForm"> <template x-if="$store.digimarks.bookmarkToEditVisible">
<fieldset class="form-group"> <form method="dialog" id="bookmarkEditForm">
<label for="bookmark_url">URL</label> <fieldset class="form-group">
<input id="bookmark_url" type="text" name="bookmark_url" placeholder="url" <label for="bookmark_url">URL</label>
x-on:change.debounce="$store.digimarks.bookmarkURLChanged()" <input id="bookmark_url" type="text" name="bookmark_url" placeholder="url"
x-model="$store.digimarks.bookmarkToEdit.url"> x-on:change.debounce="$store.digimarks.bookmarkURLChanged()"
</fieldset> x-model="$store.digimarks.bookmarkToEdit.url">
<fieldset class="form-group"> </fieldset>
<label for="bookmark_title">Title</label> <fieldset class="form-group">
<input id="bookmark_title" type="text" name="bookmark_title" <label for="bookmark_title">Title</label>
placeholder="title (leave empty for autofetch)" <input id="bookmark_title" type="text" name="bookmark_title"
x-model="$store.digimarks.bookmarkToEdit.title"> placeholder="title (leave empty for autofetch)"
</fieldset> x-model="$store.digimarks.bookmarkToEdit.title">
<fieldset class="form-group"> </fieldset>
<label for="bookmark_note">Note</label> <fieldset class="form-group">
<textarea id="bookmark_note" type="text" name="bookmark_note" <label for="bookmark_note">Note</label>
x-model="$store.digimarks.bookmarkToEdit.note"> <textarea id="bookmark_note" type="text" name="bookmark_note"
x-model="$store.digimarks.bookmarkToEdit.note">
</textarea> </textarea>
</fieldset> </fieldset>
<fieldset class="form-group"> <fieldset class="form-group">
<label for="bookmark_tags">Tags</label> <label for="bookmark_tags">Tags</label>
<input id="bookmark_tags" type="text" name="bookmark_tags" <input id="bookmark_tags" type="text" name="bookmark_tags"
placeholder="tags, divided bij comma's" placeholder="tags, divided bij comma's"
x-model="$store.digimarks.bookmarkToEdit.tags"> x-model="$store.digimarks.bookmarkToEdit.tags">
</fieldset> </fieldset>
<p x-show="$store.digimarks.bookmarkToEditError" x-data="$store.digimarks.bookmarkToEditError"></p> <p x-show="$store.digimarks.bookmarkToEditError"
<p> x-data="$store.digimarks.bookmarkToEditError"></p>
<label> <p>
<input type="checkbox" name="strip" id="strip"/> <label>
<span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span> <input type="checkbox" name="strip" id="strip"/>
</label> <span>Strip parameters from url (like <em>?utm_source=social</em> - can break the link!)</span>
</p> </label>
<div> </p>
<button value="cancel">Cancel</button> <div>
<button @click="$store.digimarks.saveBookmark()">Save</button> <button value="cancel">Cancel</button>
</div> <button @click="$store.digimarks.saveBookmark()">Save</button>
</form> </div>
</form>
</template>
</dialog> </dialog>
</main> </main>