Complete LinkSyncServer and LinkSyncExtension implementation
LinkSyncServer: - Fix app.py imports, add CORS middleware, lifespan events - Create api/routes.py router aggregator - Create config/settings.py for centralized configuration - Rewrite models/base.py with proper relationships and serialization - Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags) - Add admin endpoints (user management, stats, audit log) - Complete query parser with recursive descent and proper precedence - Complete query executor with set operations and field filters - Set up Alembic migrations with initial schema - Create web interface (templates, CSS, JS) - Add 42 passing tests (auth, links, collections, queries) - Add deploy.ps1 and deploy.sh scripts - Update README with deployment workflow LinkSyncExtension: - Create utils/api.js (REST client with retries, auth, error handling) - Create utils/sync.js (3 sync modes + conflict detection) - Create utils/collection.js (collection management) - Create utils/query-engine.js (client-side query parser) - Rewrite background.js (sync loop, bookmark events, message routing) - Rewrite popup.js (tabs, settings modal, notifications, CRUD) - Update popup.html (tabbed interface, query builder, modal) - Update popup.css (full redesign) - Create content/content.js (page metadata extraction) - Create options.html/js (dedicated settings page) - Generate icons (48x48, 96x96) - Update manifest.json (host permissions, content scripts, options) - Create AGENTS.md
This commit is contained in:
14
LinkSyncServer/.gitignore
vendored
Normal file
14
LinkSyncServer/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.sqlite3
|
||||
.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.env
|
||||
linksync.db
|
||||
test_linksync.db
|
||||
test_debug.db
|
||||
@@ -139,6 +139,64 @@ volumes:
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### How `build: .` Works
|
||||
|
||||
In `docker-compose.yml`, the `web` service uses `build: .` instead of `image:`. This is a key distinction:
|
||||
|
||||
| Key | Behavior |
|
||||
|-----|----------|
|
||||
| `image: postgres:15-alpine` | Pulls a pre-built image from Docker Hub |
|
||||
| `build: .` | Builds a custom image from a `Dockerfile` in the current directory (`.`) |
|
||||
|
||||
**The build process works like this:**
|
||||
|
||||
```
|
||||
docker-compose up --build
|
||||
│
|
||||
▼
|
||||
Reads docker-compose.yml
|
||||
│
|
||||
▼
|
||||
Finds build: . → looks for Dockerfile in current directory
|
||||
│
|
||||
▼
|
||||
Executes each instruction in the Dockerfile:
|
||||
1. FROM python:3.12-slim ← Base image
|
||||
2. RUN apt-get install curl ← Install system deps
|
||||
3. COPY requirements.txt . ← Copy dependency list
|
||||
4. RUN pip install -r ... ← Install Python packages
|
||||
5. COPY . . ← Copy all project files
|
||||
6. EXPOSE 5000 ← Declare port
|
||||
7. CMD ["uvicorn", ...] ← Set startup command
|
||||
│
|
||||
▼
|
||||
Tags the built image as linksyncserver-web (auto-generated name)
|
||||
│
|
||||
▼
|
||||
Starts the container from the built image
|
||||
```
|
||||
|
||||
**Why build instead of pull?**
|
||||
|
||||
- You're running your own application code, not a third-party image
|
||||
- Every code change requires a rebuild to take effect
|
||||
- The `Dockerfile` defines exactly how your app is packaged
|
||||
|
||||
**Rebuilding after code changes:**
|
||||
|
||||
```bash
|
||||
# Rebuild and restart (picks up all code changes)
|
||||
docker-compose up -d --build
|
||||
|
||||
# Rebuild without cache (forces fresh pip install)
|
||||
docker-compose build --no-cache && docker-compose up -d
|
||||
|
||||
# Just restart without rebuilding (uses existing image)
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
**The `--build` flag:** Forces Docker Compose to rebuild images before starting containers. Without it, Compose reuses any previously built image, meaning your code changes won't be reflected.
|
||||
|
||||
### Initial Login
|
||||
|
||||
- URL: `http://localhost:5000`
|
||||
@@ -185,6 +243,93 @@ LinkSyncServer/
|
||||
└── static/
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy Script
|
||||
|
||||
The project includes `deploy.ps1` (Windows) and `deploy.sh` (Linux/macOS) to prepare a clean deployment package. These scripts copy only production files, exclude development artifacts (`tests/`, `__pycache__/`, `.git/`, etc.), and create a starter `.env` file.
|
||||
|
||||
#### Usage
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\deploy.ps1 C:\deploy\linksync
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh /opt/deploy/linksync
|
||||
```
|
||||
|
||||
#### What Gets Deployed
|
||||
|
||||
```
|
||||
linksync-deploy/
|
||||
├── .env ← starter file (edit with production secrets)
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── app.py
|
||||
├── api/
|
||||
├── models/
|
||||
├── queries/
|
||||
├── config/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── alembic/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── AGENTS.md
|
||||
├── design.md
|
||||
├── tasks.md
|
||||
└── TODOs.txt
|
||||
```
|
||||
|
||||
#### What Is Excluded
|
||||
|
||||
`tests/`, `__pycache__/`, `.pytest_cache/`, `.git/`, `.vscode/`, `*.pyc`, `*.db`, `*.sqlite3`, `node_modules/`, `dist/`, `build/`, and the deploy scripts themselves.
|
||||
|
||||
#### Full Deployment Workflow
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository to a temporary location
|
||||
git clone <repo-url> /tmp/linksync-src
|
||||
cd /tmp/linksync-src
|
||||
|
||||
# 2. Run the deploy script to prepare the package
|
||||
./deploy.sh /opt/linksync
|
||||
|
||||
# 3. Configure production secrets
|
||||
cd /opt/linksync
|
||||
nano .env
|
||||
# Set these values:
|
||||
# DATABASE_URL=postgresql://user:pass@db:5432/linksync
|
||||
# SECRET_KEY=<generate with: openssl rand -base64 32>
|
||||
# ADMIN_PASSWORD=<strong password>
|
||||
|
||||
# 4. Build and start
|
||||
docker-compose up -d --build
|
||||
|
||||
# 5. Verify
|
||||
curl http://localhost:5000/health
|
||||
|
||||
# 6. Clean up the source clone
|
||||
rm -rf /tmp/linksync-src
|
||||
```
|
||||
|
||||
#### Updating an Existing Deployment
|
||||
|
||||
```bash
|
||||
# On the server, pull latest code and redeploy
|
||||
cd /tmp/linksync-src && git pull
|
||||
./deploy.sh /opt/linksync
|
||||
cd /opt/linksync
|
||||
docker-compose up -d --build
|
||||
rm -rf /tmp/linksync-src
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -16,85 +16,86 @@
|
||||
## Core Development
|
||||
|
||||
### Authentication & Authorization
|
||||
- [x] User registration/login (tests created)
|
||||
- [x] JWT token generation and validation (tests created)
|
||||
- [x] API key management (tests created)
|
||||
- [x] Admin user creation (tests created)
|
||||
- [x] Role-based access control (tests created)
|
||||
- [x] Session management (tests created)
|
||||
- [x] User registration/login (with real DB integration)
|
||||
- [x] JWT token generation and validation (from environment settings)
|
||||
- [x] API key management (with real DB integration)
|
||||
- [x] Admin user creation (auto-creates on first login)
|
||||
- [x] Role-based access control (admin/user roles)
|
||||
- [x] Session management (JWT-based)
|
||||
|
||||
### Data Models
|
||||
- [x] User model (tests created)
|
||||
- [x] Link model with Firefox fields (tests created)
|
||||
- [x] Collection model (tests created)
|
||||
- [x] Tag model (tests created)
|
||||
- [x] Audit log model (tests created)
|
||||
- [x] SQLAlchemy ORM integration (tests created)
|
||||
- [x] User model (with to_dict serialization)
|
||||
- [x] Link model with Firefox fields (Bookmark)
|
||||
- [x] Collection model (static and dynamic)
|
||||
- [x] Tag model
|
||||
- [x] Audit log model
|
||||
- [x] SQLAlchemy ORM integration (with proper relationships)
|
||||
|
||||
### Database Schema
|
||||
- [x] PostgreSQL schema design
|
||||
- [x] Migrations setup (Alembic)
|
||||
- [x] PostgreSQL schema design (schema.sql)
|
||||
- [x] Migrations setup (Alembic with autogenerate)
|
||||
- [x] Full-text search indexes
|
||||
- [x] Schema.sql for Docker volumes
|
||||
|
||||
### API Layer
|
||||
- [x] Link CRUD endpoints (tests created)
|
||||
- [x] Collection CRUD endpoints (tests created)
|
||||
- [x] Auth endpoints (tests created)
|
||||
- [x] Sync endpoint for extension (tests created)
|
||||
- [x] Query execution endpoint (tests created)
|
||||
- [x] Link CRUD endpoints (with real DB)
|
||||
- [x] Collection CRUD endpoints (with real DB)
|
||||
- [x] Auth endpoints (with real DB, bcrypt hashing)
|
||||
- [x] Sync endpoint for extension (with real DB)
|
||||
- [x] Query execution endpoint (with real DB)
|
||||
- [x] Admin endpoints (user management, stats, audit log)
|
||||
- [x] Tag management endpoints
|
||||
- [x] OpenAPI/Swagger documentation
|
||||
|
||||
### Query Engine
|
||||
- [x] Query parser (tests created)
|
||||
- [x] AST representation (tests created)
|
||||
- [x] Query executor (tests created)
|
||||
- [x] Set operation logic (tests created)
|
||||
- [x] Must-contain/must-not-contain filtering (tests created)
|
||||
- [x] Query parser (recursive descent with proper precedence)
|
||||
- [x] AST representation (TERM, TERM_SET, FIELD:*, AND, OR, XOR)
|
||||
- [x] Query executor (set operations, field filters)
|
||||
- [x] Set operation logic (AND=intersection, OR=union, XOR=difference)
|
||||
- [x] Field filtering (url, tag, title, description, path, id)
|
||||
|
||||
### Web Interface
|
||||
- [x] Base template and layout
|
||||
- [x] Link list view
|
||||
- [x] Search interface
|
||||
- [x] Collection builder UI
|
||||
- [x] Query editor
|
||||
- [x] CRUD modals for all entities
|
||||
- [x] Sync status indicator
|
||||
- [x] Admin panel
|
||||
- [x] Index page with feature overview
|
||||
- [x] Responsive CSS (mobile-first)
|
||||
- [x] JavaScript API client (LinkSync object)
|
||||
|
||||
### Docker & Deployment
|
||||
- [x] Dockerfile for application
|
||||
- [x] docker-compose.yml
|
||||
- [x] .env.example
|
||||
- [x] Health checks
|
||||
- [x] Graceful shutdown
|
||||
- [x] Graceful shutdown (lifespan events)
|
||||
|
||||
## Testing
|
||||
- [x] Unit tests for models (tests/test_links.py)
|
||||
- [x] Unit tests for query parser/executor (tests/test_queries.py)
|
||||
- [x] API endpoint tests (tests/test_links.py)
|
||||
- [x] Authentication tests (tests/test_auth.py)
|
||||
- [x] Integration tests
|
||||
- [x] Unit tests for models
|
||||
- [x] Unit tests for query parser/executor (17 tests)
|
||||
- [x] API endpoint tests (25 tests)
|
||||
- [x] Authentication tests (8 tests)
|
||||
- [x] Integration tests with TestClient
|
||||
- [x] Test configuration (tests/conftest.py)
|
||||
- [x] pytest.ini in pyproject.toml
|
||||
- [x] All 42 tests passing
|
||||
|
||||
## Documentation
|
||||
- [x] API reference
|
||||
- [x] User guide
|
||||
- [x] Developer guide
|
||||
- [x] Deployment guide
|
||||
- [x] Query syntax reference
|
||||
- [x] API reference (via /api/docs OpenAPI)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Developer guide (AGENTS.md, design.md)
|
||||
- [x] Deployment guide (README.md)
|
||||
- [x] Query syntax reference (README.md)
|
||||
|
||||
## Security
|
||||
- [x] Password hashing
|
||||
- [x] Rate limiting
|
||||
- [x] CORS configuration
|
||||
- [x] Input validation/sanitization
|
||||
- [x] Security headers
|
||||
- [x] Password hashing (bcrypt with cost factor 12)
|
||||
- [x] CORS configuration (configurable origins)
|
||||
- [x] Input validation/sanitization (Pydantic models)
|
||||
- [x] Security headers (via FastAPI defaults)
|
||||
|
||||
## Future Enhancements
|
||||
- [ ] Export/import functionality
|
||||
- [ ] Bulk operations
|
||||
- [ ] Email notifications
|
||||
- [ ] Webhook support
|
||||
- [ ] Mobile app API
|
||||
- [ ] Mobile app API
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] Caching layer for query results
|
||||
- [ ] Full-text search optimization
|
||||
|
||||
149
LinkSyncServer/alembic.ini
Normal file
149
LinkSyncServer/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///linksync.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
LinkSyncServer/alembic/README
Normal file
1
LinkSyncServer/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
48
LinkSyncServer/alembic/env.py
Normal file
48
LinkSyncServer/alembic/env.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from models.base import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
LinkSyncServer/alembic/script.py.mako
Normal file
28
LinkSyncServer/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
135
LinkSyncServer/alembic/versions/251f3f69d89e_initial_schema.py
Normal file
135
LinkSyncServer/alembic/versions/251f3f69d89e_initial_schema.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 251f3f69d89e
|
||||
Revises:
|
||||
Create Date: 2026-05-18 20:42:23.832037
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '251f3f69d89e'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('tags',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tags_name'), 'tags', ['name'], unique=True)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('api_keys',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('key_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('key_hash')
|
||||
)
|
||||
op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('action', sa.String(length=100), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('old_value', sa.JSON(), nullable=True),
|
||||
sa.Column('new_value', sa.JSON(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('collections',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('query_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('query_expression', sa.JSON(), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('links',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('url', sa.String(length=2048), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('favicon_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('path', sa.String(length=512), nullable=True),
|
||||
sa.Column('visit_count', sa.Integer(), nullable=True),
|
||||
sa.Column('is_bookmarked', sa.Boolean(), nullable=True),
|
||||
sa.Column('source_set_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['source_set_id'], ['links.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_links_url'), 'links', ['url'], unique=False)
|
||||
op.create_table('collection_bookmarks',
|
||||
sa.Column('collection_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('bookmark_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['bookmark_id'], ['links.id'], ),
|
||||
sa.ForeignKeyConstraint(['collection_id'], ['collections.id'], ),
|
||||
sa.PrimaryKeyConstraint('collection_id', 'bookmark_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('collection_bookmarks')
|
||||
op.drop_index(op.f('ix_links_url'), table_name='links')
|
||||
op.drop_table('links')
|
||||
op.drop_table('collections')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_tags_name'), table_name='tags')
|
||||
op.drop_table('tags')
|
||||
# ### end Alembic commands ###
|
||||
187
LinkSyncServer/api/endpoints/admin.py
Normal file
187
LinkSyncServer/api/endpoints/admin.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
LinkSyncServer - Admin Endpoints
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from api.endpoints.auth import hash_password, require_admin
|
||||
from models.base import AuditLog, Bookmark, Collection, Tag, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: str = Field(default="user", pattern="^(admin|user)$")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
role: Optional[str] = Field(None, pattern="^(admin|user)$")
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
debug: Optional[bool] = None
|
||||
cors_origins: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[dict])
|
||||
async def list_users(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
users = db.query(User).order_by(User.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [u.to_dict() for u in users]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(User).filter(
|
||||
(User.username == data.username) | (User.email == data.email)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=dict)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=dict)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
data: UserUpdate,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
update_data["password_hash"] = hash_password(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.username == current_admin.get("username"):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully", "deleted_id": user_id}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=dict)
|
||||
async def get_system_stats(current_admin: dict = require_admin):
|
||||
db = get_session()
|
||||
try:
|
||||
return {
|
||||
"total_users": db.query(User).count(),
|
||||
"total_bookmarks": db.query(Bookmark).count(),
|
||||
"total_collections": db.query(Collection).count(),
|
||||
"total_tags": db.query(Tag).count(),
|
||||
"total_audit_logs": db.query(AuditLog).count(),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/audit", response_model=List[dict])
|
||||
async def get_audit_log(
|
||||
limit: int = Query(50, le=200, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
query = db.query(AuditLog)
|
||||
if entity_type:
|
||||
query = query.filter(AuditLog.entity_type == entity_type)
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action)
|
||||
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"entity_type": log.entity_type,
|
||||
"entity_id": log.entity_id,
|
||||
"old_value": log.old_value,
|
||||
"new_value": log.new_value,
|
||||
"ip_address": log.ip_address,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
@@ -2,151 +2,275 @@
|
||||
LinkSyncServer - Authentication Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from models.base import User, ApiKey
|
||||
from models.base import get_engine
|
||||
|
||||
# Fix: Define get_db dependency
|
||||
def get_db():
|
||||
"""Get database engine/session for testing without full DB setup."""
|
||||
return None # Mock - in production would return actual session
|
||||
from config.settings import settings
|
||||
from models.base import ApiKey, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
# JWT configuration
|
||||
SECRET_KEY = secrets.token_urlsafe(32)
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Create JWT access token."""
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: dict
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
api_key: str
|
||||
key_id: str
|
||||
name: str
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(
|
||||
password.encode("utf-8"),
|
||||
bcrypt.gensalt(rounds=settings.BCRYPT_COST_FACTOR),
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_user_from_token(token: str):
|
||||
"""Get user from JWT token."""
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
user_type: str = payload.get("type")
|
||||
if user_type != "access":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"username": username, "type": "access"}
|
||||
return {"username": username, "role": payload.get("role", "user")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
def get_db():
|
||||
session = get_session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
):
|
||||
"""Register new user."""
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"user": {
|
||||
"id": "test-user-id",
|
||||
"username": username,
|
||||
"email": email,
|
||||
"role": "admin" if is_admin else "user"
|
||||
async def register(data: RegisterRequest):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(User).filter(
|
||||
(User.username == data.username) | (User.email == data.email)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role="admin" if data.is_admin else "user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"user": user.to_dict(),
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
admin_username: Optional[str] = None,
|
||||
admin_password_hash: Optional[str] = None,
|
||||
):
|
||||
"""Login and get access token."""
|
||||
|
||||
# Admin login check
|
||||
if admin_username and admin_password_hash:
|
||||
if form_data.username == admin_username and form_data.password == admin_password_hash:
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
db = get_session()
|
||||
try:
|
||||
if (
|
||||
form_data.username == settings.ADMIN_USERNAME
|
||||
and form_data.password == settings.ADMIN_PASSWORD
|
||||
):
|
||||
user = db.query(User).filter(User.username == settings.ADMIN_USERNAME).first()
|
||||
if not user:
|
||||
user = User(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
email="admin@linksync.local",
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token(
|
||||
data={"sub": admin_username, "type": "access"}
|
||||
data={"sub": user.username, "role": user.role, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": admin_username, "role": "admin"}
|
||||
"user": {"username": user.username, "role": user.role},
|
||||
}
|
||||
|
||||
# Regular user login - demo: accept any valid credentials
|
||||
token = create_access_token(
|
||||
data={"sub": form_data.username, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": form_data.username, "role": "user"}
|
||||
}
|
||||
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account disabled")
|
||||
|
||||
token = create_access_token(
|
||||
data={"sub": user.username, "role": user.role, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user.username, "role": user.role},
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client-side token invalidation)."""
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.post("/api-key", response_model=dict)
|
||||
async def create_api_key(user_data: dict = {}):
|
||||
"""Create new API key for authenticated user."""
|
||||
key = secrets.token_urlsafe(64)
|
||||
return {"api_key": key, "expires_in": None}
|
||||
@router.post("/api-key", response_model=ApiKeyResponse)
|
||||
async def create_api_key(
|
||||
name: str = "default",
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
raw_key = secrets.token_urlsafe(64)
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=user.id,
|
||||
key_hash=key_hash,
|
||||
name=name,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
return {
|
||||
"api_key": raw_key,
|
||||
"key_id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/api-key/{key_id}")
|
||||
async def get_api_key_info(key_id: str):
|
||||
"""Get API key information."""
|
||||
return {"key_id": key_id, "active": True}
|
||||
async def get_api_key_info(
|
||||
key_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = db.query(ApiKey).filter(
|
||||
ApiKey.id == key_id, ApiKey.user_id == user.id
|
||||
).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
return {
|
||||
"key_id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"is_active": api_key.is_active,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat() if api_key.created_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/api-key/{key_id}")
|
||||
async def delete_api_key(key_id: str):
|
||||
"""Delete API key."""
|
||||
return {"message": "API key deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def get_current_user_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get current user info."""
|
||||
user_data = get_user_from_token(token)
|
||||
return {"username": user_data["username"]}
|
||||
|
||||
|
||||
@router.get("/token", response_model=dict)
|
||||
async def get_token_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get token information."""
|
||||
async def delete_api_key(
|
||||
key_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return {"username": payload.get("sub"), "exp": payload.get("exp")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = db.query(ApiKey).filter(
|
||||
ApiKey.id == key_id, ApiKey.user_id == user.id
|
||||
).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
return {"message": "API key deleted successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: dict = Depends(get_current_user)):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,233 +1,258 @@
|
||||
"""
|
||||
LinkSyncServer - Collection CRUD Endpoints with SQLAlchemy
|
||||
LinkSyncServer - Collection CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, exists
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Bookmark, Collection, AuditLog, get_engine, sessionmaker
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, get_session
|
||||
from queries.executor import execute_query
|
||||
|
||||
router = APIRouter(prefix="/api/collections", tags=["Collections"])
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str = Field(..., description="Collection name")
|
||||
description: Optional[str] = Field(None, max_length=1024, description="Collection description")
|
||||
query_type: str = Field(default="static", description="Static or dynamic collection")
|
||||
description: Optional[str] = Field(None, max_length=1024)
|
||||
query_type: str = Field(default="static", description="static or dynamic")
|
||||
query_expression: Optional[dict] = Field(None, description="Query expression for dynamic collections")
|
||||
is_public: bool = Field(default=False, description="Is collection public")
|
||||
tags: Optional[List[str]] = Field(default_factory=list, description="Collection tags")
|
||||
is_public: bool = Field(default=False)
|
||||
link_ids: Optional[List[str]] = Field(default_factory=list, description="Link IDs for static collections")
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=1024)
|
||||
query_type: Optional[str] = Field(None)
|
||||
query_expression: Optional[dict] = Field(None)
|
||||
query_type: Optional[str] = None
|
||||
query_expression: Optional[dict] = None
|
||||
is_public: Optional[bool] = None
|
||||
tags: Optional[List[str]] = Field(None)
|
||||
|
||||
|
||||
class CollectionResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
query_type: str
|
||||
query_expression: Optional[dict]
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
tags: List[str]
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Get database session."""
|
||||
db_session = sessionmaker(get_engine())()
|
||||
return db_session
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
def get_current_user_id(request: Request) -> Optional[str]:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
from config.settings import settings
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
return None
|
||||
|
||||
|
||||
class CollectionManager:
|
||||
"""Collection management helper."""
|
||||
|
||||
@staticmethod
|
||||
def get_collection(collection_id: str) -> Optional[Collection]:
|
||||
"""Get collection by ID."""
|
||||
db = get_db()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
return collection
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_collection(data: CollectionCreate, request: Request) -> Collection:
|
||||
"""Create new collection."""
|
||||
db = get_db()
|
||||
|
||||
def log_audit(db, action, entity_type, entity_id, user_id, old_value=None, new_value=None):
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_collections(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
request: Request = None,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request) if request else None
|
||||
query = db.query(Collection)
|
||||
if user_id:
|
||||
query = query.filter(
|
||||
or_(Collection.created_by == user_id, Collection.is_public == True)
|
||||
)
|
||||
else:
|
||||
query = query.filter(Collection.is_public == True)
|
||||
collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [c.to_dict() for c in collections]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_model=dict)
|
||||
async def get_collection(collection_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
result = collection.to_dict()
|
||||
if collection.query_type == "static":
|
||||
links = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(CollectionBookmark.collection_id == collection_id)
|
||||
.all()
|
||||
)
|
||||
result["link_ids"] = [lb.bookmark_id for lb in links]
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_collection(data: CollectionCreate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
collection = Collection(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
query_type=data.query_type,
|
||||
query_expression=data.query_expression,
|
||||
is_public=data.is_public,
|
||||
tags=TagCollection(tags=data.tags or []),
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
db.add(collection)
|
||||
db.flush()
|
||||
|
||||
if data.query_type == "static" and data.link_ids:
|
||||
for link_id in data.link_ids:
|
||||
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=link_id)
|
||||
db.add(cb)
|
||||
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="create",
|
||||
entity_type="Collection",
|
||||
entity_id=collection.id,
|
||||
old_value=None,
|
||||
new_value=collection.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return collection
|
||||
|
||||
@staticmethod
|
||||
def update_collection(collection_id: str, data: CollectionUpdate, request: Request) -> Optional[Collection]:
|
||||
"""Update collection."""
|
||||
db = get_db()
|
||||
|
||||
log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict())
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{collection_id}", response_model=dict)
|
||||
async def update_collection(collection_id: str, data: CollectionUpdate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
for field_name, value in data.dict().items():
|
||||
if value is not None:
|
||||
if hasattr(collection, field_name):
|
||||
setattr(collection, field_name, value)
|
||||
elif field_name == "tags":
|
||||
if isinstance(value, list):
|
||||
collection.tags.add(*value)
|
||||
else:
|
||||
collection.tags.update(str(value))
|
||||
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
old_value = collection.to_dict()
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(collection, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="update",
|
||||
entity_type="Collection",
|
||||
entity_id=collection_id,
|
||||
old_value=collection.dict(),
|
||||
new_value=collection.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return collection
|
||||
|
||||
@staticmethod
|
||||
def delete_collection(collection_id: str, request: Request) -> dict:
|
||||
"""Delete collection."""
|
||||
db = get_db()
|
||||
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.to_dict())
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}", response_model=dict)
|
||||
async def delete_collection(collection_id: str, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
old_value = collection.to_dict()
|
||||
if collection.query_type == "static":
|
||||
db.query(CollectionBookmark).filter(
|
||||
CollectionBookmark.collection_id == collection_id
|
||||
).delete()
|
||||
db.delete(collection)
|
||||
db.commit()
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="delete",
|
||||
entity_type="Collection",
|
||||
entity_id=collection_id,
|
||||
old_value=collection.dict(),
|
||||
new_value=None,
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value)
|
||||
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
|
||||
|
||||
@staticmethod
|
||||
def get_collection_tags(collection_id: str) -> List[str]:
|
||||
"""Get collection tags."""
|
||||
db = get_db()
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{collection_id}/refresh", response_model=dict)
|
||||
async def refresh_collection(collection_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return []
|
||||
|
||||
return list(collection.tags)
|
||||
|
||||
@staticmethod
|
||||
def get_collection_bookmarks(collection_id: str, limit: int = 50, offset: int = 0) -> List[Bookmark]:
|
||||
"""
|
||||
Get bookmarks for collection (static or dynamic).
|
||||
|
||||
For dynamic collections with query expression:
|
||||
Use query executor to parse and filter bookmarks
|
||||
"""
|
||||
db = get_db()
|
||||
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return []
|
||||
|
||||
if collection.query_type == "static":
|
||||
# Static collection: get all bookmarks
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.collection_id == collection_id).limit(limit).offset(offset).all()
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "dynamic":
|
||||
raise HTTPException(status_code=400, detail="Only dynamic collections can be refreshed")
|
||||
|
||||
if collection.query_expression:
|
||||
bookmarks = execute_query(collection.query_expression)
|
||||
else:
|
||||
# Dynamic collection: query expression
|
||||
# TODO: Use query executor to parse expression (executor module)
|
||||
bookmarks = db.query(Bookmark).limit(limit).offset(offset).all()
|
||||
|
||||
return bookmarks
|
||||
bookmarks = []
|
||||
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"matched_count": len(bookmarks),
|
||||
"bookmarks": bookmarks,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{collection_id}/add-links", response_model=dict)
|
||||
async def add_links_to_collection(collection_id: str, link_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "static":
|
||||
raise HTTPException(status_code=400, detail="Can only add links to static collections")
|
||||
|
||||
existing = {
|
||||
cb.bookmark_id
|
||||
for cb in db.query(CollectionBookmark)
|
||||
.filter(CollectionBookmark.collection_id == collection_id)
|
||||
.all()
|
||||
}
|
||||
added = 0
|
||||
for link_id in link_ids:
|
||||
if link_id not in existing:
|
||||
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"Added {added} links", "added_count": added}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/remove-links", response_model=dict)
|
||||
async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
removed = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(
|
||||
CollectionBookmark.collection_id == collection_id,
|
||||
CollectionBookmark.bookmark_id.in_(link_ids),
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"Removed {removed} links", "removed_count": removed}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,348 +1,61 @@
|
||||
"""
|
||||
LinkSyncServer - Link CRUD Endpoints with SQLAlchemy
|
||||
LinkSyncServer - Link CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy import func, or_
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import logging
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Bookmark, User, AuditLog, get_engine, create_engine
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
from sqlalchemy import or_
|
||||
|
||||
from config.settings import settings
|
||||
from models.base import AuditLog, Bookmark, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["Links"])
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkCreate(BaseModel):
|
||||
url: str = Field(..., description="Bookmark URL")
|
||||
title: str = Field(..., min_length=1, max_length=255, description="Bookmark title")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||
notes: Optional[str] = Field(None, max_length=2000, description="Optional notes")
|
||||
tags: Optional[List[str]] = Field(default_factory=list, description="List of tag names")
|
||||
favicon_url: Optional[str] = Field(None, max_length=512, description="Favicon URL")
|
||||
path: Optional[str] = Field(None, max_length=512, description="Folder path")
|
||||
visit_count: int = Field(ge=0, description="Visit counter")
|
||||
is_bookmarked: bool = Field(default=False, description="Bookmark flag")
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
tags: Optional[List[str]] = Field(default_factory=list)
|
||||
favicon_url: Optional[str] = Field(None, max_length=512)
|
||||
path: Optional[str] = Field(None, max_length=512)
|
||||
visit_count: int = Field(0, ge=0)
|
||||
is_bookmarked: bool = Field(default=False)
|
||||
|
||||
|
||||
class BookmarkUpdate(BaseModel):
|
||||
url: Optional[str] = Field(None, description="New URL")
|
||||
url: Optional[str] = None
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
tags: Optional[List[str]] = Field(None)
|
||||
tags: Optional[List[str]] = None
|
||||
favicon_url: Optional[str] = Field(None, max_length=512)
|
||||
path: Optional[str] = Field(None, max_length=512)
|
||||
visit_count: Optional[int] = Field(None, ge=0)
|
||||
is_bookmarked: Optional[bool] = None
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
notes: Optional[str]
|
||||
tags: List[str]
|
||||
favicon_url: Optional[str]
|
||||
path: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
visit_count: int
|
||||
is_bookmarked: bool
|
||||
source_set_id: Optional[str]
|
||||
user_id: Optional[str]
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
try:
|
||||
return sessionmaker(get_engine())()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
def get_current_user_id(request: Request) -> Optional[str]:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[BookmarkResponse])
|
||||
async def list_bookmarks(
|
||||
limit: int = Query(20, le=100, ge=1, description="Number of results per page"),
|
||||
offset: int = Query(0, ge=0, description="Offset for pagination"),
|
||||
search: Optional[str] = Query(None, description="Search query"),
|
||||
tags_filter: Optional[List[str]] = Query(None, description="Filter by tags"),
|
||||
path_filter: Optional[str] = Query(None, description="Filter by folder path")
|
||||
):
|
||||
"""List all bookmarks with optional filters."""
|
||||
db = get_db_session()
|
||||
if not db:
|
||||
return []
|
||||
|
||||
query = Bookmark.query
|
||||
|
||||
# Search filter
|
||||
if search:
|
||||
query = query.filter((Bookmark.title.contains(search)) |
|
||||
(Bookmark.description.contains(search)) |
|
||||
(Bookmark.url.contains(search)))
|
||||
|
||||
# Tag filter
|
||||
if tags_filter:
|
||||
or_clause = or_(*[Bookmark.tags.contains(tag) for tag in tags_filter])
|
||||
query = query.filter(or_clause)
|
||||
|
||||
# Path filter
|
||||
if path_filter:
|
||||
query = query.filter(Bookmark.path.contains(path_filter))
|
||||
|
||||
bookmarks = query.limit(limit).offset(offset).all()
|
||||
return bookmarks
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def get_bookmark(bookmark_id: str):
|
||||
"""Get bookmark by ID."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bookmark(data: BookmarkCreate, request: Request):
|
||||
"""Create new bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = Bookmark(
|
||||
url=data.url,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
notes=data.notes,
|
||||
tags=data.tags or [],
|
||||
favicon_url=data.favicon_url,
|
||||
path=data.path,
|
||||
visit_count=data.visit_count,
|
||||
is_bookmarked=data.is_bookmarked
|
||||
)
|
||||
|
||||
bookmark_id = f"{data.url[:20]}-{uuid.uuid4()[:8]}"
|
||||
bookmark = db.add(bookmark)
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log (optional)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="create",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=None,
|
||||
new_value=bookmark.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def update_bookmark(
|
||||
bookmark_id: str,
|
||||
data: BookmarkUpdate,
|
||||
request: Request
|
||||
):
|
||||
"""Update bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
# Update fields
|
||||
for field_name, value in data.dict().items():
|
||||
if value is not None:
|
||||
setattr(bookmark, field_name, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log
|
||||
try:
|
||||
old_data = Bookmark(id=bookmark_id, url=bookmark.url, title=bookmark.title).dict()
|
||||
audit = AuditLog(
|
||||
action="update",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=old_data,
|
||||
new_value=bookmark.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", response_model=dict)
|
||||
async def delete_bookmark(bookmark_id: str, request: Request):
|
||||
"""Delete bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
db.delete(bookmark)
|
||||
db.commit()
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="delete",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=bookmark.dict(),
|
||||
new_value=None,
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
|
||||
|
||||
|
||||
@router.post("/{bookmark_id}/tags")
|
||||
async def add_tags(bookmark_id: str, tags: List[str], request: Request):
|
||||
"""Add tags to bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
for tag in tags:
|
||||
if tag.lower() not in [t.lower() for t in bookmark.tags]:
|
||||
bookmark.tags.append(tag)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}/tags")
|
||||
async def remove_tags(bookmark_id: str, tags_to_remove: List[str], request: Request):
|
||||
"""Remove tags from bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
bookmark.tags = [t for t in bookmark.tags if t.lower() not in [tag.lower() for tag in tags_to_remove]]
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}/stats")
|
||||
async def get_bookmark_stats(bookmark_id: str, request: Request):
|
||||
"""Get bookmark statistics."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return {}
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
# Get visit count
|
||||
visits = db.query("SELECT COUNT(*) FROM visits WHERE bookmark_id = :bookmark_id")
|
||||
visit_count = visits.execute({"bookmark_id": bookmark_id})
|
||||
|
||||
return {
|
||||
"bookmark_id": bookmark_id,
|
||||
"visit_count": visit_count[0][0],
|
||||
"last_visited": visits.execute({"bookmark_id": bookmark_id})
|
||||
}
|
||||
|
||||
|
||||
# Audit log helper (optional)
|
||||
def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: dict, new_value: dict):
|
||||
"""Create audit log entry."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return
|
||||
|
||||
def log_audit(db, action: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None):
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action=action,
|
||||
@@ -350,9 +63,162 @@ def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: d
|
||||
entity_id=entity_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
ip_address=request.client.host if hasattr(request, 'client') and hasattr(request.client, 'host') else None
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
db.rollback()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_bookmarks(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: Optional[str] = Query(None),
|
||||
tags_filter: Optional[List[str]] = Query(None),
|
||||
path_filter: Optional[str] = Query(None),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
query = db.query(Bookmark)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Bookmark.title.ilike(f"%{search}%"),
|
||||
Bookmark.description.ilike(f"%{search}%"),
|
||||
Bookmark.url.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
if tags_filter:
|
||||
for tag in tags_filter:
|
||||
query = query.filter(Bookmark.tags.contains(tag))
|
||||
if path_filter:
|
||||
query = query.filter(Bookmark.path.ilike(f"%{path_filter}%"))
|
||||
bookmarks = query.order_by(Bookmark.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [b.to_dict() for b in bookmarks]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=dict)
|
||||
async def get_bookmark(bookmark_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bookmark(data: BookmarkCreate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request)
|
||||
bookmark = Bookmark(
|
||||
id=str(uuid.uuid4()),
|
||||
url=data.url,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
notes=data.notes,
|
||||
tags=data.tags or [],
|
||||
favicon_url=data.favicon_url,
|
||||
path=data.path,
|
||||
visit_count=data.visit_count,
|
||||
is_bookmarked=data.is_bookmarked,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(bookmark)
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
log_audit(db, "create", "Bookmark", bookmark.id, user_id, new_value=bookmark.to_dict())
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=dict)
|
||||
async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
old_value = bookmark.to_dict()
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(bookmark, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict())
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", response_model=dict)
|
||||
async def delete_bookmark(bookmark_id: str, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
old_value = bookmark.to_dict()
|
||||
db.delete(bookmark)
|
||||
db.commit()
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value)
|
||||
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TagList(BaseModel):
|
||||
tags: List[str]
|
||||
|
||||
|
||||
@router.post("/{bookmark_id}/tags", response_model=dict)
|
||||
async def add_tags(bookmark_id: str, data: TagList, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
current_tags = list(bookmark.tags or [])
|
||||
current_lower = [t.lower() for t in current_tags]
|
||||
for tag in data.tags:
|
||||
if tag.lower() not in current_lower:
|
||||
current_tags.append(tag)
|
||||
current_lower.append(tag.lower())
|
||||
bookmark.tags = current_tags
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}/tags", response_model=dict)
|
||||
async def remove_tags(bookmark_id: str, data: TagList, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
remove_lower = [t.lower() for t in data.tags]
|
||||
bookmark.tags = [t for t in (bookmark.tags or []) if t.lower() not in remove_lower]
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,253 +1,71 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine
|
||||
LinkSyncServer - Query Engine Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from models.base import Bookmark, get_session
|
||||
from queries.executor import execute_query
|
||||
from queries.parser import QueryParser
|
||||
|
||||
router = APIRouter(prefix="/api/queries", tags=["Queries"])
|
||||
|
||||
|
||||
def tokenize(query: str) -> List[str]:
|
||||
"""Tokenize query string."""
|
||||
# Remove parentheses first, tokenize, then track nesting
|
||||
tokens = []
|
||||
current_token = ""
|
||||
paren_depth = 0
|
||||
i = 0
|
||||
while i < len(query):
|
||||
c = query[i]
|
||||
if c == '(':
|
||||
paren_depth += 1
|
||||
current_token += c
|
||||
elif c == ')':
|
||||
paren_depth -= 1
|
||||
current_token += c
|
||||
elif c in ' \t\n' or paren_depth == 0 and c in ' ,':
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
current_token = ""
|
||||
else:
|
||||
current_token += c
|
||||
i += 1
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
return tokens
|
||||
|
||||
|
||||
class TermSet:
|
||||
"""Term set: ('term1', 'term2') -> OR operation"""
|
||||
def __init__(self, terms: List[str]):
|
||||
self.terms = terms
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "term_set",
|
||||
"terms": self.terms,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class TagFilter:
|
||||
"""Tag-based filter"""
|
||||
def __init__(self, tag_name: str):
|
||||
self.tag_name = tag_name
|
||||
self.operation = "TAG"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "tag_filter",
|
||||
"tag_name": self.tag_name,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class FieldFilter:
|
||||
"""Field-based filter (e.g., url:example.com)"""
|
||||
def __init__(self, field: str, value: str):
|
||||
self.field = field
|
||||
self.value = value
|
||||
self.operation = "FIELD"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "field_filter",
|
||||
"field": self.field,
|
||||
"value": self.value,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class ANDNode:
|
||||
"""AND operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "AND"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class ORNode:
|
||||
"""OR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class XORNode:
|
||||
"""XOR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "XOR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class NOTNode:
|
||||
"""NOT operation node"""
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
self.operation = "NOT"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "unary",
|
||||
"operation": self.operation,
|
||||
"child": self.child.to_dict()
|
||||
}
|
||||
|
||||
|
||||
def parse_query(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse query expression: ('term1', 'term2') OR tagA AND tagB XOR url:example.com
|
||||
Precedence: () > XOR > AND > OR
|
||||
"""
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Remove parentheses and tokenize
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Simple parser for basic queries
|
||||
# For full parser, would need recursive descent
|
||||
|
||||
# Handle term sets: ('term1', 'term2')
|
||||
term_set = None
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if token.startswith('(') and tokens[i].endswith(')'):
|
||||
# Extract terms from tuple
|
||||
inner = token[1:-1]
|
||||
terms = [t.strip("'\"") for t in inner.split(',')]
|
||||
term_set = TermSet(terms)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if not term_set:
|
||||
# Parse as simple expression
|
||||
# This is a simplified parser for demo
|
||||
return {"type": "term_set", "terms": []}
|
||||
|
||||
return term_set.to_dict()
|
||||
|
||||
|
||||
def execute_query(query_expression: dict, all_bookmarks: List[dict]) -> List[dict]:
|
||||
"""
|
||||
Execute query expression against bookmark list.
|
||||
For demo, returns mock results.
|
||||
"""
|
||||
# Query AST evaluation would go here
|
||||
# For now, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/result",
|
||||
"title": "Query Result",
|
||||
"description": "A result from the query",
|
||||
"notes": "",
|
||||
"tags": ["query", "result"],
|
||||
"favicon_url": None,
|
||||
"path": "/Query Result",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.post("/parse", response_model=Dict[str, Any])
|
||||
async def parse_expression(query: str):
|
||||
"""Parse and validate query expression."""
|
||||
parsed = parse_query(query)
|
||||
return {
|
||||
"expression": query,
|
||||
"parsed": parsed,
|
||||
"valid": True
|
||||
}
|
||||
async def parse_expression(expression: str):
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": parsed,
|
||||
"valid": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": None,
|
||||
"valid": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute", response_model=List[dict])
|
||||
async def execute(query_expression: dict, limit: int = 20):
|
||||
"""Execute query against bookmarks."""
|
||||
# For demo, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/queried",
|
||||
"title": "Queried Item",
|
||||
"description": "Item from query",
|
||||
"notes": "",
|
||||
"tags": ["queried"],
|
||||
"favicon_url": None,
|
||||
"path": "/Queried",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
]
|
||||
async def execute(expression: str, limit: int = 20, offset: int = 0):
|
||||
db = get_session()
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=400, detail="Invalid query expression")
|
||||
|
||||
all_bookmarks = db.query(Bookmark).all()
|
||||
results = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
|
||||
return results[offset : offset + limit]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{query_id}", response_model=Dict[str, Any])
|
||||
async def get_saved_query(query_id: str):
|
||||
"""Get saved query by ID."""
|
||||
return {
|
||||
"id": query_id,
|
||||
"name": "Example Query",
|
||||
"description": "Example query description",
|
||||
"expression": "('work', 'dev') OR tag:work",
|
||||
"query_type": "dynamic",
|
||||
"is_public": False,
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z"
|
||||
}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == query_id).first()
|
||||
if not collection or collection.query_type != "dynamic":
|
||||
raise HTTPException(status_code=404, detail="Saved query not found")
|
||||
return {
|
||||
"id": collection.id,
|
||||
"name": collection.name,
|
||||
"description": collection.description,
|
||||
"expression": collection.query_expression,
|
||||
"query_type": collection.query_type,
|
||||
"is_public": collection.is_public,
|
||||
"created_at": collection.created_at.isoformat() if collection.created_at else None,
|
||||
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,29 +2,33 @@
|
||||
LinkSyncServer - Sync Endpoint for Browser Extension
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List, Dict, Any
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.base import Bookmark, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["Sync"])
|
||||
|
||||
|
||||
class SyncConfig(BaseModel):
|
||||
mode: str # "bi-directional", "browser-authoritative", "server-authoritative"
|
||||
mode: str = Field(..., description="bi-directional, browser-authoritative, or server-authoritative")
|
||||
deletions_enabled: bool = False
|
||||
|
||||
|
||||
class BookmarkData(BaseModel):
|
||||
class BookmarkSyncData(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
notes: str
|
||||
tags: List[str]
|
||||
favicon_url: str
|
||||
path: str
|
||||
visit_count: int
|
||||
is_bookmarked: bool
|
||||
description: str = ""
|
||||
notes: str = ""
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
favicon_url: str = ""
|
||||
path: str = ""
|
||||
visit_count: int = 0
|
||||
is_bookmarked: bool = False
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
@@ -32,119 +36,178 @@ class SyncResponse(BaseModel):
|
||||
synced_count: int
|
||||
|
||||
|
||||
def mock_apply_sync(sync_config: SyncConfig, browser_bookmarks: List[Dict]) -> SyncResponse:
|
||||
"""
|
||||
Apply sync based on mode.
|
||||
For demo, return mock actions.
|
||||
"""
|
||||
actions = []
|
||||
|
||||
for bookmark in browser_bookmarks:
|
||||
if sync_config.mode == "bi-directional":
|
||||
actions.append({
|
||||
"type": "create" if not bookmark.get("from_server", False) else "update",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Synced from browser"
|
||||
})
|
||||
elif sync_config.mode == "browser-authoritative":
|
||||
actions.append({
|
||||
"type": "update",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Overwritten from browser"
|
||||
})
|
||||
elif sync_config.mode == "server-authoritative":
|
||||
actions.append({
|
||||
"type": "download",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Downloaded from server"
|
||||
})
|
||||
|
||||
# If deletions enabled, would remove stale bookmarks here
|
||||
|
||||
return SyncResponse(
|
||||
actions=actions,
|
||||
synced_count=len(actions)
|
||||
)
|
||||
def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
|
||||
db = get_session()
|
||||
try:
|
||||
actions = []
|
||||
server_bookmarks = {b.id: b for b in db.query(Bookmark).all()}
|
||||
|
||||
for bm in browser_bookmarks:
|
||||
existing = server_bookmarks.get(bm.id)
|
||||
|
||||
def mock_get_server_bookmarks() -> List[Dict]:
|
||||
"""Get bookmarks from server (mock)."""
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/example",
|
||||
"title": "Example",
|
||||
"description": "An example",
|
||||
"notes": "",
|
||||
"tags": ["example"],
|
||||
"favicon_url": None,
|
||||
"path": "/Example",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False
|
||||
}
|
||||
]
|
||||
if sync_config.mode == "bi-directional":
|
||||
if existing:
|
||||
existing.url = bm.url
|
||||
existing.title = bm.title
|
||||
existing.description = bm.description
|
||||
existing.notes = bm.notes
|
||||
existing.tags = bm.tags
|
||||
existing.favicon_url = bm.favicon_url
|
||||
existing.path = bm.path
|
||||
existing.visit_count = bm.visit_count
|
||||
existing.is_bookmarked = bm.is_bookmarked
|
||||
actions.append({"type": "update", "link_id": bm.id})
|
||||
else:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
elif sync_config.mode == "browser-authoritative":
|
||||
if existing:
|
||||
existing.url = bm.url
|
||||
existing.title = bm.title
|
||||
existing.description = bm.description
|
||||
existing.notes = bm.notes
|
||||
existing.tags = bm.tags
|
||||
existing.favicon_url = bm.favicon_url
|
||||
existing.path = bm.path
|
||||
existing.visit_count = bm.visit_count
|
||||
existing.is_bookmarked = bm.is_bookmarked
|
||||
actions.append({"type": "update", "link_id": bm.id})
|
||||
else:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
elif sync_config.mode == "server-authoritative":
|
||||
if not existing:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
if sync_config.deletions_enabled:
|
||||
browser_ids = {bm.id for bm in browser_bookmarks}
|
||||
for server_id in server_bookmarks:
|
||||
if server_id not in browser_ids:
|
||||
db.query(Bookmark).filter(Bookmark.id == server_id).delete()
|
||||
actions.append({"type": "delete", "link_id": server_id})
|
||||
|
||||
db.commit()
|
||||
return SyncResponse(actions=actions, synced_count=len(actions))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=SyncResponse)
|
||||
async def sync(
|
||||
config: SyncConfig,
|
||||
browser_bookmarks: List[BookmarkData],
|
||||
server_bookmarks: List[Dict] = Depends(mock_get_server_bookmarks)
|
||||
):
|
||||
"""
|
||||
Sync bookmarks between browser and server.
|
||||
|
||||
Mode options:
|
||||
- bi-directional: Push both ways
|
||||
- browser-authoritative: Browser overwrites server
|
||||
- server-authoritative: Download from server only
|
||||
"""
|
||||
response = mock_apply_sync(config, [b.model_dump() for b in browser_bookmarks])
|
||||
return response
|
||||
async def sync(config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]):
|
||||
return apply_sync(config, browser_bookmarks)
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
@router.get("/collections", response_model=List[dict])
|
||||
async def list_collections():
|
||||
"""List user's collections."""
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Work Links",
|
||||
"description": "Work-related links",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
|
||||
"is_public": False
|
||||
}
|
||||
]
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collections = db.query(Collection).all()
|
||||
return [c.to_dict() for c in collections]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
@router.get("/collections/{collection_id}", response_model=dict)
|
||||
async def get_collection(collection_id: str):
|
||||
"""Get collection details."""
|
||||
return {
|
||||
"id": collection_id,
|
||||
"name": "Work Links",
|
||||
"description": "Work-related links",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
|
||||
"is_public": False
|
||||
}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/add-links")
|
||||
async def add_links_to_collection(
|
||||
collection_id: str,
|
||||
bookmark_ids: List[str]
|
||||
):
|
||||
"""Add links to static collection."""
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"added_count": len(bookmark_ids),
|
||||
"message": "Links added successfully"
|
||||
}
|
||||
@router.post("/collections/{collection_id}/add-links", response_model=dict)
|
||||
async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection, CollectionBookmark
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "static":
|
||||
raise HTTPException(status_code=400, detail="Can only add links to static collections")
|
||||
|
||||
added = 0
|
||||
for bid in bookmark_ids:
|
||||
existing = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(
|
||||
CollectionBookmark.collection_id == collection_id,
|
||||
CollectionBookmark.bookmark_id == bid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
return {"collection_id": collection_id, "added_count": added, "message": "Links added successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
@router.delete("/collections/{collection_id}", response_model=dict)
|
||||
async def delete_collection(collection_id: str):
|
||||
"""Delete collection."""
|
||||
return {"message": "Collection deleted successfully"}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection, CollectionBookmark
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
db.query(CollectionBookmark).filter(
|
||||
CollectionBookmark.collection_id == collection_id
|
||||
).delete()
|
||||
db.delete(collection)
|
||||
db.commit()
|
||||
return {"message": "Collection deleted successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,187 +2,164 @@
|
||||
LinkSyncServer - Tag Management Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Tag, Bookmark, get_engine
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.base import Bookmark, Tag, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["Tags"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=7)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None)
|
||||
color: Optional[str] = Field(None)
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=7)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_tags(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=200),
|
||||
search: Optional[str] = Query(None),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
return Session(get_engine())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = None
|
||||
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
elif not auth_header:
|
||||
return {"username": "guest"}
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TagResponse])
|
||||
async def list_tags(page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200)):
|
||||
"""List tags with pagination."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return []
|
||||
|
||||
count = db.query(Tag).count()
|
||||
tags = db.query(Tag).order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return tags
|
||||
query = db.query(Tag)
|
||||
if search:
|
||||
query = query.filter(Tag.name.ilike(f"%{search}%"))
|
||||
tags = query.order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
|
||||
return [t.to_dict() for t in tags]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/count", response_model=dict)
|
||||
async def tag_count():
|
||||
"""Get total tag count."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return {"count": 0}
|
||||
|
||||
return {"count": db.query(Tag).count()}
|
||||
db = get_session()
|
||||
try:
|
||||
return {"count": db.query(Tag).count()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{tag_id}", response_model=TagResponse)
|
||||
@router.get("/{tag_id}", response_model=dict)
|
||||
async def get_tag(tag_id: str):
|
||||
"""Get tag by ID."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
return tag
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{tag_id}/links")
|
||||
async def get_tag_links(tag_id: str, limit: int = Query(50, ge=1), offset: int = Query(0, ge=0)):
|
||||
"""Get links for tag.""""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return []
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
links = db.query(Bookmark).join(Tag).filter(Tag.id == tag_id).limit(limit).offset(offset).all()
|
||||
|
||||
return links
|
||||
@router.get("/name/{tag_name}", response_model=dict)
|
||||
async def get_tag_by_name(tag_name: str):
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=TagResponse, status_code=201)
|
||||
async def create_tag(data: TagCreate, request):
|
||||
"""Create new tag."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = Tag(
|
||||
id=f"tag-{uuid.uuid4()[:8]}",
|
||||
name=data.name,
|
||||
color=data.color
|
||||
)
|
||||
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
|
||||
return tag
|
||||
@router.get("/{tag_id}/links", response_model=List[dict])
|
||||
async def get_tag_links(
|
||||
tag_id: str,
|
||||
limit: int = Query(50, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
bookmarks = (
|
||||
db.query(Bookmark)
|
||||
.filter(Bookmark.tags.contains(tag.name))
|
||||
.order_by(Bookmark.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [b.to_dict() for b in bookmarks]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=TagResponse)
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_tag(data: TagCreate):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(Tag).filter(Tag.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Tag already exists")
|
||||
|
||||
tag = Tag(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
description=data.description,
|
||||
)
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=dict)
|
||||
async def update_tag(tag_id: str, data: TagUpdate):
|
||||
"""Update tag."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
for field_name in ["name", "color"]:
|
||||
if field_name in data.dict() and data.dict()[field_name] is not None:
|
||||
setattr(tag, field_name, data.dict()[field_name])
|
||||
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
|
||||
return tag
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tag, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", response_model=dict)
|
||||
async def delete_tag(tag_id: str):
|
||||
"""Delete tag (and remove from all links)."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Remove tag from all bookmarks
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag.name)).all()
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags = [t for t in bookmark.tags if t[0] != tag_id]
|
||||
|
||||
db.delete(tag)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Tag '{tag.name}' deleted and removed from all links"}
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag_name = tag.name
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag_name)).all()
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags = [t for t in (bookmark.tags or []) if t != tag_name]
|
||||
db.add(bookmark)
|
||||
|
||||
db.delete(tag)
|
||||
db.commit()
|
||||
return {"message": f"Tag '{tag_name}' deleted and removed from all links"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
23
LinkSyncServer/api/routes.py
Normal file
23
LinkSyncServer/api/routes.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
LinkSyncServer - API Router Aggregator
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.endpoints.auth import router as auth_router
|
||||
from api.endpoints.links import router as links_router
|
||||
from api.endpoints.collections import router as collections_router
|
||||
from api.endpoints.queries import router as queries_router
|
||||
from api.endpoints.sync import router as sync_router
|
||||
from api.endpoints.tags import router as tags_router
|
||||
from api.endpoints.admin import router as admin_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(auth_router)
|
||||
router.include_router(links_router)
|
||||
router.include_router(collections_router)
|
||||
router.include_router(queries_router)
|
||||
router.include_router(sync_router)
|
||||
router.include_router(tags_router)
|
||||
router.include_router(admin_router)
|
||||
@@ -1,151 +0,0 @@
|
||||
"""
|
||||
LinkSyncServer - Sync Endpoint for Browser Extension
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from typing import List, Dict
|
||||
import jwt
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from models.base import Bookmark, Collection, get_engine
|
||||
from api.parsers.bookmarks import BookmarkParser
|
||||
from api.parsers.sync import SyncParser
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sync", tags=["Sync"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get database and secrets
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///links.db")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback-for-dev")
|
||||
|
||||
# Initialize parser
|
||||
bookmark_parser = BookmarkParser()
|
||||
sync_parser = SyncParser()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={'check_same_thread': False}
|
||||
)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def validate_request_token(request_token: str) -> Dict:
|
||||
"""
|
||||
Validate sync request token.
|
||||
|
||||
Accepts:
|
||||
- Token header from extension
|
||||
- No auth for demo/maintenance
|
||||
"""
|
||||
if not request_token:
|
||||
# Allow anonymous for demo
|
||||
return {"type": "anonymous", "permissions": {}}
|
||||
|
||||
try:
|
||||
# Try to decode as JWT
|
||||
payload = jwt.decode(request_token, SECRET_KEY, algorithms=["HS256"])
|
||||
|
||||
# Check permissions
|
||||
permissions = {
|
||||
"collections": payload.get("permissions", {}).get("collections", []),
|
||||
"bookmarks": payload.get("permissions", {}).get("bookmarks", [])
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "authorized",
|
||||
"permissions": permissions
|
||||
}
|
||||
except Exception:
|
||||
# Token invalid, fall back to anonymous
|
||||
return {"type": "anonymous", "permissions": {}}
|
||||
|
||||
|
||||
def sync_with_github(account_id: str, collection_id: str, request_token: str) -> Dict:
|
||||
"""
|
||||
Sync bookmarks from GitHub to local collection.
|
||||
|
||||
Args:
|
||||
account_id: GitHub account ID
|
||||
collection_id: LinkSync collection ID
|
||||
request_token: Token from extension request
|
||||
|
||||
Returns:
|
||||
Sync response (JSON payload for extension)
|
||||
"""
|
||||
# Validate token
|
||||
token_info = validate_request_token(request_token)
|
||||
|
||||
if token_info["type"] != "authorized":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized access")
|
||||
|
||||
# Get collection
|
||||
db = get_db_session()
|
||||
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# Make request to GitHub API (using library or requests)
|
||||
try:
|
||||
# GitHub API v3
|
||||
# GET /users/{user_id}/starred
|
||||
# Response: list of starred repositories and Gists (links)
|
||||
|
||||
github_api_base = "https://api.github.com"
|
||||
starred_response = requests.get(
|
||||
f"{github_api_base}/users/{account_id}/starred",
|
||||
headers={
|
||||
"Accept": "application/vnd.github.v3+json"
|
||||
}
|
||||
)
|
||||
|
||||
if starred_response.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch GitHub data")
|
||||
|
||||
github_links = starred_response.json()
|
||||
|
||||
# Parse GitHub data
|
||||
github_bookmarks = sync_parser.parse_github_links(github_links)
|
||||
|
||||
# Create/update/delete based on sync
|
||||
changes = bookmark_parser.parse_sync(
|
||||
github_bookmarks, collection_id
|
||||
)
|
||||
|
||||
# Commit changes
|
||||
db.commit()
|
||||
|
||||
# Build response
|
||||
sync_response = {
|
||||
"_links": {
|
||||
"sync": {
|
||||
"_links": {
|
||||
"self": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"account_id": account_id,
|
||||
"collections": [collection_id],
|
||||
"changes": changes,
|
||||
"total_synced": len(github_links)
|
||||
}
|
||||
}
|
||||
|
||||
return sync_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2,17 +2,54 @@
|
||||
LinkSyncServer - Main Application
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from routes import router as api_router
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from api.routes import router as api_router
|
||||
from config.settings import settings
|
||||
from models.base import Base, get_engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LinkSyncServer",
|
||||
description="Self-hosted bookmark server with collections",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
cors_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
31
LinkSyncServer/config/settings.py
Normal file
31
LinkSyncServer/config/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
LinkSyncServer - Application Settings
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Settings:
|
||||
DATABASE_URL: str = os.environ.get(
|
||||
"DATABASE_URL", "sqlite:///linksync.db"
|
||||
)
|
||||
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "admin123")
|
||||
DEBUG: bool = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||
HOST: str = os.environ.get("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.environ.get("PORT", "5000"))
|
||||
CORS_ORIGINS: str = os.environ.get("CORS_ORIGINS", "http://localhost:5555")
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
BCRYPT_COST_FACTOR: int = 12
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_WINDOW: int = 60
|
||||
LOGIN_RATE_LIMIT: int = 10
|
||||
LOGIN_RATE_LIMIT_WINDOW: int = 3600
|
||||
|
||||
|
||||
settings = Settings()
|
||||
105
LinkSyncServer/deploy.ps1
Normal file
105
LinkSyncServer/deploy.ps1
Normal file
@@ -0,0 +1,105 @@
|
||||
#Requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prepares a deployment package for LinkSyncServer.
|
||||
|
||||
.DESCRIPTION
|
||||
Copies only the files needed for production deployment to a target folder,
|
||||
excludes development artifacts, and creates a starter .env file.
|
||||
After running, the user should edit the .env file with production secrets
|
||||
and run docker-compose up -d --build in the target folder.
|
||||
|
||||
.PARAMETER DeployPath
|
||||
Path to the deployment folder. Will be created if it does not exist.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1 -DeployPath "..\linksync-deploy"
|
||||
.\deploy.ps1 ..\linksync-deploy
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$DeployPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
|
||||
|
||||
# Patterns excluded from deployment
|
||||
|
||||
Write-Host "LinkSyncServer - Deploy Script" -ForegroundColor Cyan
|
||||
Write-Host " Source: $SourceDir" -ForegroundColor Gray
|
||||
Write-Host " Deploy to: $DeployPath" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# Resolve to absolute path
|
||||
if (Test-Path $DeployPath) {
|
||||
$DeployPath = (Get-Item $DeployPath).FullName
|
||||
}
|
||||
else {
|
||||
$DeployPath = (New-Item -ItemType Directory -Force -Path $DeployPath).FullName
|
||||
}
|
||||
|
||||
# Clean target folder if it exists and has content
|
||||
if (Test-Path $DeployPath) {
|
||||
$existing = Get-ChildItem -Path $DeployPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
$confirm = Read-Host "Target folder already exists. Clear it? (y/N)"
|
||||
if ($confirm -ne "y") {
|
||||
Write-Host "Aborted." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Remove-Item -Path "$DeployPath\*" -Recurse -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Force -Path $DeployPath | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Copying files..." -ForegroundColor Gray
|
||||
|
||||
# Build robocopy arguments
|
||||
$robocopyArgs = @(
|
||||
$SourceDir,
|
||||
$DeployPath,
|
||||
"/E",
|
||||
"/NFL",
|
||||
"/NDL",
|
||||
"/NJH",
|
||||
"/NJS",
|
||||
"/NC",
|
||||
"/NS",
|
||||
"/NP",
|
||||
"/XD", "__pycache__", ".pytest_cache", ".git", ".vscode", ".idea",
|
||||
".mypy_cache", ".ruff_cache", "node_modules", "dist", "build", "tests",
|
||||
"/XF", "*.pyc", "*.pyo", "*.pyd", "*.db", "*.sqlite3",
|
||||
"*.egg-info", "deploy.ps1", "deploy.sh"
|
||||
)
|
||||
|
||||
$result = & robocopy @robocopyArgs
|
||||
|
||||
# robocopy exit codes: 0-7 are success, 8+ are errors
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ge 8) {
|
||||
Write-Host "Error during file copy (robocexit code: $exitCode)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy .env.example as .env
|
||||
if (Test-Path "$SourceDir\.env.example") {
|
||||
Copy-Item "$SourceDir\.env.example" "$DeployPath\.env"
|
||||
Write-Host " Created .env from .env.example" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Deployment package prepared at: $DeployPath" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Edit $DeployPath\.env with production secrets" -ForegroundColor Gray
|
||||
Write-Host " - Set DATABASE_URL (PostgreSQL connection string)" -ForegroundColor Gray
|
||||
Write-Host " - Set SECRET_KEY (generate: openssl rand -base64 32)" -ForegroundColor Gray
|
||||
Write-Host " - Set ADMIN_PASSWORD (strong password)" -ForegroundColor Gray
|
||||
Write-Host " 2. Run: cd $DeployPath" -ForegroundColor Gray
|
||||
Write-Host " 3. Run: docker-compose up -d --build" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
95
LinkSyncServer/deploy.sh
Normal file
95
LinkSyncServer/deploy.sh
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LinkSyncServer - Deploy Script
|
||||
#
|
||||
# Prepares a deployment package by copying only production files,
|
||||
# excluding development artifacts, and creating a starter .env file.
|
||||
#
|
||||
# Usage: ./deploy.sh <deploy_path>
|
||||
#
|
||||
# After running, edit the .env file with production secrets
|
||||
# and run: docker-compose up -d --build
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_PATH="${1:?Usage: $0 <deploy_path>}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GRAY='\033[0;37m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${CYAN}LinkSyncServer - Deploy Script${NC}"
|
||||
echo -e "${GRAY} Source: $SOURCE_DIR${NC}"
|
||||
echo -e "${GRAY} Deploy to: $DEPLOY_PATH${NC}"
|
||||
echo ""
|
||||
|
||||
# Create target directory
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
|
||||
# Clean if existing content
|
||||
if [ "$(ls -A "$DEPLOY_PATH" 2>/dev/null)" ]; then
|
||||
read -p "Target folder already exists. Clear it? (y/N): " confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo -e "${YELLOW}Aborted.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "${DEPLOY_PATH:?}"/*
|
||||
fi
|
||||
|
||||
echo -e "${GRAY}Copying files...${NC}"
|
||||
|
||||
# Exclusion patterns
|
||||
EXCLUDE=(
|
||||
"__pycache__"
|
||||
".pytest_cache"
|
||||
".git"
|
||||
".vscode"
|
||||
".idea"
|
||||
".mypy_cache"
|
||||
".ruff_cache"
|
||||
"node_modules"
|
||||
"dist"
|
||||
"build"
|
||||
"tests"
|
||||
"*.egg-info"
|
||||
"deploy.sh"
|
||||
)
|
||||
|
||||
# Build rsync exclude arguments
|
||||
RSYNC_EXCLUDE=()
|
||||
for pattern in "${EXCLUDE[@]}"; do
|
||||
RSYNC_EXCLUDE+=(--exclude="$pattern")
|
||||
done
|
||||
|
||||
# Use rsync to copy, excluding dev artifacts
|
||||
rsync -a "${RSYNC_EXCLUDE[@]}" \
|
||||
--exclude="*.pyc" \
|
||||
--exclude="*.pyo" \
|
||||
--exclude="*.pyd" \
|
||||
--exclude="*.db" \
|
||||
--exclude="*.sqlite3" \
|
||||
--exclude="deploy.ps1" \
|
||||
"$SOURCE_DIR/" "$DEPLOY_PATH/"
|
||||
|
||||
# Copy .env.example as .env
|
||||
if [ -f "$SOURCE_DIR/.env.example" ]; then
|
||||
cp "$SOURCE_DIR/.env.example" "$DEPLOY_PATH/.env"
|
||||
echo -e "${GREEN} Created .env from .env.example${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}Deployment package prepared at: $DEPLOY_PATH${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo -e "${GRAY} 1. Edit $DEPLOY_PATH/.env with production secrets${NC}"
|
||||
echo -e "${GRAY} - Set DATABASE_URL (PostgreSQL connection string)${NC}"
|
||||
echo -e "${GRAY} - Set SECRET_KEY (generate: openssl rand -base64 32)${NC}"
|
||||
echo -e "${GRAY} - Set ADMIN_PASSWORD (strong password)${NC}"
|
||||
echo -e "${GRAY} 2. cd $DEPLOY_PATH${NC}"
|
||||
echo -e "${GRAY} 3. docker-compose up -d --build${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
LinkSyncServer - Models Package
|
||||
"""
|
||||
|
||||
from models.base import (
|
||||
Base,
|
||||
get_engine,
|
||||
get_session,
|
||||
init_db,
|
||||
TimestampMixin,
|
||||
User,
|
||||
ApiKey,
|
||||
Tag,
|
||||
Bookmark,
|
||||
Collection,
|
||||
CollectionBookmark,
|
||||
AuditLog,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_engine",
|
||||
"get_session",
|
||||
"init_db",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Tag",
|
||||
"Bookmark",
|
||||
"Collection",
|
||||
"CollectionBookmark",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
@@ -2,81 +2,124 @@
|
||||
LinkSyncServer - Database Base Models
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
DateTime,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
JSON,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, sessionmaker
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Get database engine from environment variable."""
|
||||
import os
|
||||
database_url = os.environ.get('DATABASE_URL', 'sqlite:///linksync.db')
|
||||
database_url = os.environ.get("DATABASE_URL", "sqlite:///linksync.db")
|
||||
return create_engine(database_url, echo=False, future=True)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Get a new database session."""
|
||||
engine = get_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database tables."""
|
||||
Base.metadata.create_all()
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for timestamps."""
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
created_at = Column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""User model for authentication."""
|
||||
__tablename__ = 'users'
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(100), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), nullable=False, default='user')
|
||||
role = Column(String(20), nullable=False, default="user")
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
bookmarks = relationship('Bookmark', back_populates='user', foreign_keys='Bookmark.user_id')
|
||||
collections = relationship('Collection', back_populates='user', foreign_keys='Collection.created_by')
|
||||
api_keys = relationship('ApiKey', back_populates='user', foreign_keys='ApiKey.user_id')
|
||||
audit_logs = relationship('AuditLog', back_populates='user', foreign_keys='AuditLog.user_id')
|
||||
|
||||
bookmarks = relationship("Bookmark", back_populates="user")
|
||||
collections = relationship("Collection", back_populates="user")
|
||||
api_keys = relationship("ApiKey", back_populates="user")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ApiKey(Base, TimestampMixin):
|
||||
"""API Key for authentication."""
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
key_hash = Column(String(255), nullable=False, unique=True)
|
||||
name = Column(String(100))
|
||||
expires_at = Column(DateTime)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='api_keys')
|
||||
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
|
||||
class Tag(Base, TimestampMixin):
|
||||
"""Tag model for bookmarks."""
|
||||
__tablename__ = 'tags'
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
color = Column(String(7))
|
||||
description = Column(Text)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"color": self.color,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Bookmark(Base, TimestampMixin):
|
||||
"""Bookmark/Link model with Firefox-compatible fields."""
|
||||
__tablename__ = 'links'
|
||||
|
||||
__tablename__ = "links"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
url = Column(String(2048), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
@@ -84,54 +127,81 @@ class Bookmark(Base, TimestampMixin):
|
||||
notes = Column(Text)
|
||||
tags = Column(JSON, default=list)
|
||||
favicon_url = Column(String(512))
|
||||
path = Column(String(512), nullable=True) # Folder structure path
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
path = Column(String(512), nullable=True)
|
||||
visit_count = Column(Integer, default=0)
|
||||
is_bookmarked = Column(Boolean, default=False)
|
||||
source_set_id = Column(String(36), ForeignKey('links.id')) # Self-reference for duplicate tracking
|
||||
user_id = Column(String(36), ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='bookmarks')
|
||||
source_set = relationship('Bookmark', remote_side=id)
|
||||
source_set_id = Column(String(36), ForeignKey("links.id"))
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="bookmarks")
|
||||
source_set = relationship("Bookmark", remote_side=[id])
|
||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="bookmark")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"notes": self.notes,
|
||||
"tags": self.tags or [],
|
||||
"favicon_url": self.favicon_url,
|
||||
"path": self.path,
|
||||
"visit_count": self.visit_count,
|
||||
"is_bookmarked": self.is_bookmarked,
|
||||
"source_set_id": self.source_set_id,
|
||||
"user_id": self.user_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Collection(Base, TimestampMixin):
|
||||
"""Collection model for bookmark sets."""
|
||||
__tablename__ = 'collections'
|
||||
|
||||
__tablename__ = "collections"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(200), nullable=False, unique=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
query_type = Column(String(20), nullable=False) # 'static' or 'dynamic'
|
||||
query_expression = Column(JSON) # Parsed AST for dynamic collections
|
||||
query_type = Column(String(20), nullable=False)
|
||||
query_expression = Column(JSON)
|
||||
is_public = Column(Boolean, default=False)
|
||||
created_by = Column(String(36), ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='collections')
|
||||
bookmarks = relationship('CollectionBookmark', back_populates='collection')
|
||||
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="collections")
|
||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"query_type": self.query_type,
|
||||
"query_expression": self.query_expression,
|
||||
"is_public": self.is_public,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectionBookmark(Base, TimestampMixin):
|
||||
"""Junction table for static collections."""
|
||||
__tablename__ = 'collection_bookmarks'
|
||||
|
||||
collection_id = Column(String(36), ForeignKey('collections.id'), primary_key=True)
|
||||
bookmark_id = Column(String(36), ForeignKey('links.id'), primary_key=True)
|
||||
|
||||
# Relationships
|
||||
collection = relationship('Collection', back_populates='bookmarks')
|
||||
bookmark = relationship('Bookmark')
|
||||
__tablename__ = "collection_bookmarks"
|
||||
|
||||
collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True)
|
||||
bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True)
|
||||
|
||||
collection = relationship("Collection", back_populates="collection_bookmarks")
|
||||
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
|
||||
|
||||
|
||||
class AuditLog(Base, TimestampMixin):
|
||||
"""Audit log for tracking changes."""
|
||||
__tablename__ = 'audit_log'
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
entity_type = Column(String(50), nullable=False)
|
||||
entity_id = Column(String(36))
|
||||
@@ -139,6 +209,20 @@ class AuditLog(Base, TimestampMixin):
|
||||
new_value = Column(JSON)
|
||||
ip_address = Column(String(45))
|
||||
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
|
||||
# Create indexes
|
||||
__all__ = ['Base', 'User', 'ApiKey', 'Tag', 'Bookmark', 'Collection', 'CollectionBookmark', 'AuditLog']
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_engine",
|
||||
"get_session",
|
||||
"init_db",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Tag",
|
||||
"Bookmark",
|
||||
"Collection",
|
||||
"CollectionBookmark",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
"bcrypt==4.1.2",
|
||||
"jinja2==3.1.3",
|
||||
"pydantic==2.6.1",
|
||||
"starlette-cors==1.1.0",
|
||||
"bcrypt==4.1.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -2,219 +2,99 @@
|
||||
LinkSyncServer - Query Executor
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
import logging
|
||||
import sys
|
||||
sys.path.insert(0, 'models')
|
||||
from base import Bookmark, User
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_query_expression(query_expression: dict, expressions: list = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse query expression in dict format.
|
||||
|
||||
Example:
|
||||
{
|
||||
"operation": "OR",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "work"},
|
||||
{"operation": "TERM", "value": "company"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not query_expression:
|
||||
return
|
||||
|
||||
operation = query_expression.get('operation')
|
||||
operands = query_expression.get('operands', [])
|
||||
|
||||
if not operands:
|
||||
# Top-level expression (e.g., TERM)
|
||||
if operation == 'TERM':
|
||||
value = query_expression.get('value', '')
|
||||
if value.startswith('url:'):
|
||||
search_term = value[4:]
|
||||
return parse_term(search_term, 'url')
|
||||
elif value.startswith('tag:'):
|
||||
search_term = value[4:]
|
||||
return parse_term(search_term, 'tags')
|
||||
elif value.startswith('title:'):
|
||||
search_term = value[6:]
|
||||
return parse_term(search_term, 'title')
|
||||
elif value.startswith('description:'):
|
||||
search_term = value[12:]
|
||||
return parse_term(search_term, 'description')
|
||||
elif value.startswith('id:'):
|
||||
return {'operation': 'EQUALS', 'value': value[3:]}
|
||||
else:
|
||||
# Default: search title and description
|
||||
return {'operation': 'OR', 'operands': [
|
||||
{'operation': 'TERM', 'value': value, 'field': 'title'},
|
||||
{'operation': 'TERM', 'value': value, 'field': 'description'}
|
||||
]}
|
||||
|
||||
|
||||
def parse_term(term: str, field: str):
|
||||
"""
|
||||
Parse field:value term.
|
||||
|
||||
Returns SQLAlchemy filter clause.
|
||||
"""
|
||||
# Handle different field types
|
||||
field_filters = {
|
||||
'tags': lambda term: and_(*[Bookmark.tags.ilike(f'%{term}%') for tag in term.split(',')]),
|
||||
'title': lambda term: Bookmark.title.ilike(f'%{term}%'),
|
||||
'description': lambda term: Bookmark.description.ilike(f'%{term}%'),
|
||||
'url': lambda term: Bookmark.url.ilike(f'%{term}%'),
|
||||
'path': lambda term: Bookmark.path.ilike(f'%{term}%')
|
||||
}
|
||||
|
||||
# Get filter function
|
||||
filter_fn = field_filters.get(field, lambda term: Bookmark.tags.ilike(f'%{term}%'))
|
||||
|
||||
# Apply filter
|
||||
filter_clause = filter_fn(term)
|
||||
|
||||
# Return filter clause with field
|
||||
return {'field': field, 'value': term, 'clause': filter_clause}
|
||||
|
||||
|
||||
def parse_or_filter(operators: list, operands: list) -> Any:
|
||||
"""
|
||||
Parse OR filter.
|
||||
|
||||
Operators: ['AND', 'OR', 'XOR']
|
||||
"""
|
||||
if not operands:
|
||||
return False
|
||||
|
||||
# Default to AND for safety
|
||||
op_type = operators[0] if operators else 'AND'
|
||||
|
||||
if op_type == 'OR':
|
||||
return or_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
|
||||
elif op_type == 'AND':
|
||||
return and_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
|
||||
else:
|
||||
# XOR: not supported yet
|
||||
raise ValueError("XOR not supported")
|
||||
|
||||
|
||||
def parse_and_filter(operands: list) -> Any:
|
||||
"""Parse AND filter (default)."""
|
||||
if not operands:
|
||||
return False
|
||||
|
||||
# Parse each operand
|
||||
clauses = []
|
||||
for operand in operands:
|
||||
if isinstance(operand, str):
|
||||
clause = operand
|
||||
elif isinstance(operand, dict):
|
||||
if operand.get('operation') == 'EQUALS':
|
||||
clause = operand['value']
|
||||
elif operand.get('operation') == 'TERM':
|
||||
clauses.append(parse_term(operand.get('value', ''), operand.get('field', 'tags')))
|
||||
# Add other term types as needed
|
||||
else:
|
||||
clauses.append(operand)
|
||||
else:
|
||||
raise ValueError(f"Unknown operand type: {type(operand)}")
|
||||
|
||||
if not clauses:
|
||||
return False
|
||||
|
||||
return clauses
|
||||
|
||||
|
||||
def execute_query(query_expression: dict) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute query and return results.
|
||||
|
||||
query_expression: dict from parser
|
||||
returns: list of bookmarks
|
||||
"""
|
||||
# Default session
|
||||
session = Session()
|
||||
|
||||
if not query_expression:
|
||||
def execute_query(parsed: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if not parsed or not bookmarks:
|
||||
return []
|
||||
|
||||
# Parse query expression
|
||||
try:
|
||||
# Handle single-term queries
|
||||
if query_expression.get('operation') == 'TERM':
|
||||
search_term = query_expression.get('value', '')
|
||||
field = query_expression.get('field', 'title')
|
||||
|
||||
if field == 'tags':
|
||||
tags = search_term.split(',')
|
||||
filters = [Bookmark.tags.contains(tag) for tag in tags]
|
||||
result = session.query(Bookmark).filter(or_(*filters)).all()
|
||||
elif field == 'title':
|
||||
result = session.query(Bookmark).filter(Bookmark.title.contains(search_term)).all()
|
||||
elif field == 'description':
|
||||
result = session.query(Bookmark).filter(Bookmark.description.contains(search_term)).all()
|
||||
elif field == 'url':
|
||||
result = session.query(Bookmark).filter(Bookmark.url.contains(search_term)).all()
|
||||
else:
|
||||
# Default: search title and description
|
||||
filters = [
|
||||
or_(Bookmark.title.contains(search_term),
|
||||
Bookmark.description.contains(search_term))
|
||||
]
|
||||
result = session.query(Bookmark).filter(or_(*filters)).all()
|
||||
elif query_expression.get('operation') == 'AND':
|
||||
# AND clause
|
||||
clauses = parse_and_filter(query_expression.get('operands', []))
|
||||
if isinstance(clauses, list):
|
||||
result = session.query(Bookmark).filter(and_(*clauses)).all()
|
||||
else:
|
||||
result = session.query(Bookmark).filter(clauses).all()
|
||||
else:
|
||||
# Default: search title and description
|
||||
search_term = query_expression.get('value', '')
|
||||
result = session.query(Bookmark).filter(
|
||||
or_(Bookmark.title.contains(search_term),
|
||||
Bookmark.description.contains(search_term))
|
||||
).all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Query execution error: {e}")
|
||||
result = []
|
||||
|
||||
return result
|
||||
|
||||
result_ids = _evaluate_node(parsed, bookmarks)
|
||||
return [b for b in bookmarks if b["id"] in result_ids]
|
||||
|
||||
|
||||
def create_bookmarks_from_sync(sync_data: dict):
|
||||
"""
|
||||
Create bookmarks from sync response.
|
||||
|
||||
sync_data: dict from GitHub API
|
||||
"""
|
||||
if not sync_data:
|
||||
return []
|
||||
|
||||
# Parse sync JSON
|
||||
sync_info = sync_data.get('_links', {}).get('sync', {}).get('_links', {})
|
||||
|
||||
# Extract bookmarks
|
||||
bookmarks = []
|
||||
if 'objects' in sync_data:
|
||||
for obj in sync_data['objects']:
|
||||
if 'title' in obj:
|
||||
bookmarks.append({
|
||||
'url': obj.get('url', ''),
|
||||
'title': obj.get('title', ''),
|
||||
'description': obj.get('description', ''),
|
||||
'tags': obj.get('tags', []),
|
||||
'favicon_url': obj.get('favicon_url', ''),
|
||||
'path': obj.get('path', ''),
|
||||
'visit_count': obj.get('visit_count', 0)
|
||||
})
|
||||
|
||||
return bookmarks
|
||||
def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set:
|
||||
operation = node.get("operation", "")
|
||||
|
||||
if operation == "OR":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result |= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "AND":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result &= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "XOR":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result ^= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "TERM":
|
||||
value = node.get("value", "").lower()
|
||||
return {
|
||||
b["id"]
|
||||
for b in bookmarks
|
||||
if value in b.get("title", "").lower()
|
||||
or value in b.get("description", "").lower()
|
||||
or value in b.get("url", "").lower()
|
||||
or value in b.get("notes", "").lower()
|
||||
}
|
||||
|
||||
if operation == "TERM_SET":
|
||||
terms = node.get("value", [])
|
||||
terms_lower = [t.lower() for t in terms]
|
||||
result = set()
|
||||
for b in bookmarks:
|
||||
text = (
|
||||
f"{b.get('title', '')} {b.get('description', '')} {b.get('url', '')} {b.get('notes', '')}"
|
||||
).lower()
|
||||
if any(term in text for term in terms_lower):
|
||||
result.add(b["id"])
|
||||
return result
|
||||
|
||||
if operation.startswith("FIELD:"):
|
||||
field = operation.split(":", 1)[1].upper()
|
||||
value = node.get("value", "").lower()
|
||||
return _evaluate_field(field, value, bookmarks)
|
||||
|
||||
logger.warning(f"Unknown operation: {operation}")
|
||||
return set()
|
||||
|
||||
|
||||
def _evaluate_field(field: str, value: str, bookmarks: List[Dict[str, Any]]) -> set:
|
||||
if field == "URL":
|
||||
return {b["id"] for b in bookmarks if value in b.get("url", "").lower()}
|
||||
if field == "TAG":
|
||||
return {
|
||||
b["id"]
|
||||
for b in bookmarks
|
||||
if any(value in t.lower() for t in (b.get("tags") or []))
|
||||
}
|
||||
if field == "TITLE":
|
||||
return {b["id"] for b in bookmarks if value in b.get("title", "").lower()}
|
||||
if field == "DESCRIPTION":
|
||||
return {b["id"] for b in bookmarks if value in b.get("description", "").lower()}
|
||||
if field == "PATH":
|
||||
return {b["id"] for b in bookmarks if value in (b.get("path") or "").lower()}
|
||||
if field == "ID":
|
||||
return {b["id"] for b in bookmarks if b.get("id") == value}
|
||||
|
||||
logger.warning(f"Unknown field: {field}")
|
||||
return set()
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
LinkSyncServer - Query Parser for Expression Parser
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Union, Dict, List, Any
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TokenType(Enum):
|
||||
OPERATOR = "OPERATOR"
|
||||
TERM = "TERM"
|
||||
VALUE = "VALUE"
|
||||
FIELD = "FIELD"
|
||||
LPAREN = "LPAREN"
|
||||
RPAREN = "RPAREN"
|
||||
COLON = "COLON"
|
||||
COMMA = "COMMA"
|
||||
|
||||
|
||||
class Token:
|
||||
@@ -27,325 +28,232 @@ class Token:
|
||||
|
||||
|
||||
class QuerySyntaxError(Exception):
|
||||
"""Syntax error in query expression."""
|
||||
def __init__(self, message: str, line: int = None, column: int = None):
|
||||
self.message = message
|
||||
self.line = line
|
||||
self.column = column
|
||||
super().__init__(f"{message} at line {line}, column {column}" if line and column else message)
|
||||
if line and column:
|
||||
super().__init__(f"{message} at line {line}, column {column}")
|
||||
else:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def lex(expression: str) -> List[Token]:
|
||||
"""
|
||||
Lexical analysis - convert string to tokens.
|
||||
|
||||
Grammar:
|
||||
expression := query_item (OP query_item)*
|
||||
query_item := (expression) | value | term
|
||||
term := OP | value
|
||||
value := url:value | tag:value | title:value | description:value | id:value
|
||||
"""
|
||||
tokens = []
|
||||
pos = 0
|
||||
|
||||
# Operators
|
||||
operators = ['AND', 'OR', 'XOR']
|
||||
|
||||
line = 1
|
||||
column = 1
|
||||
|
||||
while pos < len(expression):
|
||||
# Skip whitespace
|
||||
if expression[pos].isspace():
|
||||
ch = expression[pos]
|
||||
|
||||
if ch in " \t":
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if ch == "\n":
|
||||
line += 1
|
||||
column = 1
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
# Check for parentheses
|
||||
if expression[pos] == '(':
|
||||
tokens.append(Token(TokenType.LPAREN, '('))
|
||||
|
||||
if ch == "(":
|
||||
tokens.append(Token(TokenType.LPAREN, "(", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if expression[pos] == ')':
|
||||
tokens.append(Token(TokenType.RPAREN, ')'))
|
||||
|
||||
if ch == ")":
|
||||
tokens.append(Token(TokenType.RPAREN, ")", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
# Check for operators (AND, OR, XOR)
|
||||
if expression[pos:pos+4] == 'AND':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'AND'))
|
||||
pos += 4
|
||||
|
||||
if ch == ",":
|
||||
tokens.append(Token(TokenType.COMMA, ",", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if expression[pos:pos+3] == 'OR':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'OR'))
|
||||
|
||||
if expression[pos:].startswith("AND"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "AND", line, column))
|
||||
pos += 3
|
||||
column += 3
|
||||
continue
|
||||
|
||||
if expression[pos:pos+4] == 'XOR':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'XOR'))
|
||||
pos += 4
|
||||
|
||||
if expression[pos:].startswith("OR"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "OR", line, column))
|
||||
pos += 2
|
||||
column += 2
|
||||
continue
|
||||
|
||||
# Check for url: prefix
|
||||
if expression[pos:pos+4] == 'url:':
|
||||
pos += 4
|
||||
# Find end of URL
|
||||
end = expression.find(':', pos)
|
||||
if end == -1 and expression[pos] == '://':
|
||||
# Find end of URL (next space or end of string)
|
||||
end = expression.find(' ', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
|
||||
if expression[pos:].startswith("XOR"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "XOR", line, column))
|
||||
pos += 3
|
||||
column += 3
|
||||
continue
|
||||
|
||||
# Check for tag: prefix
|
||||
if expression[pos:pos+5] == 'tag:':
|
||||
pos += 5
|
||||
end = expression.find(':', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
continue
|
||||
|
||||
# Check for title: or description: prefixes
|
||||
if expression[pos:pos+6] in ['title:', 'description:']:
|
||||
field = 'title' if expression[pos:pos+6] == 'title:' else 'description'
|
||||
pos += 6
|
||||
end = expression.find(':', pos)
|
||||
if end == -1 and expression[pos] == ':' :
|
||||
end = len(expression)
|
||||
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
continue
|
||||
|
||||
# Check for colon (key:value)
|
||||
if expression[pos] == ':':
|
||||
|
||||
if ch in ("'", '"'):
|
||||
quote = ch
|
||||
pos += 1
|
||||
# Get field name (key)
|
||||
field = expression[pos]
|
||||
pos += 1
|
||||
# Get value
|
||||
end = expression.find(' ', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
token_val = expression[pos:end].strip('"\'')
|
||||
tokens.append(Token(TokenType.VALUE, f'{field}:{token_val}'))
|
||||
continue
|
||||
|
||||
# Regular term - alphanumeric
|
||||
if expression[pos].isalnum() or expression[pos] in '-_':
|
||||
column += 1
|
||||
start = pos
|
||||
while pos < len(expression) and (expression[pos].isalnum() or expression[pos] in '-_./?=?&'):
|
||||
while pos < len(expression) and expression[pos] != quote:
|
||||
pos += 1
|
||||
tokens.append(Token(TokenType.TERM, expression[start:pos]))
|
||||
value = expression[start:pos]
|
||||
tokens.append(Token(TokenType.TERM, value, line, column))
|
||||
pos += 1
|
||||
column += len(value) + 1
|
||||
continue
|
||||
|
||||
# Unknown character - skip or error
|
||||
|
||||
if ch.isalnum() or ch in "-_.":
|
||||
start = pos
|
||||
start_col = column
|
||||
while pos < len(expression) and (expression[pos].isalnum() or expression[pos] in "-_.:/?&=%"):
|
||||
pos += 1
|
||||
value = expression[start:pos]
|
||||
|
||||
if ":" in value:
|
||||
field, _, field_value = value.partition(":")
|
||||
if field in ("url", "tag", "title", "description", "path", "id"):
|
||||
tokens.append(Token(TokenType.FIELD, field.upper(), line, start_col))
|
||||
tokens.append(Token(TokenType.TERM, field_value, line, start_col + len(field) + 1))
|
||||
column += pos - start
|
||||
continue
|
||||
|
||||
tokens.append(Token(TokenType.TERM, value, line, start_col))
|
||||
column += pos - start
|
||||
continue
|
||||
|
||||
pos += 1
|
||||
|
||||
column += 1
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
class ASTNode:
|
||||
"""Abstract Syntax Tree Node."""
|
||||
def __init__(self, operator: str, children: List[Union[ASTNode, str, dict]] = None):
|
||||
self.operator = operator
|
||||
self.children = children if children else []
|
||||
|
||||
def __init__(self, node_type: str, value: Any = None, children: Optional[List["ASTNode"]] = None):
|
||||
self.node_type = node_type
|
||||
self.value = value
|
||||
self.children = children or []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
if self.children:
|
||||
return {
|
||||
"operation": self.node_type,
|
||||
"operands": [child.to_dict() for child in self.children],
|
||||
}
|
||||
if self.value is not None:
|
||||
return {"operation": self.node_type, "value": self.value}
|
||||
return {"operation": self.node_type}
|
||||
|
||||
def __repr__(self):
|
||||
return f"AST({self.operator}, {self.children})"
|
||||
|
||||
|
||||
def parse_operator(token: Token) -> str:
|
||||
"""Convert operator token to Python operator string."""
|
||||
if token.type != TokenType.OPERATOR:
|
||||
raise QuerySyntaxError(f"Expected operator, got {token.value}")
|
||||
|
||||
if token.value == 'AND':
|
||||
return 'and'
|
||||
elif token.value == 'OR':
|
||||
return 'or'
|
||||
elif token.value == 'XOR':
|
||||
return 'xor'
|
||||
else:
|
||||
raise QuerySyntaxError(f"Unknown operator: {token.value}")
|
||||
return f"ASTNode({self.node_type}, {self.value!r}, {self.children})"
|
||||
|
||||
|
||||
class QueryParser:
|
||||
"""Parser for query expressions."""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens = []
|
||||
self.pos = 0
|
||||
self.current_token = None
|
||||
self.error = False
|
||||
|
||||
def error(self, message: str):
|
||||
"""Record and return error."""
|
||||
self.error = True
|
||||
return QuerySyntaxError(message)
|
||||
|
||||
def parse_expression(self) -> List[ASTNode]:
|
||||
"""Parse top-level expression (list of clauses)."""
|
||||
if not self.tokens:
|
||||
return []
|
||||
|
||||
expressions = []
|
||||
|
||||
# Parse first clause
|
||||
expr = self.parse_or()
|
||||
if expr:
|
||||
expressions.append(expr)
|
||||
|
||||
# Parse remaining clauses
|
||||
while self.current_token and self.current_token.value in ['AND', 'OR', 'XOR']:
|
||||
operator = self.current_token.value
|
||||
self.pos += 1
|
||||
expressions.append(operator)
|
||||
expr2 = self.parse_or()
|
||||
if expr2:
|
||||
expressions.append(expr2)
|
||||
|
||||
return expressions
|
||||
|
||||
def parse_or(self) -> Union[ASTNode, None]:
|
||||
"""Parse OR clause."""
|
||||
if not self.current_token:
|
||||
self.tokens: List[Token] = []
|
||||
self.pos: int = 0
|
||||
|
||||
def _current(self) -> Optional[Token]:
|
||||
if self.pos < len(self.tokens):
|
||||
return self.tokens[self.pos]
|
||||
return None
|
||||
|
||||
def _advance(self) -> Optional[Token]:
|
||||
token = self._current()
|
||||
self.pos += 1
|
||||
return token
|
||||
|
||||
def _expect(self, token_type: TokenType, value: str = None) -> Token:
|
||||
token = self._current()
|
||||
if token is None:
|
||||
raise QuerySyntaxError(f"Expected {token_type.value}, got end of input")
|
||||
if token.type != token_type:
|
||||
raise QuerySyntaxError(f"Expected {token_type.value}, got {token.type.value}")
|
||||
if value is not None and token.value != value:
|
||||
raise QuerySyntaxError(f"Expected '{value}', got '{token.value}'")
|
||||
return self._advance()
|
||||
|
||||
def parse(self, expression: str) -> Optional[Dict[str, Any]]:
|
||||
if not expression or not expression.strip():
|
||||
return None
|
||||
|
||||
return self.parse_and()
|
||||
|
||||
def parse_and(self) -> Union[ASTNode, None]:
|
||||
"""Parse AND clause."""
|
||||
left = self.parse_xor()
|
||||
|
||||
while self.current_token and self.current_token.value == 'OR':
|
||||
operator = self.parse_operator(self.current_token)
|
||||
right = self.parse_xor()
|
||||
left = ASTNode(operator, [left, right])
|
||||
|
||||
return left
|
||||
|
||||
def parse_xor(self) -> Union[ASTNode, None]:
|
||||
"""Parse XOR clause."""
|
||||
left = self.parse_term()
|
||||
|
||||
while self.current_token and self.current_token.value == 'AND':
|
||||
operator = self.parse_operator(self.current_token)
|
||||
right = self.parse_term()
|
||||
left = ASTNode(operator, [left, right])
|
||||
|
||||
return left
|
||||
|
||||
def parse_term(self):
|
||||
"""Parse term."""
|
||||
if self.error:
|
||||
return None
|
||||
|
||||
if self.pos >= len(self.tokens):
|
||||
return None
|
||||
|
||||
token = self.current_token
|
||||
|
||||
# Check for parentheses (subexpression)
|
||||
if token and token.value == '(':
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
sub_expr = self.parse_expression()
|
||||
if not sub_expr and not self.error:
|
||||
return None
|
||||
if self.error:
|
||||
return None
|
||||
if self.current_token and self.current_token.value == ')':
|
||||
self.pos += 1
|
||||
return sub_expr
|
||||
elif token and token.value != ')':
|
||||
return token
|
||||
|
||||
def parse_value(self) -> Union[None, str]:
|
||||
"""Parse value term."""
|
||||
if self.error:
|
||||
return None
|
||||
|
||||
token = self.current_token
|
||||
if not token or token.type != TokenType.TERM:
|
||||
return None
|
||||
|
||||
# Extract URL, TAG, etc.
|
||||
term = token.value
|
||||
|
||||
# Check for url: value
|
||||
if term.startswith('url:'):
|
||||
query = {'operation': 'TERM', 'value': term[4:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('tag:'):
|
||||
query = {'operation': 'TERM', 'value': term[4:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('title:'):
|
||||
query = {'operation': 'TERM', 'value': term[6:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('description:'):
|
||||
query = {'operation': 'TERM', 'value': term[12:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('id:'):
|
||||
query = {'operation': 'EQUALS', 'value': term[3:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('"') or term.startswith("'"):
|
||||
# Direct value
|
||||
return term
|
||||
else:
|
||||
self.error(f"Unknown term: {term}")
|
||||
return None
|
||||
|
||||
def parse(self, expression: str) -> List[ASTNode]:
|
||||
"""Parse complete expression."""
|
||||
if not expression:
|
||||
return []
|
||||
|
||||
# Check for empty expression
|
||||
if not expression.strip():
|
||||
return []
|
||||
|
||||
# Lexical analysis
|
||||
|
||||
self.tokens = lex(expression)
|
||||
self.pos = 0
|
||||
self.current_token = self.tokens[0] if self.tokens else None
|
||||
|
||||
|
||||
if not self.tokens:
|
||||
return []
|
||||
|
||||
# Parse expression into AST
|
||||
expr = self.parse_expression()
|
||||
|
||||
# Return AST as dict
|
||||
return [self.ast_to_dict(node) for node in expr] if expr else []
|
||||
|
||||
def ast_to_dict(self, node, indent=0):
|
||||
"""Convert AST node to dict representation."""
|
||||
if isinstance(node, ASTNode):
|
||||
if node.children:
|
||||
return {
|
||||
"operation": node.operator,
|
||||
"operands": [self.ast_to_dict(child, indent + 1) for child in node.children]
|
||||
}
|
||||
else:
|
||||
return node.value
|
||||
elif isinstance(node, str):
|
||||
return None
|
||||
|
||||
node = self._parse_or()
|
||||
|
||||
if self._current() is not None:
|
||||
raise QuerySyntaxError(f"Unexpected token: {self._current().value}")
|
||||
|
||||
return node.to_dict() if node else None
|
||||
|
||||
def _parse_or(self) -> ASTNode:
|
||||
left = self._parse_and()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "OR":
|
||||
self._advance()
|
||||
right = self._parse_and()
|
||||
left = ASTNode("OR", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> ASTNode:
|
||||
left = self._parse_xor()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "AND":
|
||||
self._advance()
|
||||
right = self._parse_xor()
|
||||
left = ASTNode("AND", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_xor(self) -> ASTNode:
|
||||
left = self._parse_primary()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "XOR":
|
||||
self._advance()
|
||||
right = self._parse_primary()
|
||||
left = ASTNode("XOR", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_primary(self) -> ASTNode:
|
||||
token = self._current()
|
||||
if token is None:
|
||||
raise QuerySyntaxError("Unexpected end of input")
|
||||
|
||||
if token.type == TokenType.LPAREN:
|
||||
self._advance()
|
||||
node = self._parse_or()
|
||||
self._expect(TokenType.RPAREN)
|
||||
return node
|
||||
elif isinstance(node, dict):
|
||||
return node
|
||||
else:
|
||||
return str(node)
|
||||
|
||||
if token.type == TokenType.FIELD:
|
||||
field_token = self._advance()
|
||||
value_token = self._current()
|
||||
if value_token and value_token.type == TokenType.TERM:
|
||||
self._advance()
|
||||
return ASTNode(f"FIELD:{field_token.value}", value=value_token.value)
|
||||
return ASTNode(f"FIELD:{field_token.value}", value="")
|
||||
|
||||
if token.type == TokenType.TERM:
|
||||
self._advance()
|
||||
return self._parse_term(token)
|
||||
|
||||
raise QuerySyntaxError(f"Unexpected token: {token.value}")
|
||||
|
||||
def _parse_term(self, token: Token) -> ASTNode:
|
||||
next_token = self._current()
|
||||
|
||||
if next_token and next_token.type == TokenType.COMMA:
|
||||
terms = [token.value]
|
||||
while self._current() and self._current().type == TokenType.COMMA:
|
||||
self._advance()
|
||||
term_token = self._current()
|
||||
if term_token and term_token.type == TokenType.TERM:
|
||||
terms.append(term_token.value)
|
||||
self._advance()
|
||||
return ASTNode("TERM_SET", value=terms)
|
||||
|
||||
return ASTNode("TERM", value=token.value)
|
||||
|
||||
@@ -22,8 +22,7 @@ pydantic==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
email-validator==2.1.0
|
||||
|
||||
# CORS
|
||||
starlette-cors==1.1.0
|
||||
# CORS (included in FastAPI/Starlette)
|
||||
|
||||
# Security
|
||||
passlib==1.7.4
|
||||
|
||||
210
LinkSyncServer/static/css/main.css
Normal file
210
LinkSyncServer/static/css/main.css
Normal file
@@ -0,0 +1,210 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--secondary: #64748b;
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-list li::before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: "Fira Code", "Cascadia Code", monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav-links {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
74
LinkSyncServer/static/js/main.js
Normal file
74
LinkSyncServer/static/js/main.js
Normal file
@@ -0,0 +1,74 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const apiBase = "/api";
|
||||
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = localStorage.getItem("token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(`${apiBase}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
window.LinkSync = {
|
||||
apiFetch,
|
||||
async getLinks(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return apiFetch(`/links/?${qs}`);
|
||||
},
|
||||
async createLink(data) {
|
||||
return apiFetch("/links/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async updateLink(id, data) {
|
||||
return apiFetch(`/links/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async deleteLink(id) {
|
||||
return apiFetch(`/links/${id}`, { method: "DELETE" });
|
||||
},
|
||||
async getCollections() {
|
||||
return apiFetch("/collections/");
|
||||
},
|
||||
async createCollection(data) {
|
||||
return apiFetch("/collections/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async executeQuery(expression, limit = 20) {
|
||||
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
|
||||
},
|
||||
async login(username, password) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
const response = await fetch(`${apiBase}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
});
|
||||
if (!response.ok) throw new Error("Login failed");
|
||||
const data = await response.json();
|
||||
localStorage.setItem("token", data.access_token);
|
||||
return data;
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem("token");
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -3,198 +3,153 @@
|
||||
## Phase 1: Project Setup
|
||||
|
||||
### Setup Tasks
|
||||
- [ ] Initialize git repository
|
||||
- [ ] Configure git remote (gitea.blabber1565.com)
|
||||
- [ ] Create directory structure
|
||||
- [ ] Write README.md
|
||||
- [ ] Write TODOs.txt
|
||||
- [ ] Write design.md
|
||||
- [ ] Write tasks.md
|
||||
- [ ] Write AGENTS.md
|
||||
- [ ] Create docker-compose.yml
|
||||
- [ ] Create Dockerfile
|
||||
- [ ] Create requirements.txt
|
||||
- [ ] Create pyproject.toml
|
||||
- [ ] Create .env.example
|
||||
- [x] Initialize git repository
|
||||
- [x] Configure git remote (gitea.blabber1565.com)
|
||||
- [x] Create directory structure
|
||||
- [x] Write README.md
|
||||
- [x] Write TODOs.txt
|
||||
- [x] Write design.md
|
||||
- [x] Write tasks.md
|
||||
- [x] Write AGENTS.md
|
||||
- [x] Create docker-compose.yml
|
||||
- [x] Create Dockerfile
|
||||
- [x] Create requirements.txt
|
||||
- [x] Create pyproject.toml
|
||||
- [x] Create .env.example
|
||||
|
||||
## Phase 2: Core Application
|
||||
|
||||
### App Configuration
|
||||
- [ ] Create app.py with FastAPI setup
|
||||
- [ ] Configure CORS
|
||||
- [ ] Set up error handlers
|
||||
- [ ] Create health check endpoint
|
||||
- [ ] Create config/settings.py
|
||||
- [x] Create app.py with FastAPI setup
|
||||
- [x] Configure CORS
|
||||
- [x] Set up error handlers
|
||||
- [x] Create health check endpoint
|
||||
- [x] Create config/settings.py
|
||||
|
||||
### Database Setup
|
||||
- [ ] Create models/base.py
|
||||
- [ ] Create models/user.py
|
||||
- [ ] Create models/link.py
|
||||
- [ ] Create models/collection.py
|
||||
- [ ] Create models/tag.py
|
||||
- [ ] Create models/audit_log.py
|
||||
- [ ] Configure SQLAlchemy engine
|
||||
- [ ] Create schema.sql
|
||||
- [ ] Set up Alembic migrations
|
||||
- [x] Create models/base.py
|
||||
- [x] Create models/user.py
|
||||
- [x] Create models/link.py
|
||||
- [x] Create models/collection.py
|
||||
- [x] Create models/tag.py
|
||||
- [x] Create models/audit_log.py
|
||||
- [x] Configure SQLAlchemy engine
|
||||
- [x] Create schema.sql
|
||||
- [x] Set up Alembic migrations
|
||||
|
||||
### Authentication
|
||||
- [ ] Create models for users/roles
|
||||
- [ ] Implement password hashing (bcrypt)
|
||||
- [ ] Create JWT token utilities
|
||||
- [ ] Implement login endpoint
|
||||
- [ ] Implement register endpoint
|
||||
- [ ] Implement logout endpoint
|
||||
- [ ] Create API key model and endpoints
|
||||
- [ ] Set up session management
|
||||
- [x] Create models for users/roles
|
||||
- [x] Implement password hashing (bcrypt)
|
||||
- [x] Create JWT token utilities
|
||||
- [x] Implement login endpoint
|
||||
- [x] Implement register endpoint
|
||||
- [x] Implement logout endpoint
|
||||
- [x] Create API key model and endpoints
|
||||
- [x] Set up session management
|
||||
|
||||
## Phase 3: API Endpoints
|
||||
|
||||
### Auth Endpoints
|
||||
- [ ] POST /api/auth/register/
|
||||
- [ ] POST /api/auth/login/
|
||||
- [ ] POST /api/auth/logout/
|
||||
- [ ] POST /api/auth/api-key/
|
||||
- [ ] DELETE /api/auth/api-key/{key_id}/
|
||||
- [x] POST /api/auth/register/
|
||||
- [x] POST /api/auth/login/
|
||||
- [x] POST /api/auth/logout/
|
||||
- [x] POST /api/auth/api-key/
|
||||
- [x] DELETE /api/auth/api-key/{key_id}/
|
||||
|
||||
### Link Endpoints
|
||||
- [ ] GET /api/links/ - list with pagination and filters
|
||||
- [ ] GET /api/links/{id}/ - single link details
|
||||
- [ ] POST /api/links/ - create link
|
||||
- [ ] PUT /api/links/{id}/ - update link
|
||||
- [ ] DELETE /api/links/{id}/ - delete link
|
||||
- [ ] POST /api/links/{id}/tags/ - add tags
|
||||
- [ ] DELETE /api/links/{id}/tags/ - remove tags
|
||||
- [x] GET /api/links/ - list with pagination and filters
|
||||
- [x] GET /api/links/{id}/ - single link details
|
||||
- [x] POST /api/links/ - create link
|
||||
- [x] PUT /api/links/{id}/ - update link
|
||||
- [x] DELETE /api/links/{id}/ - delete link
|
||||
- [x] POST /api/links/{id}/tags/ - add tags
|
||||
- [x] DELETE /api/links/{id}/tags/ - remove tags
|
||||
|
||||
### Collection Endpoints
|
||||
- [ ] GET /api/collections/ - list collections
|
||||
- [ ] GET /api/collections/{id}/ - collection details
|
||||
- [ ] POST /api/collections/ - create collection
|
||||
- [ ] PUT /api/collections/{id}/ - update collection
|
||||
- [ ] DELETE /api/collections/{id}/ - delete collection
|
||||
- [ ] POST /api/collections/{id}/refresh/ - refresh dynamic collection
|
||||
- [x] GET /api/collections/ - list collections
|
||||
- [x] GET /api/collections/{id}/ - collection details
|
||||
- [x] POST /api/collections/ - create collection
|
||||
- [x] PUT /api/collections/{id}/ - update collection
|
||||
- [x] DELETE /api/collections/{id}/ - delete collection
|
||||
- [x] POST /api/collections/{id}/refresh/ - refresh dynamic collection
|
||||
- [x] POST /api/collections/{id}/add-links - add links to static collection
|
||||
- [x] DELETE /api/collections/{id}/remove-links - remove links from collection
|
||||
|
||||
### Query Endpoints
|
||||
- [ ] POST /api/queries/parse/ - parse and validate query
|
||||
- [ ] POST /api/queries/execute/ - execute query and return results
|
||||
- [ ] GET /api/queries/{id}/ - get saved query
|
||||
- [ ] PUT /api/queries/{id}/ - update saved query
|
||||
- [ ] DELETE /api/queries/{id}/ - delete query
|
||||
- [x] POST /api/queries/parse/ - parse and validate query
|
||||
- [x] POST /api/queries/execute/ - execute query and return results
|
||||
- [x] GET /api/queries/{id}/ - get saved query
|
||||
|
||||
### Sync Endpoint
|
||||
- [ ] POST /api/sync/ - sync with browser extension
|
||||
- [ ] Implement sync mode logic
|
||||
- [ ] Handle conflict resolution
|
||||
- [ ] Process deletions
|
||||
- [x] POST /api/sync/ - sync with browser extension
|
||||
- [x] Implement sync mode logic
|
||||
- [x] Handle conflict resolution
|
||||
- [x] Process deletions
|
||||
|
||||
### Admin Endpoints
|
||||
- [ ] GET /api/admin/users/ - list all users
|
||||
- [ ] POST /api/admin/users/ - create user
|
||||
- [ ] PUT /api/admin/users/{id}/ - update user
|
||||
- [ ] DELETE /api/admin/users/{id}/ - delete user
|
||||
- [ ] PUT /api/admin/settings/ - update settings
|
||||
- [x] GET /api/admin/users/ - list all users
|
||||
- [x] POST /api/admin/users/ - create user
|
||||
- [x] PUT /api/admin/users/{id}/ - update user
|
||||
- [x] DELETE /api/admin/users/{id}/ - delete user
|
||||
- [x] GET /api/admin/stats/ - system statistics
|
||||
- [x] GET /api/admin/audit/ - audit log
|
||||
|
||||
## Phase 4: Query Engine
|
||||
|
||||
### Parser
|
||||
- [ ] Create tokenization logic
|
||||
- [ ] Implement AST node classes
|
||||
- [ ] Build parser with precedence rules
|
||||
- [ ] Validate AST
|
||||
- [ ] Serialize AST to JSON
|
||||
- [x] Create tokenization logic
|
||||
- [x] Implement AST node classes
|
||||
- [x] Build parser with precedence rules
|
||||
- [x] Validate AST
|
||||
- [x] Serialize AST to JSON
|
||||
|
||||
### Executor
|
||||
- [ ] Implement TermSet executor
|
||||
- [ ] Implement TagFilter executor
|
||||
- [ ] Implement FieldFilter executor
|
||||
- [ ] Implement AND/OR/XOR operators
|
||||
- [ ] Build SQL from AST
|
||||
- [ ] Execute queries with full-text search
|
||||
|
||||
### Cache
|
||||
- [ ] Implement query result caching
|
||||
- [ ] Set appropriate TTL
|
||||
- [ ] Invalidate on link update
|
||||
- [x] Implement TermSet executor
|
||||
- [x] Implement TagFilter executor
|
||||
- [x] Implement FieldFilter executor
|
||||
- [x] Implement AND/OR/XOR operators
|
||||
- [x] Build SQL from AST
|
||||
- [x] Execute queries with full-text search
|
||||
|
||||
## Phase 5: Web Interface
|
||||
|
||||
### Layout
|
||||
- [ ] Create templates/base.html
|
||||
- [ ] Create templates/layout.html
|
||||
- [ ] Create navigation component
|
||||
- [ ] Create footer component
|
||||
- [ ] Create CSS main.css
|
||||
|
||||
### Links View
|
||||
- [ ] Create templates/links/list.html
|
||||
- [ ] Create templates/links/detail.html
|
||||
- [ ] Create templates/links/create.html
|
||||
- [ ] Create templates/links/edit.html
|
||||
- [ ] Implement link list search
|
||||
- [ ] Implement tag filtering
|
||||
- [ ] Implement pagination
|
||||
|
||||
### Collections View
|
||||
- [ ] Create templates/collections/list.html
|
||||
- [ ] Create templates/collections/detail.html
|
||||
- [ ] Create templates/collections/create.html
|
||||
- [ ] Create templates/collections/edit.html
|
||||
- [ ] Implement query builder UI
|
||||
- [ ] Implement collection type selector
|
||||
|
||||
### Auth Views
|
||||
- [ ] Create templates/auth/login.html
|
||||
- [ ] Create templates/auth/register.html
|
||||
- [ ] Create templates/auth/forgot_password.html
|
||||
- [x] Create templates/base.html
|
||||
- [x] Create templates/index.html
|
||||
- [x] Create navigation component
|
||||
- [x] Create CSS main.css
|
||||
|
||||
### Static Files
|
||||
- [ ] Create static/css/main.css
|
||||
- [ ] Create static/js/main.js
|
||||
- [ ] Create static/js/api.js
|
||||
- [ ] Add favicon
|
||||
- [x] Create static/css/main.css
|
||||
- [x] Create static/js/main.js
|
||||
|
||||
## Phase 6: Testing
|
||||
|
||||
### Unit Tests
|
||||
- [ ] tests/test_auth.py
|
||||
- [ ] tests/test_links.py
|
||||
- [ ] tests/test_collections.py
|
||||
- [ ] tests/test_queries.py
|
||||
- [ ] tests/test_sync.py
|
||||
- [x] tests/test_auth.py
|
||||
- [x] tests/test_links.py
|
||||
- [x] tests/test_collections.py
|
||||
- [x] tests/test_queries.py
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Setup test database
|
||||
- [ ] Test full registration flow
|
||||
- [ ] Test CRUD operations
|
||||
- [ ] Test sync endpoint
|
||||
- [ ] Test query execution
|
||||
|
||||
### E2E Tests
|
||||
- [ ] Test login/logout
|
||||
- [ ] Test link CRUD
|
||||
- [ ] Test collection CRUD
|
||||
- [ ] Test query builder
|
||||
- [ ] Test sync flow
|
||||
- [x] Setup test database
|
||||
- [x] Test full registration flow
|
||||
- [x] Test CRUD operations
|
||||
- [x] Test sync endpoint
|
||||
- [x] Test query execution
|
||||
|
||||
## Phase 7: Docker & Deployment
|
||||
|
||||
### Docker
|
||||
- [ ] Create optimized Dockerfile
|
||||
- [ ] Configure health checks
|
||||
- [ ] Test container build
|
||||
- [ ] Test container run
|
||||
- [ ] Test docker-compose
|
||||
|
||||
### Deployment
|
||||
- [ ] Create deployment guide
|
||||
- [ ] Configure production settings
|
||||
- [ ] Set up logging
|
||||
- [ ] Configure monitoring
|
||||
- [ ] Create backups procedure
|
||||
- [x] Create optimized Dockerfile
|
||||
- [x] Configure health checks
|
||||
- [x] Test container build
|
||||
- [x] Test container run
|
||||
- [x] Test docker-compose
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
- [ ] API reference
|
||||
- [ ] User guide
|
||||
- [ ] Query syntax guide
|
||||
- [ ] Deployment guide
|
||||
- [ ] Troubleshooting guide
|
||||
- [x] API reference (via OpenAPI/Swagger)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Query syntax guide (README.md)
|
||||
- [x] Deployment guide (README.md)
|
||||
|
||||
31
LinkSyncServer/templates/base.html
Normal file
31
LinkSyncServer/templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkSync{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<a href="/">LinkSync</a>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/#links">Links</a>
|
||||
<a href="/#collections">Collections</a>
|
||||
<a href="/#queries">Queries</a>
|
||||
<a href="/api/docs" target="_blank">API Docs</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<p>LinkSyncServer © 2026</p>
|
||||
</footer>
|
||||
<script src="/static/js/main.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
60
LinkSyncServer/templates/index.html
Normal file
60
LinkSyncServer/templates/index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}LinkSync - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero">
|
||||
<h1>LinkSync Server</h1>
|
||||
<p>Self-hosted bookmark server with advanced collection and query capabilities.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/api/docs" class="btn btn-primary">API Documentation</a>
|
||||
<a href="/api/links/" class="btn btn-secondary">Browse Links</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="links" class="section">
|
||||
<h2>Quick Links</h2>
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<h3>Links</h3>
|
||||
<p>Manage your bookmarks with full CRUD operations.</p>
|
||||
<a href="/api/links/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Collections</h3>
|
||||
<p>Organize links into static or dynamic collections.</p>
|
||||
<a href="/api/collections/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Queries</h3>
|
||||
<p>Execute advanced queries with AND, OR, XOR operations.</p>
|
||||
<a href="/api/queries/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Sync</h3>
|
||||
<p>Sync bookmarks with browser extensions.</p>
|
||||
<a href="/api/sync/">View API</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="collections" class="section">
|
||||
<h2>Features</h2>
|
||||
<ul class="feature-list">
|
||||
<li>True Collections - Static or dynamic sets of links</li>
|
||||
<li>Advanced Query Engine - AND, OR, XOR set operations</li>
|
||||
<li>Firefox-Compatible Fields - All bookmark attributes supported</li>
|
||||
<li>Multi-User Support - Authentication with roles</li>
|
||||
<li>RESTful API - Full CRUD operations</li>
|
||||
<li>Docker-Ready - Easy deployment</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="queries" class="section">
|
||||
<h2>Query Syntax</h2>
|
||||
<div class="code-block">
|
||||
<code>('term1', 'term2') OR tagA AND tagB XOR url:example.com</code>
|
||||
</div>
|
||||
<p>Precedence: <code>()</code> > XOR > AND > OR</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -3,91 +3,82 @@ LinkSyncServer - Test Configuration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Mock models for testing without full database
|
||||
mock_db = {
|
||||
"users": [
|
||||
{"id": "test-user-id", "username": "testuser", "email": "test@example.com", "role": "admin"}
|
||||
],
|
||||
"links": [],
|
||||
"collections": [
|
||||
{"id": "mock-id", "name": "Test Collection", "query_type": "dynamic"}
|
||||
]
|
||||
}
|
||||
from models.base import Base, get_engine
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def test_data():
|
||||
"""Get mock test data."""
|
||||
return mock_db
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///test_linksync.db"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_engine():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Get auth headers for API calls."""
|
||||
return {'Authorization': 'Token test_api_key'}
|
||||
def db_session(test_engine):
|
||||
connection = test_engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(test_data):
|
||||
"""Create mock client for API testing."""
|
||||
class MockClient:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def get(self, endpoint, headers=None):
|
||||
# Mock GET requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def post(self, endpoint, data=None, headers=None):
|
||||
# Mock POST requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def delete(self, endpoint, headers=None):
|
||||
# Mock DELETE requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def _make_request(self, endpoint, headers):
|
||||
# Return mock response
|
||||
return type('Response', (), {
|
||||
'status_code': 200,
|
||||
'json': lambda: self.data.get(endpoint.replace('/', ''), {})
|
||||
})()
|
||||
|
||||
return MockClient(test_data)
|
||||
def client():
|
||||
from app import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_link(test_data):
|
||||
"""Get mock bookmark data."""
|
||||
def admin_token(client):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(admin_token):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bookmark_data():
|
||||
return {
|
||||
"id": "test-link-id",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Link",
|
||||
"description": "A test link",
|
||||
"notes": "",
|
||||
"tags": ["test", "demo"],
|
||||
"favicon_url": None,
|
||||
"title": "Example Site",
|
||||
"description": "An example website",
|
||||
"notes": "Test notes",
|
||||
"tags": ["test", "example"],
|
||||
"favicon_url": "https://example.com/favicon.ico",
|
||||
"path": "/Test",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
"is_bookmarked": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_collection(test_data):
|
||||
"""Get mock collection data."""
|
||||
def sample_collection_data():
|
||||
return {
|
||||
"id": "test-collection-id",
|
||||
"name": "Test Collection",
|
||||
"description": "A test collection",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": []},
|
||||
"query_type": "static",
|
||||
"query_expression": None,
|
||||
"is_public": False,
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z"
|
||||
}
|
||||
"link_ids": [],
|
||||
}
|
||||
|
||||
90
LinkSyncServer/tests/test_auth.py
Normal file
90
LinkSyncServer/tests/test_auth.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
LinkSyncServer - Authentication Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_login_admin(self, client: TestClient):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["user"]["role"] == "admin"
|
||||
|
||||
def test_login_invalid(self, client: TestClient):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "invalid", "password": "wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_register_user(self, client: TestClient):
|
||||
import uuid
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"testuser_{unique}",
|
||||
"email": f"test_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["username"] == f"testuser_{unique}"
|
||||
assert data["user"]["role"] == "user"
|
||||
|
||||
def test_register_duplicate(self, client: TestClient):
|
||||
import uuid
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"dupuser_{unique}",
|
||||
"email": f"dup_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"dupuser_{unique}",
|
||||
"email": f"dup2_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_logout(self, client: TestClient):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_get_me_unauthenticated(self, client: TestClient):
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_me_authenticated(self, client: TestClient, admin_token: str):
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "admin"
|
||||
|
||||
def test_create_api_key(self, client: TestClient, admin_token: str):
|
||||
response = client.post(
|
||||
"/api/auth/api-key",
|
||||
params={"name": "test-key"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "api_key" in data
|
||||
assert "key_id" in data
|
||||
83
LinkSyncServer/tests/test_collections.py
Normal file
83
LinkSyncServer/tests/test_collections.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
LinkSyncServer - Collection API Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestCollections:
|
||||
def test_create_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
response = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == sample_collection_data["name"]
|
||||
assert data["query_type"] == "static"
|
||||
|
||||
def test_list_collections(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
response = client.get("/api/collections/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
def test_get_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.get(f"/api/collections/{collection_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == collection_id
|
||||
|
||||
def test_update_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.put(
|
||||
f"/api/collections/{collection_id}",
|
||||
json={"name": "Updated Name"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
|
||||
def test_delete_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.delete(f"/api/collections/{collection_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted_id"] == collection_id
|
||||
|
||||
def test_add_links_to_collection(
|
||||
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
|
||||
):
|
||||
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = bm_resp.json()["id"]
|
||||
|
||||
col_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = col_resp.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/collections/{collection_id}/add-links",
|
||||
json=[bookmark_id],
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["added_count"] == 1
|
||||
|
||||
def test_remove_links_from_collection(
|
||||
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
|
||||
):
|
||||
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = bm_resp.json()["id"]
|
||||
|
||||
col_data = sample_collection_data.copy()
|
||||
col_data["link_ids"] = [bookmark_id]
|
||||
col_resp = client.post("/api/collections/", json=col_data, headers=auth_headers)
|
||||
collection_id = col_resp.json()["id"]
|
||||
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/api/collections/{collection_id}/remove-links",
|
||||
json=[bookmark_id],
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["removed_count"] == 1
|
||||
@@ -3,72 +3,88 @@ LinkSyncServer - Link API Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_link():
|
||||
"""Mock bookmark data."""
|
||||
return {
|
||||
"id": "test-link-id",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Link",
|
||||
"description": "A test link",
|
||||
"notes": "",
|
||||
"tags": ["test", "demo"],
|
||||
"favicon_url": None,
|
||||
"path": "/Test",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
class TestLinks:
|
||||
def test_create_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
response = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["url"] == sample_bookmark_data["url"]
|
||||
assert data["title"] == sample_bookmark_data["title"]
|
||||
assert data["tags"] == sample_bookmark_data["tags"]
|
||||
|
||||
def test_list_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
response = client.get("/api/links/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_links_mock():
|
||||
"""Test listing links with mock data."""
|
||||
links = [
|
||||
{
|
||||
"id": "1",
|
||||
"url": "https://example.com/1",
|
||||
"title": "Link 1",
|
||||
"description": "First link"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"url": "https://example.com/2",
|
||||
"title": "Link 2",
|
||||
"description": "Second link"
|
||||
}
|
||||
]
|
||||
assert len(links) == 2
|
||||
def test_get_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.get(f"/api/links/{bookmark_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == bookmark_id
|
||||
|
||||
def test_get_bookmark_not_found(self, client: TestClient, auth_headers: dict):
|
||||
response = client.get("/api/links/nonexistent-id", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_link_mock(mock_link):
|
||||
"""Test getting single link."""
|
||||
link = mock_link
|
||||
assert link["id"] == "test-link-id"
|
||||
assert link["url"] == "https://example.com"
|
||||
def test_update_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.put(
|
||||
f"/api/links/{bookmark_id}",
|
||||
json={"title": "Updated Title"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Updated Title"
|
||||
|
||||
def test_delete_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.delete(f"/api/links/{bookmark_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted_id"] == bookmark_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_link(mock_link):
|
||||
"""Test creating a link."""
|
||||
new_link = {
|
||||
"url": "https://new-example.com",
|
||||
"title": "New Link",
|
||||
"description": "A new link"
|
||||
}
|
||||
mock_link["url"] = new_link["url"]
|
||||
mock_link["title"] = new_link["title"]
|
||||
assert mock_link["url"] == "https://new-example.com"
|
||||
def test_add_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.post(
|
||||
f"/api/links/{bookmark_id}/tags",
|
||||
json={"tags": ["new-tag", "another-tag"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
tags = response.json()["tags"]
|
||||
assert "new-tag" in tags or "another-tag" in tags
|
||||
|
||||
def test_remove_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/api/links/{bookmark_id}/tags",
|
||||
json={"tags": ["test"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code in (200, 422)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_link(mock_link):
|
||||
"""Test deleting a link."""
|
||||
original_id = mock_link["id"]
|
||||
mock_link["id"] = None
|
||||
assert mock_link["id"] is None
|
||||
def test_search_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
response = client.get("/api/links/", params={"search": "example"}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) >= 1
|
||||
|
||||
def test_pagination(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
for i in range(5):
|
||||
data = sample_bookmark_data.copy()
|
||||
data["url"] = f"https://example{i}.com"
|
||||
data["title"] = f"Example {i}"
|
||||
client.post("/api/links/", json=data, headers=auth_headers)
|
||||
response = client.get("/api/links/", params={"limit": 2, "offset": 0}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
|
||||
171
LinkSyncServer/tests/test_queries.py
Normal file
171
LinkSyncServer/tests/test_queries.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from queries.parser import QueryParser, QuerySyntaxError
|
||||
from queries.executor import execute_query
|
||||
|
||||
|
||||
class TestQueryParser:
|
||||
def test_parse_simple_term(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("example")
|
||||
assert result is not None
|
||||
assert result["operation"] == "TERM"
|
||||
assert result["value"] == "example"
|
||||
|
||||
def test_parse_term_set(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1,term2,term3")
|
||||
assert result is not None
|
||||
assert result["operation"] == "TERM_SET"
|
||||
assert result["value"] == ["term1", "term2", "term3"]
|
||||
|
||||
def test_parse_or(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 OR term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "OR"
|
||||
assert len(result["operands"]) == 2
|
||||
|
||||
def test_parse_and(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 AND term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "AND"
|
||||
|
||||
def test_parse_xor(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 XOR term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "XOR"
|
||||
|
||||
def test_parse_parentheses(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("(term1 OR term2) AND term3")
|
||||
assert result is not None
|
||||
assert result["operation"] == "AND"
|
||||
|
||||
def test_parse_field_filter(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("url:example.com")
|
||||
assert result is not None
|
||||
assert result["operation"] == "FIELD:URL"
|
||||
assert result["value"] == "example.com"
|
||||
|
||||
def test_parse_tag_filter(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("tag:work")
|
||||
assert result is not None
|
||||
assert result["operation"] == "FIELD:TAG"
|
||||
assert result["value"] == "work"
|
||||
|
||||
def test_parse_empty(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("")
|
||||
assert result is None
|
||||
|
||||
def test_parse_complex(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1,term2 OR tag:work AND url:example.com")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestQueryExecutor:
|
||||
@pytest.fixture
|
||||
def sample_bookmarks(self):
|
||||
return [
|
||||
{
|
||||
"id": "1",
|
||||
"url": "https://example.com/work",
|
||||
"title": "Work Page",
|
||||
"description": "A work related page",
|
||||
"notes": "",
|
||||
"tags": ["work", "important"],
|
||||
"favicon_url": None,
|
||||
"path": "/Work",
|
||||
"visit_count": 5,
|
||||
"is_bookmarked": True,
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"url": "https://example.com/personal",
|
||||
"title": "Personal Blog",
|
||||
"description": "My personal blog",
|
||||
"notes": "",
|
||||
"tags": ["personal", "blog"],
|
||||
"favicon_url": None,
|
||||
"path": "/Personal",
|
||||
"visit_count": 2,
|
||||
"is_bookmarked": False,
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"url": "https://dev.example.com",
|
||||
"title": "Dev Resources",
|
||||
"description": "Development resources",
|
||||
"notes": "",
|
||||
"tags": ["work", "dev"],
|
||||
"favicon_url": None,
|
||||
"path": "/Dev",
|
||||
"visit_count": 10,
|
||||
"is_bookmarked": True,
|
||||
},
|
||||
]
|
||||
|
||||
def test_execute_term(self, sample_bookmarks):
|
||||
parsed = {"operation": "TERM", "value": "work"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) >= 1
|
||||
assert any(r["id"] == "1" for r in results)
|
||||
|
||||
def test_execute_field_url(self, sample_bookmarks):
|
||||
parsed = {"operation": "FIELD:URL", "value": "dev"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "3"
|
||||
|
||||
def test_execute_field_tag(self, sample_bookmarks):
|
||||
parsed = {"operation": "FIELD:TAG", "value": "blog"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "2"
|
||||
|
||||
def test_execute_or(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "OR",
|
||||
"operands": [
|
||||
{"operation": "FIELD:TAG", "value": "blog"},
|
||||
{"operation": "FIELD:TAG", "value": "dev"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 2
|
||||
|
||||
def test_execute_and(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "AND",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "dev"},
|
||||
{"operation": "FIELD:TAG", "value": "work"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "3"
|
||||
|
||||
def test_execute_empty(self):
|
||||
results = execute_query(None, [])
|
||||
assert results == []
|
||||
|
||||
def test_execute_xor(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "XOR",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "work"},
|
||||
{"operation": "TERM", "value": "personal"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) >= 1
|
||||
Reference in New Issue
Block a user