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:
@@ -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",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user