diff --git a/.circleci/config.yml b/.circleci/config.yml
index 8bb0a9fb..87549ac9 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -142,6 +142,66 @@ jobs:
- run:
name: Stop React App
command: pkill -f npm
+ jest-tests:
+ docker:
+ - image: cimg/node:20.13
+ resource_class: medium
+ steps:
+ - checkout
+ - restore_cache:
+ key: app-{{ checksum "app/package-lock.json" }}
+ - run:
+ name: Install Dependencies
+ command: cd app && npm install --legacy-peer-deps
+ - save_cache:
+ key: app-{{ checksum "app/package-lock.json" }}
+ paths:
+ - ./app/node_modules
+ - run:
+ name: Run Jest Tests
+ command: cd app && CI=true npm test -- --watchAll=false --coverage --coverageReporters=text --coverageReporters=lcov
+ - store_artifacts:
+ path: app/coverage
+ destination: coverage-report
+ cypress-e2e-tests:
+ docker:
+ - image: cimg/node:20.18-browsers
+ resource_class: large
+ steps:
+ - checkout
+ - restore_cache:
+ key: app-browsers-{{ checksum "app/package-lock.json" }}
+ - run:
+ name: Install Dependencies
+ command: cd app && npm install --legacy-peer-deps
+ - save_cache:
+ key: app-browsers-{{ checksum "app/package-lock.json" }}
+ paths:
+ - ./app/node_modules
+ - run:
+ name: Start React App in Background
+ command: cd app && DISABLE_ESLINT_PLUGIN=true npm start
+ background: true
+ - run:
+ name: Wait for React App
+ command: |
+ for i in {1..60}; do
+ if curl -s http://localhost:3000 > /dev/null; then
+ echo "React app is ready!"
+ exit 0
+ fi
+ echo "Waiting for React app... ($i/60)"
+ sleep 2
+ done
+ echo "React app failed to start"
+ exit 1
+ - run:
+ name: Run Cypress Tests
+ command: cd app && CI=true npx cypress run
+ - store_artifacts:
+ path: app/cypress/videos
+ - store_artifacts:
+ path: app/cypress/screenshots
workflows:
wrolpi-api-tests:
@@ -153,3 +213,5 @@ workflows:
wrolpi-app-test:
jobs:
- react-app-start
+ - jest-tests
+ - cypress-e2e-tests
diff --git a/.gitignore b/.gitignore
index 1e711302..172ad019 100644
--- a/.gitignore
+++ b/.gitignore
@@ -125,7 +125,7 @@ mapnik.xml
docker-compose.override.yml
# test directory is used as media directory, we don't want to commit what a user downloads.
-test
+/test
pg_data
# Directories used to build images
@@ -135,3 +135,5 @@ pg_data
/pi-gen/*xz
.DS_Store
+app/cypress/screenshots
+app/cypress/videos
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..1071ab6e
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,12 @@
+{
+ "mcpServers": {
+ "postgres": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-postgres",
+ "postgresql://postgres:wrolpi@localhost:5432/wrolpi"
+ ]
+ }
+ }
+}
diff --git a/alembic/versions/66407d145b76_.py b/alembic/versions/66407d145b76_.py
new file mode 100644
index 00000000..bd2ea5b3
--- /dev/null
+++ b/alembic/versions/66407d145b76_.py
@@ -0,0 +1,87 @@
+"""Create collection table for domain collections
+
+Revision ID: 66407d145b76
+Revises: 4f03b9548f6e
+Create Date: 2025-10-26 10:57:16.462524
+
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.orm import Session
+
+
+# revision identifiers, used by Alembic.
+revision = '66407d145b76'
+down_revision = '4f03b9548f6e'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ # Step 1: Create new collection table (keeping channel table separate for now)
+ op.create_table(
+ 'collection',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('name', sa.String(), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('kind', sa.String(), nullable=False),
+ sa.Column('directory', sa.Text(), nullable=True),
+ sa.Column('tag_id', sa.Integer(), nullable=True),
+ sa.Column('created_date', sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.Column('item_count', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('total_size', sa.Integer(), nullable=False, server_default='0'),
+ sa.PrimaryKeyConstraint('id'),
+ sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name='collection_tag_id_fkey'),
+ sa.UniqueConstraint('directory', name='uq_collection_directory')
+ )
+
+ # Create indexes for collection
+ op.create_index('idx_collection_kind', 'collection', ['kind'], unique=False)
+ op.create_index('idx_collection_item_count', 'collection', ['item_count'], unique=False)
+ op.create_index('idx_collection_total_size', 'collection', ['total_size'], unique=False)
+
+ # Step 2: Create collection_item junction table
+ op.create_table(
+ 'collection_item',
+ sa.Column('id', sa.Integer(), nullable=False),
+ sa.Column('collection_id', sa.Integer(), nullable=False),
+ sa.Column('file_group_id', sa.Integer(), nullable=False),
+ sa.Column('position', sa.Integer(), nullable=False, server_default='0'),
+ sa.Column('added_date', sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.PrimaryKeyConstraint('id'),
+ sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['file_group_id'], ['file_group.id'], ondelete='CASCADE'),
+ sa.UniqueConstraint('collection_id', 'file_group_id', name='uq_collection_file_group')
+ )
+
+ # Create indexes for collection_item
+ op.create_index('idx_collection_item_collection_id', 'collection_item', ['collection_id'], unique=False)
+ op.create_index('idx_collection_item_file_group_id', 'collection_item', ['file_group_id'], unique=False)
+ op.create_index('idx_collection_item_position', 'collection_item', ['position'], unique=False)
+ op.create_index('idx_collection_item_collection_position', 'collection_item', ['collection_id', 'position'], unique=False)
+
+ # Ensure table owner in non-docker environments
+ if not DOCKERIZED:
+ session.execute(sa.text('ALTER TABLE public.collection OWNER TO wrolpi'))
+ session.execute(sa.text('ALTER TABLE public.collection_item OWNER TO wrolpi'))
+
+
+def downgrade():
+ # Drop collection_item table and its indexes
+ op.drop_index('idx_collection_item_collection_position', table_name='collection_item')
+ op.drop_index('idx_collection_item_position', table_name='collection_item')
+ op.drop_index('idx_collection_item_file_group_id', table_name='collection_item')
+ op.drop_index('idx_collection_item_collection_id', table_name='collection_item')
+ op.drop_table('collection_item')
+
+ # Drop collection table and its indexes
+ op.drop_index('idx_collection_total_size', table_name='collection')
+ op.drop_index('idx_collection_item_count', table_name='collection')
+ op.drop_index('idx_collection_kind', table_name='collection')
+ op.drop_table('collection')
diff --git a/alembic/versions/add_unique_collection_name_kind.py b/alembic/versions/add_unique_collection_name_kind.py
new file mode 100644
index 00000000..10e966f5
--- /dev/null
+++ b/alembic/versions/add_unique_collection_name_kind.py
@@ -0,0 +1,95 @@
+"""Add unique constraint on collection (name, kind)
+
+This migration:
+1. Removes duplicate collections (keeping the one with most items/archives)
+2. Adds a unique constraint on (name, kind) to prevent future duplicates
+
+Revision ID: add_unique_collection_name_kind
+Revises: migrate_download_to_collection
+Create Date: 2025-11-27
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = 'add_unique_collection_name_kind'
+down_revision = 'migrate_download_to_collection'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ print("\n" + "=" * 60)
+ print("Add Unique Constraint on Collection (name, kind)")
+ print("=" * 60 + "\n")
+
+ # Step 1: Find and remove duplicate collections
+ print("Step 1: Finding duplicate collections...")
+
+ # Find duplicates - keep the one with the lowest id (first created)
+ duplicates = session.execute(text("""
+ SELECT name, kind, array_agg(id ORDER BY id) as ids
+ FROM collection
+ GROUP BY name, kind
+ HAVING COUNT(*) > 1
+ """)).fetchall()
+
+ if duplicates:
+ print(f"Found {len(duplicates)} sets of duplicate collections")
+ for name, kind, ids in duplicates:
+ keep_id = ids[0] # Keep the first one (lowest id)
+ remove_ids = ids[1:] # Remove the rest
+ print(f" - '{name}' ({kind}): keeping id={keep_id}, removing ids={remove_ids}")
+
+ # Move any archives from duplicate collections to the one we're keeping
+ for remove_id in remove_ids:
+ session.execute(text("""
+ UPDATE archive SET collection_id = :keep_id
+ WHERE collection_id = :remove_id
+ """), {'keep_id': keep_id, 'remove_id': remove_id})
+
+ # Move any downloads from duplicate collections
+ session.execute(text("""
+ UPDATE download SET collection_id = :keep_id
+ WHERE collection_id = :remove_id
+ """), {'keep_id': keep_id, 'remove_id': remove_id})
+
+ # Move any collection items
+ session.execute(text("""
+ UPDATE collection_item SET collection_id = :keep_id
+ WHERE collection_id = :remove_id
+ """), {'keep_id': keep_id, 'remove_id': remove_id})
+
+ # Delete the duplicate collection
+ session.execute(text("""
+ DELETE FROM collection WHERE id = :remove_id
+ """), {'remove_id': remove_id})
+
+ session.commit()
+ print("Duplicates removed\n")
+ else:
+ print("No duplicate collections found\n")
+
+ # Step 2: Add unique constraint
+ print("Step 2: Adding unique constraint on (name, kind)...")
+ op.create_unique_constraint('uq_collection_name_kind', 'collection', ['name', 'kind'])
+ print("Done\n")
+
+ print("=" * 60)
+ print("Migration Complete")
+ print("=" * 60 + "\n")
+
+ if not DOCKERIZED:
+ session.execute(text('ALTER TABLE public.collection OWNER TO wrolpi'))
+
+
+def downgrade():
+ op.drop_constraint('uq_collection_name_kind', 'collection', type_='unique')
diff --git a/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py b/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py
new file mode 100644
index 00000000..cc0da2fd
--- /dev/null
+++ b/alembic/versions/b43f70f369d0_remove_duplicate_channel_fields_.py
@@ -0,0 +1,73 @@
+"""Remove duplicate Channel fields delegated to Collection
+
+This migration removes fields from Channel that now delegate to Collection:
+- name (now property: channel.name → collection.name)
+- directory (now property: channel.directory → collection.directory)
+- tag_id (now property: channel.tag_id → collection.tag_id)
+
+These fields were already synced to Collection in previous migration.
+
+Revision ID: b43f70f369d0
+Revises: ba98bd360b7a
+Create Date: 2025-11-19 21:48:58.488850
+
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = 'b43f70f369d0'
+down_revision = 'ba98bd360b7a'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ print("\n" + "="*60)
+ print("Removing Duplicate Fields from Channel")
+ print("="*60 + "\n")
+
+ # Step 1: Drop foreign key constraint on tag_id
+ print("Step 1: Dropping tag_id foreign key constraint...")
+ op.drop_constraint('channel_tag_id_fkey', 'channel', type_='foreignkey')
+ print("✓ Dropped foreign key constraint\n")
+
+ # Step 2: Drop columns (data already in Collection)
+ print("Step 2: Dropping duplicate columns...")
+ op.drop_column('channel', 'name')
+ print(" ✓ Dropped channel.name")
+ op.drop_column('channel', 'directory')
+ print(" ✓ Dropped channel.directory")
+ op.drop_column('channel', 'tag_id')
+ print(" ✓ Dropped channel.tag_id\n")
+
+ print("="*60)
+ print("✓ Channel Cleanup Complete")
+ print(" Channels now delegate name/directory/tag to Collection")
+ print("="*60 + "\n")
+
+
+def downgrade():
+ # Re-add the columns
+ op.add_column('channel', sa.Column('name', sa.String(), nullable=True))
+ op.add_column('channel', sa.Column('directory', sa.String(), nullable=True))
+ op.add_column('channel', sa.Column('tag_id', sa.Integer(), nullable=True))
+
+ # Re-add foreign key
+ op.create_foreign_key('channel_tag_id_fkey', 'channel', 'tag', ['tag_id'], ['id'], ondelete='CASCADE')
+
+ # Restore data from Collection
+ bind = op.get_bind()
+ bind.execute(text("""
+ UPDATE channel
+ SET name = collection.name,
+ directory = collection.directory,
+ tag_id = collection.tag_id
+ FROM collection
+ WHERE channel.collection_id = collection.id
+ """))
\ No newline at end of file
diff --git a/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py b/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py
new file mode 100644
index 00000000..36117eac
--- /dev/null
+++ b/alembic/versions/ba98bd360b7a_add_collection_id_to_channel_model.py
@@ -0,0 +1,130 @@
+"""Add collection_id to Channel model
+
+This migration:
+1. Adds collection_id column to channel table
+2. Creates Collection records for existing Channels
+3. Links Channels to their Collections
+4. Adds foreign key constraint
+
+Revision ID: ba98bd360b7a
+Revises: migrate_domains_to_collections
+Create Date: 2025-11-19 21:10:56.472836
+
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = 'ba98bd360b7a'
+down_revision = 'migrate_domains_to_collections'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ print("\n" + "="*60)
+ print("Channel → Collection Migration")
+ print("="*60 + "\n")
+
+ # Step 1: Add collection_id column to channel table (nullable initially)
+ print("Step 1: Adding collection_id column to channel table...")
+ op.add_column('channel', sa.Column('collection_id', sa.Integer(), nullable=True))
+ print("✓ Added collection_id column\n")
+
+ # Step 2: Create Collection records for each Channel
+ print("Step 2: Creating Collection records for existing Channels...")
+
+ # Fetch all channels
+ result = session.execute(text("""
+ SELECT id, name, directory, tag_id
+ FROM channel
+ ORDER BY name
+ """))
+
+ channels = []
+ for row in result:
+ channels.append({
+ 'id': row[0],
+ 'name': row[1],
+ 'directory': row[2],
+ 'tag_id': row[3],
+ })
+
+ print(f"Found {len(channels)} Channel records to migrate")
+
+ if channels:
+ from wrolpi.collections import Collection
+
+ for channel in channels:
+ channel_id = channel['id']
+ channel_name = channel['name']
+
+ print(f" Processing Channel {channel_id}: {channel_name}")
+
+ # Check if Collection already exists
+ existing = session.query(Collection).filter_by(
+ name=channel_name,
+ kind='channel'
+ ).first()
+
+ if existing:
+ print(f" Collection already exists (id={existing.id})")
+ collection_id = existing.id
+ else:
+ # Create Collection
+ collection = Collection(
+ name=channel_name,
+ kind='channel',
+ directory=channel['directory'], # Channels always have directory
+ tag_id=channel['tag_id'],
+ )
+ session.add(collection)
+ session.flush([collection])
+ collection_id = collection.id
+ print(f" Created Collection id={collection_id}")
+
+ # Link Channel to Collection
+ session.execute(
+ text("UPDATE channel SET collection_id = :collection_id WHERE id = :channel_id"),
+ {'collection_id': collection_id, 'channel_id': channel_id}
+ )
+
+ session.commit()
+ print(f"✓ Created Collections and linked Channels\n")
+ else:
+ print("No channels to migrate\n")
+
+ # Step 3: Add foreign key constraint
+ print("Step 3: Adding foreign key constraint...")
+ op.create_foreign_key(
+ 'fk_channel_collection_id',
+ 'channel',
+ 'collection',
+ ['collection_id'],
+ ['id'],
+ ondelete='CASCADE'
+ )
+ print("✓ Added foreign key constraint\n")
+
+ print("="*60)
+ print("✓ Channel → Collection Migration Complete")
+ print("="*60 + "\n")
+
+ if not DOCKERIZED:
+ session.execute(text('ALTER TABLE public.channel OWNER TO wrolpi'))
+
+
+def downgrade():
+ # Remove foreign key constraint
+ op.drop_constraint('fk_channel_collection_id', 'channel', type_='foreignkey')
+
+ # Drop collection_id column
+ op.drop_column('channel', 'collection_id')
diff --git a/alembic/versions/migrate_domains_to_collections.py b/alembic/versions/migrate_domains_to_collections.py
new file mode 100644
index 00000000..b7430a9b
--- /dev/null
+++ b/alembic/versions/migrate_domains_to_collections.py
@@ -0,0 +1,381 @@
+"""Migrate Domain model to Collection model
+
+This migration:
+1. Adds collection_id column to archive table
+2. Runs the data migration to populate collections
+3. Makes collection_id NOT NULL
+4. Drops the old domain_id column and domains table
+
+Revision ID: migrate_domains_to_collections
+Revises: 66407d145b76
+Create Date: 2025-10-29
+
+"""
+import os
+from typing import Dict, List
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = 'migrate_domains_to_collections'
+down_revision = '66407d145b76'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+class DomainMigrationStats:
+ """Track migration statistics."""
+ def __init__(self):
+ self.domains_found = 0
+ self.domains_migrated = 0
+ self.collections_created = 0
+ self.collection_items_created = 0
+ self.archives_linked = 0
+ self.errors: List[str] = []
+ self.warnings: List[str] = []
+
+ def add_error(self, msg: str):
+ self.errors.append(msg)
+ print(f" ✗ ERROR: {msg}")
+
+ def add_warning(self, msg: str):
+ self.warnings.append(msg)
+ print(f" ⚠ WARNING: {msg}")
+
+ def print_summary(self, dry_run: bool = False):
+ """Print migration summary."""
+ mode = "DRY-RUN" if dry_run else "ACTUAL"
+ print(f"\n{'='*60}")
+ print(f"Domain → Collection Migration Summary ({mode})")
+ print(f"{'='*60}")
+ print(f"Domains found: {self.domains_found}")
+ print(f"Domains migrated: {self.domains_migrated}")
+ print(f"Collections created: {self.collections_created}")
+ print(f"CollectionItems created: {self.collection_items_created}")
+ print(f"Archives linked: {self.archives_linked}")
+
+ if self.warnings:
+ print(f"\nWarnings: {len(self.warnings)}")
+ for warning in self.warnings:
+ print(f" ⚠ {warning}")
+
+ if self.errors:
+ print(f"\nErrors: {len(self.errors)}")
+ for error in self.errors:
+ print(f" ✗ {error}")
+
+ if not self.errors:
+ print(f"\n✓ Migration completed successfully")
+ else:
+ print(f"\n✗ Migration completed with {len(self.errors)} errors")
+
+ print(f"{'='*60}\n")
+
+
+def get_domain_data(session: Session) -> List[Dict]:
+ """Fetch all Domain records from database."""
+ result = session.execute(text("SELECT id, domain, directory FROM domains ORDER BY domain"))
+ domains = []
+ for row in result:
+ domains.append({
+ 'id': row[0],
+ 'domain': row[1],
+ 'directory': row[2],
+ })
+ return domains
+
+
+def get_archives_for_domain(session: Session, domain_id: int) -> List[Dict]:
+ """Get all Archive IDs for a given domain."""
+ result = session.execute(
+ text("SELECT id, file_group_id FROM archive WHERE domain_id = :domain_id"),
+ {'domain_id': domain_id}
+ )
+ archives = []
+ for row in result:
+ if row[1]: # file_group_id must not be null
+ archives.append({
+ 'archive_id': row[0],
+ 'file_group_id': row[1],
+ })
+ return archives
+
+
+def validate_domain_name(domain: str) -> bool:
+ """Validate domain name format - must contain at least one dot."""
+ if not domain or not isinstance(domain, str):
+ return False
+ if domain.startswith('.') or domain.endswith('.'):
+ return False
+ return '.' in domain
+
+
+def perform_domain_migration(session: Session, verbose: bool = True) -> DomainMigrationStats:
+ """
+ Migrate all Domain records to Collection records.
+
+ This function is integrated into the alembic migration.
+ """
+ from wrolpi.collections import Collection, CollectionItem
+
+ stats = DomainMigrationStats()
+
+ # Step 1: Fetch all domains
+ print("Step 1: Fetching Domain records...")
+ domains = get_domain_data(session)
+ stats.domains_found = len(domains)
+ print(f"Found {len(domains)} Domain records")
+
+ if not domains:
+ print("No domains to migrate")
+ return stats
+
+ # Step 2: Create Collections from Domains
+ print("\nStep 2: Creating Collection records...")
+ domain_to_collection: Dict[int, int] = {} # domain_id -> collection_id
+
+ for domain in domains:
+ domain_id = domain['id']
+ domain_name = domain['domain']
+
+ if verbose:
+ print(f" Processing Domain {domain_id}: {domain_name}")
+
+ # Validate domain name
+ if not validate_domain_name(domain_name):
+ stats.add_error(f"Invalid domain name: {repr(domain_name)} (skipping)")
+ continue
+
+ # Check if Collection already exists
+ existing = session.query(Collection).filter_by(
+ name=domain_name,
+ kind='domain'
+ ).first()
+
+ if existing:
+ stats.add_warning(f"Collection already exists for domain {repr(domain_name)} (id={existing.id})")
+ domain_to_collection[domain_id] = existing.id
+ stats.domains_migrated += 1
+ continue
+
+ # Create Collection
+ collection = Collection(
+ name=domain_name,
+ kind='domain',
+ directory=None, # Domains are unrestricted
+ )
+ session.add(collection)
+ session.flush([collection])
+
+ domain_to_collection[domain_id] = collection.id
+ stats.collections_created += 1
+ stats.domains_migrated += 1
+
+ if verbose:
+ print(f" Created Collection id={collection.id}")
+
+ print(f"Created {stats.collections_created} Collection records")
+
+ # Step 3: Update archive.collection_id for all Archives
+ print("\nStep 3: Updating archive.collection_id...")
+
+ for domain in domains:
+ domain_id = domain['id']
+ domain_name = domain['domain']
+
+ if domain_id not in domain_to_collection:
+ # Domain was skipped due to error
+ continue
+
+ collection_id = domain_to_collection[domain_id]
+
+ # Update all archives for this domain
+ result = session.execute(
+ text("UPDATE archive SET collection_id = :collection_id WHERE domain_id = :domain_id"),
+ {'collection_id': collection_id, 'domain_id': domain_id}
+ )
+ updated_count = result.rowcount
+
+ if verbose and updated_count > 0:
+ print(f" Domain {repr(domain_name)}: Updated {updated_count} archives")
+
+ stats.archives_linked += updated_count
+
+ print(f"Updated {stats.archives_linked} Archives with collection_id")
+
+ # Step 4: Create CollectionItem records
+ print("\nStep 4: Creating CollectionItem records...")
+
+ for domain in domains:
+ domain_id = domain['id']
+ domain_name = domain['domain']
+
+ if domain_id not in domain_to_collection:
+ continue
+
+ collection_id = domain_to_collection[domain_id]
+
+ # Get all archives for this domain
+ archives = get_archives_for_domain(session, domain_id)
+
+ if not archives:
+ if verbose:
+ print(f" Domain {repr(domain_name)}: No archives found")
+ continue
+
+ if verbose:
+ print(f" Domain {repr(domain_name)}: {len(archives)} archives")
+
+ for idx, archive in enumerate(archives, start=1):
+ file_group_id = archive['file_group_id']
+ archive_id = archive['archive_id']
+
+ # Check if CollectionItem already exists
+ existing_item = session.query(CollectionItem).filter_by(
+ collection_id=collection_id,
+ file_group_id=file_group_id
+ ).first()
+
+ if existing_item:
+ if verbose:
+ print(f" Archive {archive_id} already linked to collection")
+ continue
+
+ # Create CollectionItem
+ item = CollectionItem(
+ collection_id=collection_id,
+ file_group_id=file_group_id,
+ position=idx
+ )
+ session.add(item)
+ stats.collection_items_created += 1
+
+ session.flush()
+ print(f"Created {stats.collection_items_created} CollectionItem records")
+
+ # Step 5: Skip config export during migration
+ # The config export requires a separate database session, but we're inside
+ # an alembic transaction. The application will export on startup instead.
+ print("\nStep 5: Skipping domains.yaml export during migration...")
+ print(" (Config will be exported on next application startup)")
+
+ return stats
+
+
+def upgrade():
+ """
+ Migrate Domains to Collections.
+
+ This is a one-way migration - there is no downgrade path because:
+ - Domain data is transformed into Collection data
+ - Once migrated, the system uses Collections exclusively
+ - Downgrade would require reimplementing Domain model
+ """
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ print("\n" + "="*60)
+ print("DOMAIN → COLLECTION MIGRATION")
+ print("="*60 + "\n")
+
+ # Step 1: Add collection_id column to archive table (nullable initially)
+ print("Step 1: Adding collection_id column to archive table...")
+ op.add_column('archive',
+ sa.Column('collection_id', sa.Integer(), nullable=True))
+
+ # Add foreign key to collection table
+ op.create_foreign_key(
+ 'archive_collection_id_fkey',
+ 'archive', 'collection',
+ ['collection_id'], ['id'],
+ ondelete='CASCADE'
+ )
+ print("✓ Added collection_id column\n")
+
+ # Step 2: Run data migration script
+ print("Step 2: Running data migration script...")
+ print("This will:")
+ print(" - Create Collection records from Domain records")
+ print(" - Create CollectionItem records for Archives\n")
+
+ try:
+ # Commit the schema changes before running migration
+ session.commit()
+
+ # Run the data migration
+ stats = perform_domain_migration(session, verbose=True)
+
+ # Print summary
+ stats.print_summary(dry_run=False)
+
+ if stats.errors:
+ raise Exception(f"Migration completed with {len(stats.errors)} errors. See logs above.")
+
+ except Exception as e:
+ print(f"\n✗ Migration failed: {e}")
+ raise
+
+ # Step 3: Make collection_id NOT NULL
+ print("\nStep 3: Making collection_id NOT NULL...")
+
+ # Check if any archives don't have a collection_id
+ result = session.execute(sa.text(
+ "SELECT COUNT(*) FROM archive WHERE collection_id IS NULL"
+ ))
+ null_count = result.scalar()
+
+ if null_count > 0:
+ raise Exception(
+ f"Cannot make collection_id NOT NULL: {null_count} archives have NULL collection_id. "
+ f"The migration script should have assigned all archives to collections."
+ )
+
+ op.alter_column('archive', 'collection_id', nullable=False)
+ print("✓ collection_id is now NOT NULL\n")
+
+ # Step 4: Drop old domain_id column and foreign key
+ print("Step 4: Dropping domain_id column and foreign key...")
+ op.drop_constraint('archive_domain_id_fkey', 'archive', type_='foreignkey')
+ op.drop_column('archive', 'domain_id')
+ print("✓ Dropped domain_id column\n")
+
+ # Step 5: Drop domains table
+ print("Step 5: Dropping domains table...")
+ op.drop_table('domains')
+ print("✓ Dropped domains table\n")
+
+ # Ensure table ownership in non-docker environments
+ if not DOCKERIZED:
+ print("Setting table ownership...")
+ session.execute(sa.text('ALTER TABLE public.archive OWNER TO wrolpi'))
+ session.commit()
+
+ print("="*60)
+ print("MIGRATION COMPLETED SUCCESSFULLY")
+ print("="*60 + "\n")
+
+
+def downgrade():
+ """
+ No downgrade path for this migration.
+
+ This is intentional because:
+ 1. Domain → Collection is a one-way data transformation
+ 2. The Domain model is being removed from the codebase
+ 3. Rolling back would require:
+ - Recreating Domain model code
+ - Reversing the data transformation (Collection → Domain)
+ - Handling edge cases where Collections have features Domains didn't
+
+ If you need to rollback, you should:
+ 1. Restore from a database backup taken before migration
+ 2. Re-index all files to rebuild Domain records
+ """
+ raise NotImplementedError(
+ "This migration has no downgrade path. "
+ "To rollback, reset database and re-index files."
+ )
diff --git a/alembic/versions/migrate_download_channel_to_collection.py b/alembic/versions/migrate_download_channel_to_collection.py
new file mode 100644
index 00000000..5e328232
--- /dev/null
+++ b/alembic/versions/migrate_download_channel_to_collection.py
@@ -0,0 +1,110 @@
+"""Migrate Download.channel_id to Download.collection_id
+
+This migration:
+1. Adds collection_id column to download table
+2. Migrates existing channel_id references to collection_id via Channel.collection_id
+3. Drops channel_id column and foreign key
+
+Revision ID: migrate_download_to_collection
+Revises: b43f70f369d0
+Create Date: 2025-11-27
+"""
+import os
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+# revision identifiers, used by Alembic.
+revision = 'migrate_download_to_collection'
+down_revision = 'b43f70f369d0'
+branch_labels = None
+depends_on = None
+
+DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
+
+
+def upgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ print("\n" + "=" * 60)
+ print("Download channel_id -> collection_id Migration")
+ print("=" * 60 + "\n")
+
+ # Step 1: Add collection_id column (nullable initially)
+ print("Step 1: Adding collection_id column to download table...")
+ op.add_column('download', sa.Column('collection_id', sa.Integer(), nullable=True))
+ print("Done\n")
+
+ # Step 2: Migrate data - lookup collection_id through channel
+ print("Step 2: Migrating channel_id to collection_id...")
+ result = session.execute(text("""
+ UPDATE download
+ SET collection_id = channel.collection_id
+ FROM channel
+ WHERE download.channel_id = channel.id
+ AND download.channel_id IS NOT NULL
+ """))
+ print(f"Updated {result.rowcount} download records\n")
+
+ # Step 3: Add foreign key constraint
+ print("Step 3: Adding foreign key constraint...")
+ op.create_foreign_key(
+ 'fk_download_collection_id',
+ 'download',
+ 'collection',
+ ['collection_id'],
+ ['id'],
+ ondelete='SET NULL'
+ )
+ print("Done\n")
+
+ # Step 4: Create index for performance
+ print("Step 4: Creating index on collection_id...")
+ op.create_index('idx_download_collection_id', 'download', ['collection_id'])
+ print("Done\n")
+
+ # Step 5: Drop old channel_id foreign key and column
+ print("Step 5: Dropping channel_id column...")
+ op.drop_constraint('download_channel_id_fkey', 'download', type_='foreignkey')
+ op.drop_column('download', 'channel_id')
+ print("Done\n")
+
+ print("=" * 60)
+ print("Migration Complete")
+ print("=" * 60 + "\n")
+
+ if not DOCKERIZED:
+ session.execute(text('ALTER TABLE public.download OWNER TO wrolpi'))
+
+
+def downgrade():
+ bind = op.get_bind()
+ session = Session(bind=bind)
+
+ # Add back channel_id column
+ op.add_column('download', sa.Column('channel_id', sa.Integer(), nullable=True))
+
+ # Migrate collection_id back to channel_id
+ session.execute(text("""
+ UPDATE download
+ SET channel_id = channel.id
+ FROM channel
+ WHERE download.collection_id = channel.collection_id
+ AND download.collection_id IS NOT NULL
+ """))
+
+ # Add foreign key constraint
+ op.create_foreign_key(
+ 'download_channel_id_fkey',
+ 'download',
+ 'channel',
+ ['channel_id'],
+ ['id']
+ )
+
+ # Drop collection_id
+ op.drop_index('idx_download_collection_id', 'download')
+ op.drop_constraint('fk_download_collection_id', 'download', type_='foreignkey')
+ op.drop_column('download', 'collection_id')
diff --git a/app/cypress.config.js b/app/cypress.config.js
index c092825e..dc8bf255 100644
--- a/app/cypress.config.js
+++ b/app/cypress.config.js
@@ -1,6 +1,14 @@
const { defineConfig } = require("cypress");
module.exports = defineConfig({
+ e2e: {
+ // Use HTTP for CI, HTTPS for local development
+ baseUrl: process.env.CI ? 'http://localhost:3000' : 'https://localhost:8443',
+ specPattern: 'cypress/e2e/**/*.cy.js',
+ supportFile: 'cypress/support/e2e.js',
+ video: process.env.CI ? true : false,
+ screenshotOnRunFailure: true,
+ },
component: {
devServer: {
framework: "create-react-app",
diff --git a/app/cypress/e2e/domains/domain-edit.cy.js b/app/cypress/e2e/domains/domain-edit.cy.js
new file mode 100644
index 00000000..c7034866
--- /dev/null
+++ b/app/cypress/e2e/domains/domain-edit.cy.js
@@ -0,0 +1,270 @@
+describe('Domain Editing Workflow', () => {
+ beforeEach(() => {
+ // Suppress uncaught exceptions from the application (API errors are thrown as unhandled rejections)
+ cy.on('uncaught:exception', (err) => {
+ // Return false to prevent Cypress from failing the test
+ // These are expected API errors in error handling tests
+ return false;
+ });
+
+ // Mock directory search endpoint - DirectorySearch component makes this call automatically
+ cy.intercept('POST', '/api/files/search_directories', {
+ statusCode: 200,
+ body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []}
+ }).as('searchDirectories');
+
+ // Mock tags endpoint - needed for TagsSelector component
+ cy.intercept('GET', '/api/tags', {
+ statusCode: 200,
+ body: {
+ tags: [
+ {id: 1, name: 'News', color: '#ff0000'},
+ {id: 2, name: 'Tech', color: '#00ff00'}
+ ]
+ }
+ }).as('getTags');
+
+ // Mock the collections list for domains
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [
+ {
+ id: 1,
+ domain: 'example.com',
+ archive_count: 42,
+ size: 1024000,
+ tag_name: null,
+ directory: null,
+ can_be_tagged: false,
+ description: 'Example domain'
+ }
+ ],
+ totals: {collections: 1},
+ metadata: {
+ kind: 'domain',
+ columns: [
+ {key: 'domain', label: 'Domain', sortable: true},
+ {key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
+ {key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
+ {key: 'tag_name', label: 'Tag', sortable: true},
+ {key: 'actions', label: 'Manage', sortable: false, type: 'actions'},
+ ],
+ fields: [
+ {key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'},
+ {key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
+ {key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'},
+ ],
+ routes: {
+ list: '/archive/domains',
+ edit: '/archive/domain/:id/edit',
+ search: '/archive',
+ },
+ }
+ }
+ }).as('getDomains');
+ });
+
+ it('completes full edit workflow: add directory and tag', () => {
+ // Mock collection details endpoint BEFORE navigation
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ domain: 'example.com',
+ directory: '',
+ tag_name: null,
+ description: '',
+ can_be_tagged: false,
+ archive_count: 42,
+ size: 1024000
+ }
+ }
+ }).as('getDomain');
+
+ // Start at domains list
+ cy.visit('/archive/domains');
+ cy.wait('@getDomains');
+
+ // Click edit on first domain
+ cy.get('table tbody tr').first().within(() => {
+ cy.contains('Edit').click();
+ });
+
+ // Should navigate to edit page
+ cy.url().should('include', '/archive/domain/1/edit');
+ cy.wait('@getDomain');
+
+ // Page should load with domain name
+ cy.contains('h1', 'example.com').should('exist');
+
+ // Set directory - DirectorySearch component uses input inside field
+ cy.contains('label', 'Directory').parent().find('input').clear().type('archive/example.com');
+
+ // Set description - Semantic UI TextArea
+ cy.contains('label', 'Description').parent().find('textarea').clear().type('Updated example domain');
+
+ // Mock tags endpoint
+ cy.intercept('GET', '/api/tags', {
+ statusCode: 200,
+ body: {
+ tags: [
+ {id: 1, name: 'News', color: '#ff0000'},
+ {id: 2, name: 'Tech', color: '#00ff00'}
+ ]
+ }
+ }).as('getTags');
+
+ // Tag selector should now be enabled (because directory is set)
+ // This depends on the actual implementation of CollectionEditForm
+
+ // Mock update endpoint
+ cy.intercept('PUT', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ success: true,
+ collection: {
+ id: 1,
+ name: 'example.com',
+ directory: 'archive/example.com',
+ tag_name: null,
+ description: 'Updated example domain',
+ can_be_tagged: true,
+ }
+ }
+ }).as('updateDomain');
+
+ // Save changes
+ cy.contains('button', 'Save').click();
+
+ cy.wait('@updateDomain');
+
+ // Should show success toast and stay on edit page (page refreshes data after save)
+ cy.contains('Domain Updated').should('be.visible');
+ cy.url().should('include', '/archive/domain/1/edit');
+ });
+
+ it('shows validation errors for invalid data', () => {
+ // Mock domain details
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ domain: 'example.com',
+ directory: 'archive/example.com',
+ tag_name: null,
+ description: 'Example domain',
+ can_be_tagged: true,
+ archive_count: 42,
+ size: 1024000
+ }
+ }
+ }).as('getDomain');
+
+ cy.visit('/archive/domain/1/edit');
+ cy.wait('@getDomain');
+
+ // Try to save with invalid directory
+ cy.intercept('PUT', '/api/collections/1', {
+ statusCode: 400,
+ body: {
+ error: 'Invalid directory path',
+ cause: {
+ code: 'INVALID_DIRECTORY'
+ }
+ }
+ }).as('invalidUpdate');
+
+ cy.contains('label', 'Directory').parent().find('input').clear().type('/invalid/absolute/path');
+ cy.contains('button', 'Save').click();
+
+ cy.wait('@invalidUpdate');
+
+ // Should show error message
+ cy.contains('Invalid directory path').should('be.visible');
+
+ // Should stay on edit page
+ cy.url().should('include', '/edit');
+ });
+
+ it('allows navigating back without saving using Back button', () => {
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ domain: 'example.com',
+ directory: '',
+ tag_name: null,
+ description: 'Example domain',
+ can_be_tagged: false,
+ archive_count: 42,
+ size: 1024000
+ }
+ }
+ }).as('getDomain');
+
+ // Mock PUT endpoint but don't expect it to be called
+ cy.intercept('PUT', '/api/collections/1', {
+ statusCode: 200,
+ body: {success: true, domain: {}}
+ }).as('updateDomain');
+
+ cy.visit('/archive/domain/1/edit');
+ cy.wait('@getDomain');
+
+ // Make some changes
+ cy.contains('label', 'Description').parent().find('textarea').clear().type('Changed description');
+
+ // Click Back button (the page uses BackButton, not Cancel)
+ cy.contains('button', 'Back').click();
+
+ // Should navigate back without saving
+ // Note: Since we don't have browser history in test, we just verify no save was made
+ cy.get('@updateDomain.all').should('have.length', 0);
+ });
+
+ it('shows form after domain data loads', () => {
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ domain: 'example.com',
+ directory: null,
+ tag_name: null,
+ description: 'Example domain',
+ can_be_tagged: false
+ }
+ }
+ }).as('getDomain');
+
+ cy.visit('/archive/domain/1/edit');
+
+ cy.wait('@getDomain');
+
+ // Form should be visible with domain name in title
+ cy.contains('h1', 'example.com').should('exist');
+
+ // Form fields should be present
+ cy.contains('label', 'Directory').should('exist');
+ cy.contains('label', 'Description').should('exist');
+ });
+
+ it('handles 404 when domain not found', () => {
+ cy.intercept('GET', '/api/collections/999', {
+ statusCode: 404,
+ body: {
+ error: 'Domain collection with ID 999 not found'
+ }
+ }).as('domainNotFound');
+
+ cy.visit('/archive/domain/999/edit');
+ cy.wait('@domainNotFound');
+
+ // Should show error message
+ cy.contains('not found').should('be.visible');
+ });
+});
diff --git a/app/cypress/e2e/domains/domains-list.cy.js b/app/cypress/e2e/domains/domains-list.cy.js
new file mode 100644
index 00000000..a6b3b25a
--- /dev/null
+++ b/app/cypress/e2e/domains/domains-list.cy.js
@@ -0,0 +1,154 @@
+describe('Domains List Page', () => {
+ beforeEach(() => {
+ // Mock the collections API response for domains
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [
+ {
+ id: 1,
+ domain: 'example.com',
+ archive_count: 42,
+ size: 1024000,
+ tag_name: 'News',
+ directory: '/media/archives/example.com',
+ can_be_tagged: true,
+ description: 'Example domain'
+ },
+ {
+ id: 2,
+ domain: 'test.org',
+ archive_count: 15,
+ size: 512000,
+ tag_name: null,
+ directory: null,
+ can_be_tagged: false,
+ description: null
+ }
+ ],
+ totals: {collections: 2},
+ metadata: {
+ kind: 'domain',
+ columns: [
+ {key: 'domain', label: 'Domain', sortable: true},
+ {key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
+ {key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
+ {key: 'tag_name', label: 'Tag', sortable: true},
+ {key: 'actions', label: 'Manage', sortable: false, type: 'actions'}
+ ],
+ fields: [
+ {key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'},
+ {key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
+ {key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}
+ ],
+ routes: {
+ list: '/archive/domains',
+ edit: '/archive/domain/:id/edit',
+ search: '/archive'
+ },
+ messages: {
+ no_directory: 'Set a directory to enable tagging',
+ tag_will_move: 'Tagging will move files to a new directory'
+ }
+ }
+ }
+ }).as('getDomains');
+
+ // Visit the domains page
+ cy.visit('/archive/domains');
+ cy.wait('@getDomains');
+ });
+
+ it('displays domains page without errors', () => {
+ // DomainsPage doesn't have h1, check for the search input instead
+ cy.get('input[placeholder="Domain filter..."]').should('exist');
+ });
+
+ it('renders CollectionTable component', () => {
+ // Check that the table exists
+ cy.get('table').should('exist');
+ });
+
+ it('shows search input', () => {
+ cy.get('input[placeholder="Domain filter..."]').should('exist');
+ });
+
+ it('shows all domains from API', () => {
+ cy.get('table tbody tr').should('have.length', 2);
+ });
+
+ it('displays domain names', () => {
+ cy.get('table tbody tr').first().should('contain', 'example.com');
+ cy.get('table tbody tr').last().should('contain', 'test.org');
+ });
+
+ it('displays Edit buttons in Manage column', () => {
+ cy.get('table tbody tr').first().within(() => {
+ cy.get('a').contains('Edit').should('exist');
+ });
+ });
+
+ it('Edit button has correct styling', () => {
+ cy.get('table tbody tr').first().within(() => {
+ cy.get('a').contains('Edit').should('have.class', 'ui');
+ cy.get('a').contains('Edit').should('have.class', 'button');
+ cy.get('a').contains('Edit').should('have.class', 'secondary');
+ });
+ });
+
+ it('navigates to domain edit page when edit clicked', () => {
+ // Mock the edit page's API calls to prevent "Failed to fetch" errors
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ domain: 'example.com',
+ directory: '/media/archives/example.com',
+ tag_name: 'News',
+ description: 'Example domain'
+ }
+ }
+ }).as('getDomain');
+
+ // Mock directory search to prevent errors
+ cy.intercept('POST', '/api/files/search_directories', {
+ statusCode: 200,
+ body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []}
+ }).as('searchDirs');
+
+ cy.get('table tbody tr').first().within(() => {
+ cy.get('a').contains('Edit').click();
+ });
+ cy.url().should('include', '/archive/domain/1/edit');
+ });
+
+ it('filters domains with search', () => {
+ cy.get('input[placeholder="Domain filter..."]').type('example');
+ cy.get('table tbody tr').should('have.length', 1);
+ cy.get('table tbody tr').should('contain', 'example.com');
+ });
+
+ it('shows empty message when no domains', () => {
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [],
+ totals: {collections: 0},
+ metadata: {
+ kind: 'domain',
+ columns: []
+ }
+ }
+ }).as('getEmptyDomains');
+
+ cy.visit('/archive/domains');
+ cy.wait('@getEmptyDomains');
+
+ cy.contains('No domains yet').should('exist');
+ });
+
+ it('does not show "New Domain" button (domains are auto-created)', () => {
+ cy.contains('button', 'New Domain').should('not.exist');
+ });
+});
diff --git a/app/cypress/e2e/forms/directory-search.cy.js b/app/cypress/e2e/forms/directory-search.cy.js
new file mode 100644
index 00000000..4c53c7a4
--- /dev/null
+++ b/app/cypress/e2e/forms/directory-search.cy.js
@@ -0,0 +1,299 @@
+describe('DirectorySearch Integration Tests', () => {
+ const mockSearchResponse = {
+ is_dir: false,
+ directories: [
+ {path: 'videos/nature'},
+ {path: 'videos/tech'},
+ {path: 'videos/cooking'}
+ ],
+ channel_directories: [
+ {path: 'videos/channels/news', name: 'News Channel'},
+ {path: 'videos/channels/tech', name: 'Tech Reviews'}
+ ],
+ domain_directories: [
+ {path: 'archive/example.com', domain: 'example.com'},
+ {path: 'archive/test.org', domain: 'test.org'}
+ ]
+ };
+
+ const mockEmptyResponse = {
+ is_dir: false,
+ directories: [],
+ channel_directories: [],
+ domain_directories: []
+ };
+
+ const mockExistingDirResponse = {
+ is_dir: true,
+ directories: [
+ {path: 'videos/nature/wildlife'},
+ {path: 'videos/nature/ocean'}
+ ],
+ channel_directories: [],
+ domain_directories: []
+ };
+
+ beforeEach(() => {
+ // Mock the directory search API
+ cy.intercept('POST', '/api/files/search_directories', (req) => {
+ const {path} = req.body;
+
+ if (path === 'videos') {
+ req.reply({statusCode: 200, body: mockSearchResponse});
+ } else if (path === 'empty') {
+ req.reply({statusCode: 200, body: mockEmptyResponse});
+ } else if (path === 'videos/nature') {
+ req.reply({statusCode: 200, body: mockExistingDirResponse});
+ } else {
+ req.reply({statusCode: 200, body: mockSearchResponse});
+ }
+ }).as('searchDirectories');
+ });
+
+ context('Standalone DirectorySearch Behavior', () => {
+ beforeEach(() => {
+ // Visit a page with DirectorySearch (using domain edit as example)
+ cy.intercept('GET', '/api/collections/1', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 1,
+ name: 'example.com',
+ kind: 'domain',
+ directory: '',
+ description: '',
+ tag_name: null
+ }
+ }
+ }).as('getDomain');
+
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [],
+ totals: {collections: 0},
+ metadata: {
+ kind: 'domain',
+ columns: [],
+ fields: [
+ {key: 'directory', label: 'Directory', type: 'text', required: false}
+ ],
+ routes: {},
+ messages: {}
+ }
+ }
+ }).as('getMetadata');
+
+ cy.visit('/archive/domain/1/edit');
+ cy.wait('@getDomain');
+ cy.wait('@getMetadata');
+ });
+
+ it('types in search box and sees results', () => {
+ // Find the directory input
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Should see results in dropdown
+ cy.contains('videos/nature').should('be.visible');
+ cy.contains('videos/tech').should('be.visible');
+ });
+
+ it('clicks result and value updates', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Click a result
+ cy.contains('videos/nature').click();
+
+ // Input should show selected value
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .should('have.value', 'videos/nature');
+ });
+
+
+ it('categories are visually distinct', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Check that categories exist
+ cy.contains('Directories').should('be.visible');
+ cy.contains('Channels').should('be.visible');
+ cy.contains('Domains').should('be.visible');
+ });
+
+ it('result descriptions appear correctly', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Channel should show name as description
+ cy.contains('News Channel').should('be.visible');
+
+ // Domain should show domain as description
+ cy.contains('example.com').should('be.visible');
+ });
+
+ it('shows "New Directory" for new paths', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('empty');
+
+ cy.wait('@searchDirectories');
+
+ // Should show "New Directory" option
+ cy.contains('New Directory').should('be.visible');
+ });
+
+ });
+
+ context('DestinationForm Workflow', () => {
+ beforeEach(() => {
+ // Set up domain edit page as a form with DestinationForm
+ cy.intercept('GET', '/api/collections/2', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 2,
+ name: 'test.com',
+ kind: 'domain',
+ directory: 'archive/test.com',
+ description: '',
+ tag_name: null
+ }
+ }
+ }).as('getDomain');
+
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [],
+ totals: {collections: 0},
+ metadata: {
+ kind: 'domain',
+ columns: [],
+ fields: [
+ {key: 'directory', label: 'Directory', type: 'text', required: true}
+ ],
+ routes: {},
+ messages: {}
+ }
+ }
+ }).as('getMetadata');
+
+ cy.visit('/archive/domain/2/edit');
+ cy.wait('@getDomain');
+ cy.wait('@getMetadata');
+ });
+
+ it('starts with existing directory value', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .should('have.value', 'archive/test.com');
+ });
+
+ it('types partial path and sees suggestions', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .clear()
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Should see suggestions
+ cy.contains('videos/nature').should('be.visible');
+ });
+
+ it('selects channel directory and form updates', () => {
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .clear()
+ .type('videos');
+
+ cy.wait('@searchDirectories');
+
+ // Select a channel directory
+ cy.contains('News Channel').click();
+
+ // Form should update
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .should('have.value', 'videos/channels/news');
+ });
+
+
+ });
+
+ context('Real-World Scenarios', () => {
+ it('handles API returning empty results', () => {
+ cy.intercept('GET', '/api/collections/3', {
+ statusCode: 200,
+ body: {
+ collection: {
+ id: 3,
+ name: 'empty.com',
+ kind: 'domain',
+ directory: '',
+ description: '',
+ tag_name: null
+ }
+ }
+ }).as('getDomain');
+
+ cy.intercept('GET', '/api/collections?kind=domain', {
+ statusCode: 200,
+ body: {
+ collections: [],
+ totals: {collections: 0},
+ metadata: {
+ kind: 'domain',
+ columns: [],
+ fields: [
+ {key: 'directory', label: 'Directory', type: 'text', required: false}
+ ],
+ routes: {},
+ messages: {}
+ }
+ }
+ }).as('getMetadata');
+
+ cy.visit('/archive/domain/3/edit');
+ cy.wait('@getDomain');
+ cy.wait('@getMetadata');
+
+ // Type path that returns empty results
+ cy.contains('label', 'Directory')
+ .parent()
+ .find('input')
+ .type('empty');
+
+ cy.wait('@searchDirectories');
+
+ // Should show "New Directory" option
+ cy.contains('New Directory').should('be.visible');
+ });
+
+ });
+});
diff --git a/app/cypress/fixtures/channel-collections.json b/app/cypress/fixtures/channel-collections.json
new file mode 100644
index 00000000..8e153947
--- /dev/null
+++ b/app/cypress/fixtures/channel-collections.json
@@ -0,0 +1,63 @@
+{
+ "collections": [
+ {
+ "id": 1,
+ "name": "My Tech Channel",
+ "video_count": 100,
+ "size": 5000000000,
+ "tag_name": "Tech",
+ "directory": "videos/channels/tech",
+ "download_frequency": "daily",
+ "url": "https://youtube.com/c/tech",
+ "rss_url": "https://youtube.com/feeds/tech"
+ },
+ {
+ "id": 2,
+ "name": "News Channel",
+ "video_count": 50,
+ "size": 2500000000,
+ "tag_name": "News",
+ "directory": "videos/channels/news",
+ "download_frequency": "weekly",
+ "url": "https://youtube.com/c/news",
+ "rss_url": null
+ },
+ {
+ "id": 3,
+ "name": "Science Channel",
+ "video_count": 75,
+ "size": 3750000000,
+ "tag_name": null,
+ "directory": "videos/channels/science",
+ "download_frequency": null,
+ "url": null,
+ "rss_url": null
+ }
+ ],
+ "totals": {
+ "channels": 3
+ },
+ "metadata": {
+ "kind": "channel",
+ "columns": [
+ {"key": "name", "label": "Name", "sortable": true},
+ {"key": "tag_name", "label": "Tag", "sortable": true},
+ {"key": "video_count", "label": "Videos", "sortable": true},
+ {"key": "download_frequency", "label": "Download Frequency", "sortable": true},
+ {"key": "size", "label": "Size", "sortable": true, "format": "bytes"},
+ {"key": "actions", "label": "Manage", "sortable": false, "type": "actions"}
+ ],
+ "fields": [
+ {"key": "name", "label": "Channel Name", "type": "text", "required": true},
+ {"key": "directory", "label": "Directory", "type": "path", "required": true},
+ {"key": "url", "label": "URL", "type": "url", "optional": true},
+ {"key": "tag_name", "label": "Tag", "type": "tag", "optional": true},
+ {"key": "download_missing_data", "label": "Download Missing Data", "type": "boolean", "optional": true}
+ ],
+ "routes": {
+ "list": "/videos/channels",
+ "edit": "/videos/channel/:id/edit",
+ "search": "/videos/channel/:id/video"
+ }
+ }
+}
diff --git a/app/cypress/fixtures/domain-collections.json b/app/cypress/fixtures/domain-collections.json
new file mode 100644
index 00000000..8de2e7b2
--- /dev/null
+++ b/app/cypress/fixtures/domain-collections.json
@@ -0,0 +1,62 @@
+{
+ "collections": [
+ {
+ "id": 1,
+ "domain": "example.com",
+ "archive_count": 42,
+ "size": 1024000,
+ "tag_name": "News",
+ "directory": "archive/example.com",
+ "can_be_tagged": true,
+ "description": "News from Example.com"
+ },
+ {
+ "id": 2,
+ "domain": "test.org",
+ "archive_count": 15,
+ "size": 512000,
+ "tag_name": null,
+ "directory": null,
+ "can_be_tagged": false,
+ "description": "Test archives"
+ },
+ {
+ "id": 3,
+ "domain": "demo.net",
+ "archive_count": 8,
+ "size": 256000,
+ "tag_name": "Tech",
+ "directory": "archive/demo.net",
+ "can_be_tagged": true,
+ "description": "Tech demos"
+ }
+ ],
+ "totals": {
+ "domains": 3
+ },
+ "metadata": {
+ "kind": "domain",
+ "columns": [
+ {"key": "domain", "label": "Domain", "sortable": true},
+ {"key": "archive_count", "label": "URLs", "sortable": true, "align": "right"},
+ {"key": "size", "label": "Size", "sortable": true, "align": "right", "format": "bytes"},
+ {"key": "tag_name", "label": "Tag", "sortable": true},
+ {"key": "actions", "label": "Manage", "sortable": false, "type": "actions"}
+ ],
+ "fields": [
+ {"key": "directory", "label": "Directory", "type": "text", "placeholder": "Optional directory path"},
+ {"key": "tag_name", "label": "Tag", "type": "tag", "placeholder": "Select or create tag", "depends_on": "directory"},
+ {"key": "description", "label": "Description", "type": "textarea", "placeholder": "Optional description"}
+ ],
+ "routes": {
+ "list": "/archive/domains",
+ "edit": "/archive/domain/:id/edit",
+ "search": "/archive",
+ "searchParam": "domain"
+ },
+ "messages": {
+ "no_directory": "Set a directory to enable tagging",
+ "tag_will_move": "Tagging will move files to a new directory"
+ }
+ }
+}
diff --git a/app/cypress/support/e2e.js b/app/cypress/support/e2e.js
new file mode 100644
index 00000000..a5094c49
--- /dev/null
+++ b/app/cypress/support/e2e.js
@@ -0,0 +1,9 @@
+// ***********************************************************
+// This file is used to load the support and commands for e2e tests
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands'
+
+// Alternatively you can use CommonJS syntax:
+// require('./commands')
diff --git a/app/package-lock.json b/app/package-lock.json
index d7f8cc27..3ba7e02c 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -29,8 +29,18 @@
"semantic-ui-react": "^2.1.4",
"three": "^0.154",
"web-vitals": "^2.1.4"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -3728,6 +3738,33 @@
"node": ">=8"
}
},
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@testing-library/react": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
@@ -6365,6 +6402,13 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@@ -13041,6 +13085,16 @@
"node": ">=6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@@ -13699,9 +13753,10 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -15751,6 +15806,20 @@
"node": ">=6.0.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -16985,6 +17054,19 @@
"node": ">=6"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -18880,6 +18962,12 @@
}
},
"dependencies": {
+ "@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true
+ },
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -21357,6 +21445,28 @@
}
}
},
+ "@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "requires": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "dependencies": {
+ "dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true
+ }
+ }
+ },
"@testing-library/react": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
@@ -23310,6 +23420,12 @@
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
},
+ "css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true
+ },
"cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@@ -28143,6 +28259,12 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
+ "min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true
+ },
"mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@@ -28605,9 +28727,9 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"picocolors": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch": {
"version": "2.3.1",
@@ -29902,6 +30024,16 @@
"minimatch": "^3.0.5"
}
},
+ "redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "requires": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ }
+ },
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -30830,6 +30962,15 @@
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="
},
+ "strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "requires": {
+ "min-indent": "^1.0.0"
+ }
+ },
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
diff --git a/app/package.json b/app/package.json
index f6bbf51c..46181d61 100644
--- a/app/package.json
+++ b/app/package.json
@@ -27,9 +27,11 @@
},
"scripts": {
"start": "react-scripts start",
- "build": "react-scripts build",
+ "build": "BROWSERSLIST_IGNORE_OLD_DATA=true react-scripts build",
"test": "react-scripts test",
- "eject": "react-scripts eject"
+ "eject": "react-scripts eject",
+ "cy:run": "cypress run",
+ "cy:open": "cypress open"
},
"eslintConfig": {
"extends": [
@@ -47,5 +49,8 @@
"last 1 firefox version",
"last 1 safari version"
]
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.9.1"
}
}
diff --git a/app/src/Events.js b/app/src/Events.js
index d2a2b685..6dd72477 100644
--- a/app/src/Events.js
+++ b/app/src/Events.js
@@ -118,6 +118,14 @@ function handleEvents(events) {
eventToast('Screenshot Generation Failed!', message, 'error', 5000);
}
+ if (event === 'upgrade_started') {
+ eventToast('Upgrade Started', message, 'info', 10000);
+ // Redirect to maintenance page after a short delay
+ setTimeout(() => {
+ window.location.href = '/maintenance.html';
+ }, 1000);
+ }
+
if (subject) {
newestEvents[subject] = dt;
}
diff --git a/app/src/Tags.js b/app/src/Tags.js
index bffabb37..6276d4dc 100644
--- a/app/src/Tags.js
+++ b/app/src/Tags.js
@@ -385,6 +385,11 @@ export function AddTagsButton({
const [loading, setLoading] = React.useState(false);
const [localTags, setLocalTags] = React.useState(selectedTagNames);
+ // Sync localTags with selectedTagNames when it changes from parent
+ React.useEffect(() => {
+ setLocalTags(selectedTagNames);
+ }, [selectedTagNames]);
+
const active = anyTag || (selectedTagNames && selectedTagNames.length > 0);
const handleOpen = (e) => {
diff --git a/app/src/api.js b/app/src/api.js
index 8ad122f9..a29922e1 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -1,7 +1,7 @@
import {emptyToNull} from "./components/Common";
import {toast} from "react-semantic-toasts-2";
import _ from "lodash";
-import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars";
+import {API_URI, ARCHIVES_API, COLLECTIONS_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars";
function timeoutPromise(ms, promise) {
// Create a timeout wrapper around a promise. If the timeout is reached, throw an error. Otherwise, return
@@ -719,10 +719,10 @@ export async function searchArchives(offset, limit, domain, searchStr, order, ta
}
export async function fetchDomains() {
- const response = await apiGet(`${ARCHIVES_API}/domains`);
+ const response = await apiGet(`${COLLECTIONS_API}?kind=domain`);
if (response.ok) {
let data = await response.json();
- return [data['domains'], data['totals']['domains']];
+ return [data['collections'], data['totals']['collections'], data['metadata']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domains. See server logs.');
toast({
@@ -734,6 +734,137 @@ export async function fetchDomains() {
}
}
+export async function fetchChannels() {
+ const response = await apiGet(`${COLLECTIONS_API}?kind=channel`);
+ if (response.ok) {
+ let data = await response.json();
+ return [data['collections'], data['totals']['collections'], data['metadata']];
+ } else {
+ const message = await getErrorMessage(response, 'Unable to fetch Channels. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Channels Error',
+ description: message,
+ time: 5000,
+ });
+ }
+}
+
+export async function getDomain(domainId) {
+ const response = await apiGet(`${COLLECTIONS_API}/${domainId}`);
+ if (response.ok) {
+ const data = await response.json();
+ return data['collection'];
+ } else {
+ const message = await getErrorMessage(response, 'Unable to fetch Domain. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Domain Error',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+}
+
+export async function updateDomain(domainId, updates) {
+ updates = emptyToNull(updates);
+ const response = await apiPut(`${COLLECTIONS_API}/${domainId}`, updates);
+ if (response.ok) {
+ const data = await response.json();
+ return data['collection'];
+ } else {
+ const message = await getErrorMessage(response, 'Unable to update Domain. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Domain Update Error',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+}
+
+export async function getCollectionTagInfo(collectionId, tagName) {
+ const body = {
+ tag_name: tagName,
+ }
+ const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag_info`, body);
+ if (response.ok) {
+ const data = await response.json();
+ return data;
+ } else {
+ const message = await getErrorMessage(response, 'Unable to get tag info. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Tag Info Error',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+}
+
+export async function tagDomain(domainId, tagName, directory) {
+ const body = {
+ tag_name: tagName,
+ }
+ if (directory) {
+ body['directory'] = directory;
+ }
+ const response = await apiPost(`${COLLECTIONS_API}/${domainId}/tag`, body);
+ if (response.ok) {
+ const data = await response.json();
+ return data;
+ } else {
+ const message = await getErrorMessage(response, 'Unable to tag Domain. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Domain Tag Error',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+}
+
+export async function deleteDomain(domainId) {
+ const response = await apiDelete(`${COLLECTIONS_API}/${domainId}`);
+ if (response.status !== 204) {
+ const message = await getErrorMessage(response, 'Failed to delete domain.');
+ toast({
+ type: 'error',
+ title: 'Delete Failed',
+ description: message,
+ time: 5000,
+ });
+ throw new Error(message);
+ }
+ return response;
+}
+
+export async function refreshDomain(domainId) {
+ let url = `${COLLECTIONS_API}/${domainId}/refresh`;
+ const response = await apiPost(url);
+ if (!response.ok) {
+ const message = await getErrorMessage(response, "Failed to refresh this domain's directory");
+ toast({
+ type: 'error',
+ title: 'Failed to refresh',
+ description: message,
+ time: 5000,
+ });
+ } else {
+ toast({
+ type: 'success',
+ title: 'Domain refresh started',
+ description: 'The domain directory is being refreshed',
+ time: 3000,
+ });
+ }
+ return response;
+}
+
export async function getArchive(archiveId) {
const response = await apiGet(`${ARCHIVES_API}/${archiveId}`);
if (response.ok) {
@@ -1519,4 +1650,27 @@ export async function postRestart() {
export async function postVideoFileFormat(video_file_format) {
const body = {video_file_format};
return await apiPost(`${API_URI}/videos/file_format`, body);
+}
+
+export async function checkUpgrade(force = false) {
+ const url = force ? `${API_URI}/upgrade/check?force=true` : `${API_URI}/upgrade/check`;
+ const response = await apiGet(url);
+ if (response.ok) {
+ return await response.json();
+ }
+ return null;
+}
+
+export async function triggerUpgrade() {
+ const response = await apiPost(`${API_URI}/upgrade/start`);
+ if (!response.ok) {
+ const message = await getErrorMessage(response, 'Failed to start upgrade. See server logs.');
+ toast({
+ type: 'error',
+ title: 'Upgrade Error',
+ description: message,
+ time: 5000,
+ });
+ }
+ return response;
}
\ No newline at end of file
diff --git a/app/src/components/Archive.js b/app/src/components/Archive.js
index 68830fb6..6b25c825 100644
--- a/app/src/components/Archive.js
+++ b/app/src/components/Archive.js
@@ -9,10 +9,7 @@ import {
GridRow,
Image,
Input,
- PlaceholderHeader,
- PlaceholderLine,
TableCell,
- TableRow
} from "semantic-ui-react";
import {
APIButton,
@@ -35,19 +32,31 @@ import {
textEllipsis,
useTitle
} from "./Common";
-import {deleteArchives, generateArchiveScreenshot, postDownload, tagFileGroup, untagFileGroup} from "../api";
-import {Link, Route, Routes, useNavigate, useParams} from "react-router-dom";
+import {deleteArchives, deleteDomain, generateArchiveScreenshot, getCollectionTagInfo, postDownload, refreshDomain, tagDomain, tagFileGroup, untagFileGroup} from "../api";
+import {CollectionTagModal} from "./collections/CollectionTagModal";
+import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router-dom";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
-import {useArchive, useDomains, useSearchArchives, useSearchOrder} from "../hooks/customHooks";
+import {
+ useArchive,
+ useCollectionMetadata,
+ useDomain,
+ useDomains,
+ useOneQuery,
+ useSearchArchives,
+ useSearchOrder
+} from "../hooks/customHooks";
import {FileCards, FileRowTagIcon, FilesView} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import _ from "lodash";
import {Media, ThemeContext} from "../contexts/contexts";
import {Button, Card, CardIcon, darkTheme, Header, Loader, Placeholder, Popup, Segment, Tab, TabPane} from "./Theme";
import {SortableTable} from "./SortableTable";
-import {taggedImageLabel, TagsSelector} from "../Tags";
+import {taggedImageLabel, TagsSelector, TagsContext} from "../Tags";
import {toast} from "react-semantic-toasts-2";
import {API_ARCHIVE_UPLOAD_URI, Downloaders} from "./Vars";
+import {CollectionTable} from "./collections/CollectionTable";
+import {CollectionEditForm} from "./collections/CollectionEditForm";
+import {RecurringDownloadsTable} from "./admin/Downloads";
function archiveFileLink(path, directory = false) {
if (path) {
@@ -350,66 +359,219 @@ export function ArchiveCard({file}) {
export function DomainsPage() {
useTitle('Archive Domains');
- const [domains] = useDomains();
- const [searchStr, setSearchStr] = useState('');
+ const [domains, total, metadata] = useDomains();
+ const [searchStr, setSearchStr] = useOneQuery('domain');
- if (domains === null) {
- // Request is pending.
+ // Header section matching ChannelsPage pattern
+ const header =
+
+
+
+ setSearchStr('')}
+ onChange={setSearchStr}
+ onSubmit={null}
+ />
+
+
+ {/* No "New Domain" button - domains are auto-created */}
+
+
+
+
;
+
+ // Empty state
+ if (domains && domains.length === 0) {
return <>
-
-
-
-
-
-
+ {header}
+
+ No domains yet. Archive some webpages!
+
>;
- } else if (domains === undefined) {
- return Could not fetch domains
- } else if (domains && domains.length === 0) {
- return
- No domains yet.
- Archive some webpages!
- ;
}
- let filteredDomains = domains;
- if (searchStr) {
- const re = new RegExp(_.escapeRegExp(searchStr), 'i');
- filteredDomains = domains.filter(i => re.test(i['domain']));
+ // Error state
+ if (domains === undefined) {
+ return <>
+ {header}
+ Could not fetch Domains
+ >;
}
- const domainRow = ({domain, url_count, size}) => {
- return
-
-
- {domain}
-
-
- {url_count}
- {humanFileSize(size)}
-
- }
-
- const headers = [
- {key: 'domain', text: 'Domain', sortBy: 'domain', width: 12},
- {key: 'archives', text: 'Archives', sortBy: 'url_count', width: 2},
- {key: 'Size', text: 'Size', sortBy: 'size', width: 2},
- ];
-
return <>
- setSearchStr(value)}
+ {header}
+
- domainRow(i)}
- rowKey='domain'
- tableHeaders={headers}
+ >;
+}
+
+export function DomainEditPage() {
+ const {domainId} = useParams();
+ const navigate = useNavigate();
+ const {domain, form, fetchDomain} = useDomain(parseInt(domainId));
+ const {metadata} = useCollectionMetadata('domain');
+
+ // Modal state for tagging
+ const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
+
+ // Filter out tag_name field - we use the modal button instead of inline selector
+ const filteredMetadata = React.useMemo(() => {
+ if (!metadata) return metadata;
+ return {
+ ...metadata,
+ fields: metadata.fields.filter(field => field.key !== 'tag_name')
+ };
+ }, [metadata]);
+
+ useTitle(`Edit Domain: ${domain?.domain || '...'}`);
+
+ // Wrap form.onSubmit to add toast and refresh domain data
+ React.useEffect(() => {
+ if (form && form.onSubmit) {
+ const originalOnSubmit = form.onSubmit;
+ form.onSubmit = async () => {
+ try {
+ await originalOnSubmit();
+ toast({
+ type: 'success',
+ title: 'Domain Updated',
+ description: 'Domain was successfully updated',
+ time: 3000,
+ });
+ // Refresh domain data to show updated values
+ await fetchDomain();
+ } catch (e) {
+ console.error('Failed to update domain:', e);
+ throw e;
+ }
+ };
+ }
+ }, [form, fetchDomain]);
+
+ // Handler for tag modal save
+ const handleTagSave = async (tagName, directory) => {
+ try {
+ await tagDomain(parseInt(domainId), tagName, directory);
+ toast({
+ type: 'success',
+ title: 'Domain Tagged',
+ description: `Domain "${domain?.domain}" has been tagged with "${tagName}"`,
+ time: 3000,
+ });
+ } catch (e) {
+ console.error('Failed to tag domain', e);
+ } finally {
+ setTimeout(async () => {
+ await fetchDomain();
+ }, 500);
+ }
+ };
+
+ // Handler for fetching tag info
+ const handleGetTagInfo = async (tagName) => {
+ if (domain?.id) {
+ return await getCollectionTagInfo(domain.id, tagName);
+ }
+ return null;
+ };
+
+ const handleRefreshDomain = async (e) => {
+ if (e) {
+ e.preventDefault();
+ }
+ await refreshDomain(parseInt(domainId));
+ // Refresh domain data after completion
+ await fetchDomain();
+ };
+
+ if (!form.ready) {
+ return Loading domain...;
+ }
+
+ // Handler for domain deletion
+ const handleDelete = async () => {
+ try {
+ let response = await deleteDomain(parseInt(domainId));
+ if (response.status === 204) {
+ navigate('/archive/domains');
+ }
+ } catch (e) {
+ console.error('Failed to delete domain', e);
+ }
+ };
+
+ const deleteButton = Delete;
+
+ const refreshButton = domain?.directory ? (
+ Refresh
+ ) : null;
+
+ const tagButton = ;
+
+ const actionButtons = <>
+ {deleteButton}
+ {refreshButton}
+ {tagButton}
+ >;
+
+ return <>
+
+
+
+ {/* Tag Modal */}
+ setTagEditModalOpen(false)}
+ currentTagName={domain?.tag_name}
+ originalDirectory={domain?.directory}
+ getTagInfo={handleGetTagInfo}
+ onSave={handleTagSave}
+ collectionName="Domain"
+ />
+
+ {/* Downloads Segment */}
+
+
+
+
>;
}
@@ -633,9 +795,21 @@ export function ArchiveRowCells({file}) {
}
export function ArchiveRoute() {
+ const location = useLocation();
+ const path = location.pathname;
+
const links = [
- {text: 'Archives', to: '/archive', end: true},
- {text: 'Domains', to: '/archive/domains'},
+ {
+ text: 'Archives',
+ to: '/archive',
+ end: true,
+ isActive: () => path === '/archive' || /^\/archive\/\d+$/.test(path)
+ },
+ {
+ text: 'Domains',
+ to: '/archive/domains',
+ isActive: () => path.startsWith('/archive/domain')
+ },
{text: 'Settings', to: '/archive/settings'},
];
return
@@ -643,6 +817,7 @@ export function ArchiveRoute() {
}/>
}/>
+ }/>
}/>
}/>
diff --git a/app/src/components/Channels.js b/app/src/components/Channels.js
index 2032ffb1..d5ba6905 100644
--- a/app/src/components/Channels.js
+++ b/app/src/components/Channels.js
@@ -1,6 +1,7 @@
import React, {useState} from "react";
-import {Grid, Input, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
+import {Grid, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo, updateChannel} from "../api";
+import {CollectionTagModal} from "./collections/CollectionTagModal";
import {
APIButton,
BackButton,
@@ -12,7 +13,6 @@ import {
secondsToFrequency,
secondsToFullDuration,
SimpleAccordion,
- Toggle,
useTitle,
WROLModeMessage
} from "./Common";
@@ -26,9 +26,7 @@ import {
Header,
Loader,
Modal,
- ModalActions,
ModalContent,
- ModalHeader,
Segment,
Statistic
} from "./Theme";
@@ -36,9 +34,10 @@ import {Media, ThemeContext} from "../contexts/contexts";
import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import {RecurringDownloadsTable} from "./admin/Downloads";
-import {TagsContext, TagsSelector} from "../Tags";
+import {TagsContext} from "../Tags";
import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
+import {CollectionTable} from "./collections/CollectionTable";
function ChannelStatistics({statistics}) {
@@ -86,20 +85,11 @@ export function ChannelPage({create, header}) {
const {SingleTag} = React.useContext(TagsContext);
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
- const [newTagName, setNewTagName] = useState(null);
- const [moveToTagDirectory, setMoveToTagDirectory] = useState(true);
- const [newTagDirectory, setNewTagDirectory] = useState('');
const {channel, form, fetchChannel} = useChannel(channelId);
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
- React.useEffect(() => {
- if (channel && channel.tag_name !== newTagName) {
- setNewTagName(channel.tag_name);
- }
- }, [channel]);
-
if (!create && !form.ready) {
// Waiting for editing Channel to be fetched.
return ;
@@ -223,10 +213,10 @@ export function ChannelPage({create, header}) {
setDownloadModalOpen(false);
}
- const handleTagEditChannel = async () => {
+ // Handler for tag modal save
+ const handleTagSave = async (tagName, directory) => {
try {
- await tagChannel(channelId, newTagName, moveToTagDirectory ? newTagDirectory : null);
- setTagEditModalOpen(false);
+ await tagChannel(channelId, tagName, directory);
} catch (e) {
console.error('Failed to tag channel', e);
} finally {
@@ -235,69 +225,25 @@ export function ChannelPage({create, header}) {
await fetchChannel();
}, 500);
}
- }
+ };
- const handleTagSelectMoveSuggestion = async (newTagName_) => {
- setNewTagName(newTagName_);
- try {
- // Get suggested Tag directory for this Tag and Channel.
- const videosDestination = await tagChannelInfo(channelId, newTagName_);
- setNewTagDirectory(videosDestination);
- } catch (e) {
- console.error('Failed to tag channel', e);
- }
- }
+ // Handler for fetching tag info
+ const handleGetTagInfo = async (tagName) => {
+ return await tagChannelInfo(channelId, tagName);
+ };
let tagEditModal;
if (!create) {
// User is editing the Tag of the Channel.
- tagEditModal = setTagEditModalOpen(false)}
- closeIcon
- >
- {channel.tag_name ? 'Modify Tag' : 'Add Tag'}
-
-
-
-
- handleTagSelectMoveSuggestion(null)}
- />
-
-
-
-
-
-
-
-
-
- setNewTagDirectory(value)}
- disabled={!moveToTagDirectory}
- />
-
-
-
-
-
-
- {moveToTagDirectory ? 'Move' : 'Save'}
-
- ;
+ currentTagName={channel.tag_name}
+ originalDirectory={channel.directory}
+ getTagInfo={handleGetTagInfo}
+ onSave={handleTagSave}
+ collectionName="Channel"
+ />;
}
let channelUrlRow;
@@ -502,72 +448,13 @@ export function ChannelNewPage(props) {
return
}
-function ChannelRow({channel}) {
- const {SingleTag} = React.useContext(TagsContext);
-
- const videosTo = `/videos/channel/${channel.id}/video`;
-
- const {inverted} = React.useContext(ThemeContext);
- const editTo = `/videos/channel/${channel.id}/edit`;
- const buttonClass = `ui button secondary ${inverted}`;
-
- return
-
- {channel.name}
-
-
- {channel.tag_name ? : null}
-
-
- {channel.video_count}
-
-
- {channel.url && channel.minimum_frequency ? secondsToFrequency(channel.minimum_frequency) : null}
-
-
- {channel.size ? humanFileSize(channel.size) : null}
-
-
- Edit
-
- ;
-}
-
-function MobileChannelRow({channel}) {
- const {SingleTag} = React.useContext(TagsContext);
-
- const editTo = `/videos/channel/${channel.id}/edit`;
- const videosTo = `/videos/channel/${channel.id}/video`;
- return
-
-
-
- {channel.name}
-
- {channel.tag_name ? : null}
-
-
- Videos: {channel.video_count}
-
-
-
-
- Edit
-
-
- ;
-}
-
-
export function ChannelsPage() {
useTitle('Channels');
- const {channels} = useChannels();
+ const [channels, total, metadata] = useChannels();
const [searchStr, setSearchStr] = useOneQuery('name');
- // Hides Channels with few videos.
- const [hideSmall, setHideSmall] = React.useState(true);
- const enoughChannelsToHideSmall = channels && channels.length > 20;
+ // Header section matching DomainsPage pattern
const header =
@@ -591,25 +478,7 @@ export function ChannelsPage() {
;
- const headers = [
- {key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()], width: 8},
- {key: 'tag', text: 'Tag', sortBy: [i => i['tag_name'], i => i['name'].toLowerCase()], width: 2},
- {key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()], width: 2},
- {
- key: 'download_frequency',
- text: 'Download Frequency',
- sortBy: [i => i['minimum_frequency'], i => i['name'].toLowerCase()],
- width: 2
- },
- {key: 'size', text: 'Size', sortBy: [i => i['size'], i => i['name'].toLowerCase()], width: 2},
- {key: 'manage', text: 'Manage', width: 2},
- ];
- const mobileHeaders = [
- {key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()]},
- {key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()]},
- {key: 'manage', text: 'Manage'},
- ];
-
+ // Empty state
if (channels && channels.length === 0) {
return <>
{header}
@@ -617,65 +486,23 @@ export function ChannelsPage() {
No channels exist yet!
Create one.
- >
- } else if (channels === undefined) {
+ >;
+ }
+
+ // Error state
+ if (channels === undefined) {
return <>
{header}
Could not fetch Channels
- >
- }
-
- let filteredChannels = channels;
- if (searchStr && Array.isArray(channels)) {
- const re = new RegExp(_.escapeRegExp(searchStr), 'i');
- filteredChannels = channels.filter(i => re.test(i['name']));
- } else if (channels && hideSmall && enoughChannelsToHideSmall) {
- // Get the top 80% of Channels.
- let index80 = Math.floor(channels.length * 0.8);
- const sortedChannels = channels.sort((a, b) => b.video_count - a.video_count);
- let percentile = sortedChannels[index80].video_count;
- filteredChannels = channels.filter(i => {
- return i.video_count > percentile || i.name.toLowerCase() === 'wrolpi'
- });
- if (filteredChannels.length < 21) {
- // Filtering hid too many Channels, show them all.
- filteredChannels = channels;
- }
+ >;
}
return <>
{header}
-
- }
- />
-
-
- }
- />
-
-
-
-
-
-
-
-
- >
+
+ >;
}
diff --git a/app/src/components/Common.js b/app/src/components/Common.js
index 4a1a83a5..3f9e3910 100644
--- a/app/src/components/Common.js
+++ b/app/src/components/Common.js
@@ -303,6 +303,21 @@ export function secondsToTimestamp(seconds) {
}
export function humanFileSize(bytes, dp = 1) {
+ // Handle null/undefined
+ if (bytes == null) {
+ return '-';
+ }
+
+ // Convert string to number if needed
+ if (typeof bytes === 'string') {
+ bytes = parseFloat(bytes);
+ }
+
+ // Handle invalid numbers
+ if (isNaN(bytes) || !isFinite(bytes)) {
+ return '-';
+ }
+
const thresh = 1024;
if (Math.abs(bytes) < thresh) {
@@ -318,7 +333,6 @@ export function humanFileSize(bytes, dp = 1) {
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
-
return bytes.toFixed(dp) + ' ' + units[u];
}
@@ -596,10 +610,13 @@ export function TabLinks({links}) {
return