Merge pull request #533 from arabcoders/dev
Some checks failed
native-build / build (amd64, ubuntu-latest) (push) Has been cancelled
native-build / build (amd64, windows-latest) (push) Has been cancelled
native-build / build (arm64, macos-latest) (push) Has been cancelled
native-build / build (arm64, ubuntu-latest) (push) Has been cancelled
native-build / build (arm64, windows-latest) (push) Has been cancelled

Refactor: add enabled field to conditions
This commit is contained in:
Abdulmohsen 2026-01-04 17:30:15 +03:00 committed by GitHub
commit a924ddb718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 512 additions and 877 deletions

11
API.md
View file

@ -1510,7 +1510,8 @@ or an error:
"name": "condition_name",
"filter": "...",
"cli": "...",
...
"extras": {},
"enabled": true
},
...
]
@ -1529,6 +1530,8 @@ or an error:
"name": "Use proxy for region locked content",
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
"cli": "--proxy http://myproxy.com:8080",
"extras": {},
"enabled": true
},
...
]
@ -1542,11 +1545,17 @@ or an error:
"name": "Use proxy for region locked content",
"filter": "availability = 'needs_auth' & channel_id = 'channel_id'",
"cli": "--proxy http://myproxy.com:8080",
"extras": {},
"enabled": true
},
...
]
```
**Notes**:
- Disabled conditions (`enabled: false`) will be stored but ignored during matching.
- All conditions are enabled by default when the `enabled` field is not provided.
---
### POST /api/conditions/test

View file

@ -1,715 +1,149 @@
# Contributing to YTPTube
Thank you for your interest in contributing to **YTPTube**! We welcome contributions from the community to help improve this project.
YTPTube is a **personal-first project**. While contributions are welcome, **all final decisions rest with the maintainer**.
This document provides guidelines and instructions for contributing to the project.
## Core Principles
## Table of Contents
* **The maintainer has final say** on all changes and project direction.
* This project is built **for personal use first**. Community contributions are secondary.
* **All contributions require prior discussion and approval.**
* **All pull requests must target the `dev` branch** never `master`.
* Contributions must align with the project's goals, architecture, and coding standards.
- [Contributing to YTPTube](#contributing-to-ytptube)
- [Table of Contents](#table-of-contents)
- [Code of Conduct](#code-of-conduct)
- [Getting Started](#getting-started)
- [Prerequisites](#prerequisites)
- [Backend Requirements](#backend-requirements)
- [Frontend Requirements](#frontend-requirements)
- [Optional Tools](#optional-tools)
- [Installing Required Package Managers](#installing-required-package-managers)
- [Installing uv (Python Package Manager)](#installing-uv-python-package-manager)
- [Installing pnpm (Node.js Package Manager)](#installing-pnpm-nodejs-package-manager)
- [Cloning the Repository](#cloning-the-repository)
- [Development Setup](#development-setup)
- [Backend Setup (Python)](#backend-setup-python)
- [Frontend Setup (Node.js)](#frontend-setup-nodejs)
- [Development Workflow](#development-workflow)
- [Working on a Feature or Bug Fix](#working-on-a-feature-or-bug-fix)
- [Branching Strategy](#branching-strategy)
- [Important Rules:](#important-rules)
- [Branch Naming Conventions:](#branch-naming-conventions)
- [Testing Requirements](#testing-requirements)
- [Backend Testing (Python)](#backend-testing-python)
- [Frontend Testing (TypeScript/Vue)](#frontend-testing-typescriptvue)
- [Test Coverage Expectations](#test-coverage-expectations)
- [Code Quality and Linting](#code-quality-and-linting)
- [Backend (Python)](#backend-python)
- [Frontend (TypeScript/Vue)](#frontend-typescriptvue)
- [Code Quality Checklist](#code-quality-checklist)
- [Pre-commit Hooks](#pre-commit-hooks)
- [Setting Up Pre-commit Hooks](#setting-up-pre-commit-hooks)
- [Before Submitting](#before-submitting)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Creating a Pull Request](#creating-a-pull-request)
- [PR Review Process](#pr-review-process)
- [After Your PR is Merged](#after-your-pr-is-merged)
- [Code Style](#code-style)
- [Python Code Style](#python-code-style)
- [TypeScript/Vue Code Style](#typescriptvue-code-style)
- [Getting Help](#getting-help)
- [Resources](#resources)
- [Ask Questions](#ask-questions)
- [Reporting Bugs](#reporting-bugs)
- [License](#license)
**Opening a PR without prior approval will result in immediate closure without review.**
---
## Code of Conduct
## Contribution Process
Please be respectful and constructive in all interactions. We aim to maintain a welcoming and inclusive community.
### 1. Start a Discussion
---
Before writing any code, **propose your idea** through one of these channels:
## Getting Started
* Open a **GitHub Issue** for bug fixes or small features
* Start a **GitHub Discussion** for larger changes or questions
### Prerequisites
**Include in your proposal:**
* **What** you want to change
* **Why** it's needed or beneficial
* Bug fixes and functional improvements are prioritized
* Performance enhancements with measurable impact
* Features that align with the project's core purpose
* **Not acceptable:** Personal UI/UX preferences, stylistic changes, or "I think it looks better" rationale
* **How** you plan to implement it (high-level approach)
* Any relevant context or use cases
Before you begin, ensure you have the following tools installed on your system:
### 2. Wait for Approval
#### Backend Requirements
* Only proceed after **explicit approval** from the maintainer to not waste effort.
* The maintainer may suggest modifications or alternative approaches.
* Not all proposals will be accepted this protects project coherence.
- **Python 3.13+**: YTPTube requires Python 3.13 or higher
- **uv**: Modern Python package manager and virtual environment tool
#### Frontend Requirements
- **Node.js 18+**: Required for the Nuxt/Vue frontend
- **pnpm**: Fast, disk space efficient package manager
#### Optional Tools
- **Git**: Version control
- **Docker**: For containerized development (optional)
- **ffmpeg**: Required for video player functionality
### Installing Required Package Managers
#### Installing uv (Python Package Manager)
**uv** is a fast Python package installer and resolver written in Rust. It's used for managing Python dependencies and virtual environments.
**On Linux/macOS:**
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**On Windows (PowerShell):**
```powershell
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
```
**Using pip:**
```bash
pip install uv
```
**Verify installation:**
```bash
uv --version
```
For more information, visit: https://github.com/astral-sh/uv
#### Installing pnpm (Node.js Package Manager)
**pnpm** is a fast, disk space efficient package manager for Node.js.
**Using npm:**
```bash
npm install -g pnpm
```
**Using Homebrew (macOS):**
```bash
brew install pnpm
```
**Using Scoop (Windows):**
```bash
scoop install pnpm
```
**Using standalone script:**
```bash
curl -fsSL https://get.pnpm.io/install.sh | sh -
```
**Verify installation:**
```bash
pnpm --version
```
For more information, visit: https://pnpm.io/installation
### Cloning the Repository
Clone the repository to your local machine:
```bash
git clone https://github.com/arabcoders/ytptube.git
cd ytptube
```
**Important**: Always work on the `dev` branch, not `master`/`main`:
```bash
git checkout dev
```
---
## Development Setup
### Backend Setup (Python)
1. **Navigate to the project root:**
```bash
cd ytptube
```
2. **Install Python dependencies using uv:**
```bash
uv sync
```
This will:
- Create a virtual environment (`.venv/`)
- Install all dependencies from `pyproject.toml`
- Install development dependencies
3. **Install development dependencies:**
Development dependencies are defined in `pyproject.toml` under `[dependency-groups]`:
```bash
uv sync --group dev
```
4. **Verify installation:**
```bash
uv run python --version
uv run pytest --version
```
5. **Run the backend development server:**
```bash
uv run app/main.py
```
The backend API will be available at `http://localhost:8081`
### Frontend Setup (Node.js)
1. **Navigate to the UI directory:**
```bash
cd ui
```
2. **Install Node.js dependencies using pnpm:**
```bash
pnpm install
```
> [!NOTE]
> Create a `.env` file in the `ui/` directory based on `.env.example` to configure environment variables.
> to link the frontend to the backend API.
3. **Run the frontend development server:**
```bash
pnpm dev
```
The frontend will be available at `http://localhost:8082` (or the port shown in the terminal)
4. **Verify the setup:**
```bash
pnpm run typecheck
pnpm run lint
```
---
## Development Workflow
### Working on a Feature or Bug Fix
1. **Ensure you're on the `dev` branch:**
### 3. Develop Your Changes
**Branch from `dev`:**
```bash
git checkout dev
git pull origin dev
git checkout -b feature/descriptive-name
```
2. **Create a feature branch:**
**Follow project standards:**
* Match existing code style and conventions
* Add or update tests for all changes:
* New features **MUST** include tests
* Bug fixes **MUST** include a regression test
* Ensure all linting and tests pass
* Keep changes focused and atomic
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/bug-description
```
### 4. Submit a Pull Request
3. **Make your changes**
**Target the `dev` branch:**
* Reference the approved issue or discussion number
* Provide a clear description of what changed and why
* List any breaking changes or migration steps
* Ensure CI checks pass
4. **Test your changes** (see [Testing Requirements](#testing-requirements))
5. **Run code quality checks** (see [Code Quality and Linting](#code-quality-and-linting))
6. **Commit your changes:**
```bash
git add .
git commit -m "feat: add new feature description"
# or
git commit -m "fix: fix bug description"
```
7. **Push to your fork:**
```bash
git push origin feature/your-feature-name
```
8. **Create a Pull Request** targeting the `dev` branch
**PR template checklist:**
- [ ] Discussed and approved beforehand
- [ ] Targets `dev` branch
- [ ] Tests added/updated and passing
- [ ] Linting passes
- [ ] Documentation updated (if needed)
---
## Branching Strategy
## Automatic Rejections
**YTPTube uses a two-branch strategy:**
The following will be **closed immediately without review**:
- **`master`**: Production-ready code. Only maintainers merge to this branch.
- **`dev`**: Development branch. **All contributions must target this branch.**
* PRs opened without prior discussion and approval
* PRs targeting `master` instead of `dev`
* Large refactors or architectural changes without approval
* Fully AI-generated code without meaningful human oversight
* Changes that don't align with project goals or philosophy
* PRs where the contributor cannot explain or justify the changes
### Important Rules:
---
- ✅ **DO**: Create feature branches from `dev`
- ✅ **DO**: Submit pull requests to `dev`
- ❌ **DON'T**: Submit pull requests to `master`/`main`
- ❌ **DON'T**: Commit directly to `dev` (use feature branches)
## AI-Assisted Development
### Branch Naming Conventions:
AI tools are **permitted** as development aids, but **you remain fully responsible** for all submitted code.
- `feature/description` - For new features
- `fix/description` - For bug fixes
- `refactor/description` - For code refactoring
- `docs/description` - For documentation updates
- `test/description` - For test additions/improvements
### Acceptable Use
AI-assisted code is welcome **when**:
* You **fully understand** every line being submitted
* The code **seamlessly integrates** with existing patterns and style
* You have **reviewed, tested, and validated** the output yourself
* **Comprehensive tests** are included (not AI-generated stubs)
* The code is **indistinguishable in quality** from hand-written contributions
* You can **explain and defend** design decisions in the PR
**AI is a tool, not a substitute for understanding.**
### Not Acceptable
The following will be rejected:
* Fully AI-generated PRs with minimal human review
* Code that introduces new patterns or abstractions without approval
* "Prompt-dump" output that doesn't match project conventions
* Changes the contributor cannot explain or justify
* AI-generated test suites that don't meaningfully validate behavior
### Disclosure
You are **not required** to disclose AI usage. However, if code quality suggests pure AI generation without human oversight, the PR will be closed.
**You are accountable for correctness, maintainability, and alignment regardless of how the code was created.**
---
## Testing Requirements
**All code changes must include appropriate tests.** This ensures code quality and prevents regressions.
All contributions must include appropriate tests:
### Backend Testing (Python)
* **New features:** Full test coverage including edge cases
* **Bug fixes:** Regression test that fails before the fix and passes after
* **Refactors:** Existing tests must continue to pass
* **Performance changes:** Benchmarks or performance tests when applicable
Tests are located in `app/tests/` and use **pytest**.
**Running tests:**
```bash
# Run all tests
uv run pytest
# Run specific test file
uv run pytest app/tests/test_download.py
# Run with verbose output
uv run pytest -v
# Run with coverage
uv run pytest --cov=app --cov-report=html
```
**Creating new tests:**
1. Create a test file in `app/tests/` with the prefix `test_`
2. Use pytest conventions:
```python
import pytest
from app.library.YourModule import YourClass
class TestYourClass:
def test_feature_name(self):
"""Test description."""
# Arrange
instance = YourClass()
# Act
result = instance.method()
# Assert
assert result == expected_value
@pytest.mark.asyncio
async def test_async_feature(self):
"""Test async functionality."""
result = await async_function()
assert result is not None
```
**Testing Singleton Classes:**
When testing singleton classes, always reset the singleton instance:
```python
from app.library.Presets import Presets
class TestPresets:
def setup_method(self):
"""Reset singleton before each test."""
Presets._reset_singleton()
def teardown_method(self):
"""Reset singleton after each test."""
Presets._reset_singleton()
```
### Frontend Testing (TypeScript/Vue)
Tests are located in `ui/tests/` and use **Vitest**.
**Running tests:**
```bash
cd ui
# Run all tests
pnpm test
# Run in watch mode
pnpm test:watch
# Run with coverage
pnpm run test --coverage
```
**Creating new tests:**
```typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import YourComponent from '~/components/YourComponent.vue'
describe('YourComponent', () => {
it('renders correctly', () => {
const wrapper = mount(YourComponent)
expect(wrapper.exists()).toBe(true)
})
it('handles user interaction', async () => {
const wrapper = mount(YourComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('event-name')).toBeTruthy()
})
})
```
### Test Coverage Expectations
- **New features**: Must include comprehensive tests
- **Bug fixes**: Must include a test that reproduces the bug
- **Refactoring**: Existing tests should still pass
- **Coverage goal**: Aim for >80% code coverage on new code
Tests should be clear, maintainable, and actually validate the intended behavior.
---
## Code Quality and Linting
## Questions?
### Backend (Python)
YTPTube uses **Ruff** for linting and formatting Python code.
**Running Ruff:**
```bash
# Check for linting issues
uv run ruff check app/
# Auto-fix linting issues
uv run ruff check --fix app/
# Format code
uv run ruff format app/
# Check specific file
uv run ruff check app/library/Download.py
uv run ruff check --fix app/library/Download.py
```
**Configuration:**
Ruff configuration is in `pyproject.toml` under `[tool.ruff]`.
### Frontend (TypeScript/Vue)
The frontend uses **ESLint** for linting and **TypeScript** for type checking.
**Running checks:**
```bash
cd ui
# Type checking
pnpm run typecheck
# Linting
pnpm run lint
# Auto-fix linting issues
pnpm run lint:fix
```
### Code Quality Checklist
Before committing, ensure:
- ✅ All tests pass (`uv run pytest` and `pnpm test`)
- ✅ No linting errors (`ruff check` and `pnpm run lint`)
- ✅ TypeScript compilation succeeds (`pnpm run typecheck`)
- ✅ Code is formatted properly
- ✅ No unused imports or variables
- ✅ Type hints are present (Python 3.13+)
---
## Pre-commit Hooks
YTPTube uses a custom shell script-based pre-commit hook that runs all checks in parallel for faster execution.
### Setting Up Pre-commit Hooks
1. **Create the pre-commit hook script:**
Create a file at `.git/hooks/pre-commit` with the following content:
```bash
#!/bin/sh
set -eu
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT" || exit 1
PIDS_FILE=$(mktemp)
trap 'rm -f "$PIDS_FILE"' EXIT
export CI=1
run_step() {
local label="$1"
shift
echo "🔹 Starting $label..." >&2
(
if ! "$@"; then
echo "❌ [$label] failed. Commit aborted." >&2
exit 1
else
echo "✅ [$label] passed." >&2
fi
) &
echo $! >>"$PIDS_FILE"
}
run_step "Ruff (Python linter)" uv run ruff check app/
run_step "PyTest" uv run pytest app/ -q
run_step "ESLint" pnpm --dir ./ui run lint
run_step "Vitest" pnpm --dir ./ui run test
fail=0
while read -r pid; do
if ! wait "$pid"; then
fail=1
fi
done <"$PIDS_FILE"
if [ "$fail" -eq 1 ]; then
echo "❌ One or more checks failed. Commit aborted." >&2
exit 1
fi
echo "✅ All checks passed. Commit allowed."
exit 0
```
2. **Make the hook executable:**
```bash
chmod +x .git/hooks/pre-commit
```
3. **Test the hook:**
```bash
# Test manually
.git/hooks/pre-commit
# Or make a test commit
git commit --allow-empty -m "test: verify pre-commit hook"
```
---
### Before Submitting
✅ **Checklist:**
- [ ] Code is on a feature branch created from `dev`
- [ ] All tests pass locally
- [ ] Code passes linting and formatting checks
- [ ] TypeScript types are properly defined (no `any`) without justification
- [ ] New features include tests
- [ ] Documentation is updated (if applicable)
- [ ] Commit messages are clear and descriptive
- [ ] Pre-commit hooks pass
---
## Pull Request Guidelines
### Creating a Pull Request
1. **Push your branch to GitHub:**
```bash
git push origin feature/your-feature-name
```
2. **Open a Pull Request on GitHub**
3. **Target the `dev` branch** (not `master`)
4. **Fill out the PR template** with:
- Description of changes
- Related issue numbers (if applicable)
- Testing performed
- Screenshots (for UI changes)
- Breaking changes (if any)
### PR Review Process
1. **Automated Checks**: CI pipeline runs tests and linting
2. **Code Review**: Maintainers review your code
3. **Feedback**: Address any requested changes
4. **Approval**: Once approved, maintainers will merge
### After Your PR is Merged
1. **Delete your feature branch** (optional but recommended)
2. **Pull the latest `dev` branch:**
```bash
git checkout dev
git pull origin dev
```
3. **Celebrate!** Thank you for contributing!
---
## Code Style
### Python Code Style
- **Line length**: 120 characters
- **Indentation**: 4 spaces
- **Quotes**: Double quotes for strings
- **Type hints**: Use Python 3.13+ type hints
- **Imports**: Absolute imports from `app.library`
- **Comparison**: Use Yoda comparisons for literals (`"value" == variable`)
**Example:**
```python
from app.library.Download import Download
from app.library.ItemDTO import ItemDTO
async def process_download(item: ItemDTO) -> bool:
"""Process a download item."""
try:
if "pending" == item.status: # Yoda comparison
result = await Download.start(item)
return result.success
except Exception as e:
LOG.error(f"Download failed: {e}")
return False
```
### TypeScript/Vue Code Style
- **Type safety**: No `any` types without justification
- **Explicit imports**: Import all Vue functions explicitly
- **Component structure**: Use `<script setup lang="ts">`
- **Props/Emits**: Always type-define
**Example:**
```vue
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { DownloadItem } from '~/types/download'
const props = defineProps<{
items: Array<DownloadItem>
}>()
const emit = defineEmits<{
(e: 'itemSelected', item: DownloadItem): void
}>()
const selectedItem = ref<DownloadItem | null>(null)
</script>
```
---
## Getting Help
### Resources
- **API Docs**: See [API.md](API.md) for API reference
- **FAQ**: Check [FAQ.md](FAQ.md) for common questions
- **Issues**: Browse existing [GitHub Issues](https://github.com/arabcoders/ytptube/issues)
### Ask Questions
- **GitHub Discussions**: For general questions and discussions
- **GitHub Issues**: For bug reports and feature requests
- **Pull Requests**: For code review and implementation questions
- **Discord**: Join our community on Discord (link in README)
### Reporting Bugs
When reporting bugs, please include:
1. **Description**: Clear description of the issue
2. **Steps to Reproduce**: Detailed steps to reproduce the bug
3. **Expected Behavior**: What you expected to happen
4. **Actual Behavior**: What actually happened
5. **Environment**: OS, Python version, Node version, etc.
6. **Logs**: Relevant error messages or logs
7. **Screenshots**: If applicable
* Check existing **Issues** and **Discussions** first
* Join the project **Discord** for real-time discussion
* Be patient, this is a personal project with limited maintenance time
---
## License
By contributing to YTPTube, you agree that your contributions will be licensed under the [MIT License](LICENSE).
By contributing, you agree that your code will be licensed under the project's **MIT License**.
---
Thank you for contributing to YTPTube! Your efforts help make this project better for everyone.
Thank you for respecting this contribution process. It helps maintain project quality and the maintainer's sanity.

View file

@ -34,6 +34,9 @@ class Condition:
extras: dict[str, Any] = field(default_factory=dict)
"""Any extra data to store with the condition."""
enabled: bool = True
"""Whether the condition is enabled."""
def serialize(self) -> dict:
return self.__dict__
@ -138,6 +141,10 @@ class Conditions(metaclass=Singleton):
item["extras"] = {}
need_save = True
if "enabled" not in item:
item["enabled"] = True
need_save = True
item: Condition = init_class(Condition, item)
self._items.append(item)
@ -146,7 +153,7 @@ class Conditions(metaclass=Singleton):
continue
if need_save:
LOG.info("Saving conditions due changes.")
LOG.warning("Saving conditions due to schema changes.")
self.save(self._items)
return self
@ -299,6 +306,9 @@ class Conditions(metaclass=Singleton):
return None
for item in self.get_all():
if not item.enabled:
continue
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
continue
@ -330,7 +340,7 @@ class Conditions(metaclass=Singleton):
return None
item = self.get(name)
if not item or not item.filter:
if not item or not item.enabled or not item.filter:
return None
return item if match_str(item.filter, info) else None

View file

@ -33,7 +33,7 @@ FRONTEND_ROUTES: list[str] = [
"/logs/",
"/conditions/",
"/browser/",
"/settings/",
"/simple/",
"/browser/{path:.*}",
]

View file

@ -94,6 +94,9 @@ async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -
if not item.get("extras"):
item["extras"] = {}
if "enabled" not in item:
item["enabled"] = True
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
item["id"] = str(uuid.uuid4())

View file

@ -34,6 +34,11 @@
"type": "object",
"description": "Any extra data to store with the condition.",
"default": {}
},
"enabled": {
"type": "boolean",
"description": "Whether the condition is enabled.",
"default": true
}
},
"examples": [
@ -43,7 +48,8 @@
"filter": "duration>3600",
"extras": {
"ignore_download": true
}
},
"enabled": true
},
{
"id": "b3c4d5e6-7890-3456-1cde-f23456789012",
@ -51,7 +57,8 @@
"filter": "categories~='Music'",
"extras": {
"set_preset": "audio-only"
}
},
"enabled": false
}
]
}

View file

@ -41,6 +41,7 @@ class TestCondition:
# Check defaults
assert condition.cli == ""
assert condition.extras == {}
assert condition.enabled is True
def test_condition_creation_with_all_fields(self):
"""Test creating a condition with all fields specified."""
@ -48,7 +49,7 @@ class TestCondition:
extras = {"key": "value", "number": 42}
condition = Condition(
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras
id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras, enabled=False
)
assert condition.id == test_id
@ -56,6 +57,7 @@ class TestCondition:
assert condition.filter == "uploader = 'test'"
assert condition.cli == "--format best"
assert condition.extras == extras
assert condition.enabled is False
def test_condition_serialize(self):
"""Test condition serialization to dict."""
@ -209,6 +211,7 @@ class TestConditions:
"filter": "duration < 300",
"cli": "--format worst",
"extras": {"category": "short"},
"enabled": True,
},
{
"id": str(uuid.uuid4()),
@ -216,6 +219,7 @@ class TestConditions:
"filter": "title ~= 'music'",
"cli": "--audio-quality 0",
"extras": {"type": "audio"},
"enabled": False,
},
]
@ -232,10 +236,12 @@ class TestConditions:
assert conditions._items[0].filter == "duration < 300"
assert conditions._items[0].cli == "--format worst"
assert conditions._items[0].extras == {"category": "short"}
assert conditions._items[0].enabled is True
# Check second condition
assert conditions._items[1].name == "music_videos"
assert conditions._items[1].filter == "title ~= 'music'"
assert conditions._items[1].enabled is False
def test_load_conditions_without_id(self):
"""Test loading conditions that don't have ID (should generate ID)."""
@ -279,6 +285,26 @@ class TestConditions:
# Should call save due to changes
mock_save.assert_called_once()
def test_load_conditions_without_enabled(self):
"""Test loading conditions that don't have enabled field (should default to True)."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "no_enabled_conditions.json"
test_data = [{"id": str(uuid.uuid4()), "name": "no_enabled_test", "filter": "duration > 60", "extras": {}}]
file_path.write_text(json.dumps(test_data))
with patch.object(Conditions, "save") as mock_save:
conditions = Conditions(file=file_path)
conditions.load()
# Should have generated enabled=True
assert len(conditions._items) == 1
assert conditions._items[0].enabled is True
# Should call save due to changes
mock_save.assert_called_once()
def test_load_invalid_json(self):
"""Test loading file with invalid JSON."""
with tempfile.TemporaryDirectory() as temp_dir:
@ -613,6 +639,28 @@ class TestConditions:
assert result is None
@patch("app.library.conditions.match_str")
def test_match_disabled_condition(self, mock_match_str):
"""Test that disabled conditions are skipped during matching."""
mock_match_str.return_value = True
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "disabled_match_test.json"
conditions = Conditions(file=file_path)
# Add disabled condition
disabled_condition = Condition(name="disabled_test", filter="duration > 60", enabled=False)
enabled_condition = Condition(name="enabled_test", filter="duration > 120", enabled=True)
conditions._items = [disabled_condition, enabled_condition]
info_dict = {"duration": 150}
result = conditions.match(info_dict)
# Should skip disabled condition and match enabled one
assert result is enabled_condition
# Should only call match_str once for enabled condition
mock_match_str.assert_called_once_with("duration > 120", info_dict)
def test_match_empty_filter(self):
"""Test matching with condition that has empty filter."""
with tempfile.TemporaryDirectory() as temp_dir:
@ -687,6 +735,21 @@ class TestConditions:
assert result is None
def test_single_match_disabled_condition(self):
"""Test single matching with disabled condition."""
with tempfile.TemporaryDirectory() as temp_dir:
file_path = Path(temp_dir) / "disabled_single_test.json"
conditions = Conditions(file=file_path)
test_condition = Condition(name="disabled_single", filter="duration > 60", enabled=False)
conditions._items = [test_condition]
info_dict = {"duration": 120}
result = conditions.single_match("disabled_single", info_dict)
# Should return None because condition is disabled
assert result is None
def test_single_match_invalid_inputs(self):
"""Test single matching with invalid inputs."""
with tempfile.TemporaryDirectory() as temp_dir:

View file

@ -374,3 +374,7 @@ div.is-centered {
padding: 0;
margin: 0 auto;
}
.is-rounded-less {
border-radius: 0px !important;
}

View file

@ -49,7 +49,7 @@
</span>
</div>
<div class="column is-12">
<div class="column is-9-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
<span class="icon"><i class="fa-solid fa-tag" /></span>
@ -66,6 +66,26 @@
</div>
</div>
<div class="column is-3-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="enabled">
<span class="icon"><i class="fa-solid fa-power-off" /></span>
Enabled
</label>
<div class="control is-unselectable">
<input id="enabled" type="checkbox" v-model="form.enabled" :disabled="addInProgress"
class="switch is-success" />
<label for="enabled" class="is-unselectable">
{{ form.enabled ? 'Yes' : 'No' }}
</label>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span class="is-bold">Whether the condition is enabled.</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="filter">
@ -276,6 +296,7 @@
</template>
<script setup lang="ts">
import 'assets/css/bulma-switch.css'
import { useStorage } from '@vueuse/core'
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
import type { AutoCompleteOptions } from '~/types/autocomplete';
@ -443,6 +464,10 @@ const importItem = async (): Promise<void> => {
form.extras = { ...item.extras }
}
if (item.enabled !== undefined) {
form.enabled = item.enabled
}
import_string.value = ''
showImport.value = false
} catch (e: any) {

View file

@ -1,51 +1,39 @@
<template>
<main class="m-6">
<div>
<template v-if="!simpleMode">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-cog" /></span>
<p class="card-header-title">WebUI Settings</p>
</span>
</span>
<span class="field is-horizontal" />
</template>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Page View</label>
</div>
<div class="field-body">
<div class="modal" :class="{ 'is-active': isOpen }">
<div class="modal-background" @click="emitter('close')" />
<div class="modal-card"
:class="{ 'slide-from-right': direction === 'right', 'slide-from-left': direction === 'left' }">
<header class="modal-card-head is-rounded-less">
<p class="modal-card-title">WebUI Settings</p>
<button class="delete" @click="emitter('close')" aria-label="close" />
</header>
<section class="modal-card-body">
<div class="box">
<div class="field">
<div class="control is-expanded has-icons-left">
<label class="label">Page View</label>
<div class="control">
<input id="view_mode" type="checkbox" class="switch is-success" v-model="simpleMode">
<label for="view_mode" class="is-unselectable">
{{ simpleMode ? 'Simple View' : 'Regular View' }}
</label>
</div>
<p class="help">
<span class="icon"> <i class="fa-solid fa-info-circle" /></span>
<span class="icon"><i class="fa-solid fa-info-circle" /></span>
The simple view is ideal for non-technical users and mobile devices.
</p>
</div>
</div>
</div>
<span class="field title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-palette" /></span>
<p class="card-header-title">Theming</p>
</span>
</span>
<span class="field is-horizontal" />
<div class="box">
<p class="title is-5 mb-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-palette" /></span>
<span>Theming</span>
</span>
</p>
<div class="field is-horizontal">
<div class="field-label">
<label class="label">Color scheme</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="field">
<label class="label">Color scheme</label>
<div class="control">
<label for="auto" class="radio">
<input id="auto" type="radio" v-model="selectedTheme" value="auto">
@ -64,15 +52,9 @@
</label>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Show Background</label>
</div>
<div class="field-body">
<div class="field">
<label class="label">Show Background</label>
<div class="control">
<input id="random_bg" type="checkbox" class="switch is-success" v-model="bg_enable">
<label for="random_bg" class="is-unselectable">
@ -80,62 +62,42 @@
</label>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label"></label>
</div>
<div class="field-body">
<div class="field">
<div class="field" v-if="bg_enable">
<div class="control">
<template v-if="bg_enable">
<button @click="$emit('reload_bg')" class="has-text-link" :disabled="isLoading">
<span class="icon-text">
<span class="icon"><i class="fa"
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
<span>Reload Background</span>
</span>
</button>
</template>
<button @click="$emit('reload_bg')" class="button is-link is-light is-fullwidth" :disabled="isLoading">
<span class="icon"><i class="fa"
:class="{ 'fa-spin fa-spinner': isLoading, 'fa-file-image': !isLoading }" /></span>
<span>Reload Background</span>
</button>
</div>
</div>
<div class="field" v-if="bg_enable">
<label class="label">Background visibility</label>
<div class="field has-addons">
<div class="control">
<a class="button is-static">
<code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
</a>
</div>
<div class="control is-expanded">
<input class="input" type="range" v-model="bg_opacity" min="0.50" max="1.00" step="0.05">
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Background visibility</label>
</div>
<div class="field-body">
<div class="field is-expanded">
<a class="button is-static is-small">
<code>{{ parseFloat(String(1.0 - bg_opacity)).toFixed(2) }}</code>
</a>
<div class="box">
<p class="title is-5 mb-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-home" /></span>
<span>Dashboard</span>
</span>
</p>
<div class="control">
<input id="random_bg_opacity" style="width: 100%" type="range" v-model="bg_opacity" min="0.50" max="1.00"
step="0.05">
</div>
</div>
</div>
</div>
<span class="field title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-home" /></span>
<p class="card-header-title">Dashboard</p>
</span>
</span>
<span class="field is-horizontal" />
<div class="field is-horizontal" v-if="!simpleMode">
<div class="field-label is-normal">
<label class="label">URL Separator</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="field" v-if="!simpleMode">
<label class="label">URL Separator</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="separator">
@ -146,17 +108,10 @@
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Show Thumbnails</label>
</div>
<div class="field-body">
<div class="control">
<div class="field">
<div class="field">
<label class="label">Show Thumbnails</label>
<div class="control">
<input id="show_thumbnail" type="checkbox" class="switch is-success" v-model="show_thumbnail">
<label for="show_thumbnail" class="is-unselectable">
{{ show_thumbnail ? 'Yes' : 'No' }}
@ -167,17 +122,10 @@
Show videos thumbnail if available
</p>
</div>
</div>
</div>
<div class="field is-horizontal" v-if="show_thumbnail">
<div class="field-label is-normal">
<label class="label">Aspect Ratio</label>
</div>
<div class="field-body">
<div class="control">
<div class="field">
<div class="field" v-if="show_thumbnail">
<label class="label">Aspect Ratio</label>
<div class="control">
<label for="ratio_16by9" class="radio">
<input id="ratio_16by9" type="radio" v-model="thumbnail_ratio" value="is-16by9">
<span>&nbsp;16:9</span>
@ -193,39 +141,27 @@
</p>
</div>
</div>
</div>
<span class="field title is-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-bell" /></span>
<p class="card-header-title">Notifications</p>
</span>
</span>
<span class="field is-horizontal" />
<div class="box">
<p class="title is-5 mb-4">
<span class="icon-text">
<span class="icon"><i class="fas fa-bell" /></span>
<span>Notifications</span>
</span>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Show notifications</label>
</div>
<div class="field-body">
<div class="control">
<div class="field">
<div class="field">
<label class="label">Show notifications</label>
<div class="control">
<input id="allow_toasts" type="checkbox" class="switch is-success" v-model="allow_toasts">
<label for="allow_toasts" class="is-unselectable">
{{ allow_toasts ? 'Yes' : 'No' }}
</label>
</div>
</div>
</div>
</div>
<div class="field is-horizontal" v-if="allow_toasts">
<div class="field-label is-normal">
<label class="label">Notification target</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="field" v-if="allow_toasts">
<label class="label">Notification target</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="toast_target" @change="onNotificationTargetChange">
@ -244,15 +180,9 @@
</template>
</p>
</div>
</div>
</div>
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
<div class="field-label is-normal">
<label class="label">Notifications position</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
<label class="label">Notifications position</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="toast_position">
@ -266,17 +196,10 @@
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal" v-if="allow_toasts && toast_target === 'toast'">
<div class="field-label is-normal">
<label class="label">Dismiss notification on click</label>
</div>
<div class="field-body">
<div class="control">
<div class="field">
<div class="field" v-if="allow_toasts && toast_target === 'toast'">
<label class="label">Dismiss notification on click</label>
<div class="control">
<input id="dismiss_on_click" type="checkbox" class="switch is-success" v-model="toast_dismiss_on_click">
<label for="dismiss_on_click" class="is-unselectable">
{{ toast_dismiss_on_click ? 'Yes' : 'No' }}
@ -284,22 +207,31 @@
</div>
</div>
</div>
</div>
</section>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import 'assets/css/bulma-switch.css'
import { watch, onMounted, onBeforeUnmount, ref } from 'vue'
import { useStorage } from '@vueuse/core'
import { ref, onMounted } from 'vue'
import { POSITION } from 'vue-toastification'
import { useConfigStore } from '~/stores/ConfigStore'
import { useNotification } from '~/composables/useNotification'
import type { notificationTarget } from '~/composables/useNotification'
defineProps<{ isLoading: boolean }>()
const emitter = defineEmits<{ (e: 'reload_bg' | 'close'): void }>()
const props = withDefaults(defineProps<{
isOpen?: boolean
direction?: 'left' | 'right'
isLoading?: boolean
}>(), {
isOpen: false,
direction: 'right',
isLoading: false
})
const emitter = defineEmits<{ (e: 'close' | 'reload_bg'): void }>()
const config = useConfigStore()
const notification = useNotification()
@ -317,14 +249,27 @@ const separator = useStorage<string>('url_separator', separators[0]?.value ?? ',
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
const isSecureContext = ref<boolean>(false)
const handleKeydown = (e: KeyboardEvent) => {
if ('Escape' === e.key && props.isOpen) {
e.preventDefault()
e.stopPropagation()
emitter('close')
}
}
onMounted(async () => {
isSecureContext.value = window.isSecureContext
await nextTick()
if ('browser' === toast_target.value && !isSecureContext.value) {
toast_target.value = 'toast'
}
document.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => document.removeEventListener('keydown', handleKeydown))
const onNotificationTargetChange = async (): Promise<void> => {
if ('browser' === toast_target.value) {
const permission = await notification.requestBrowserPermission()
@ -335,5 +280,74 @@ const onNotificationTargetChange = async (): Promise<void> => {
}
}
watch(simpleMode, async () => emitter('close'))
watch(() => props.isOpen, isOpen => {
if (isOpen) {
document.body.classList.add('settings-panel-open')
} else {
document.body.classList.remove('settings-panel-open')
}
})
</script>
<style scoped>
.modal-card.slide-from-right {
position: fixed;
right: 0;
top: 0;
height: 100vh;
max-height: 100vh;
margin: 0;
width: 600px;
max-width: 90vw;
transition: transform 0.3s ease;
transform: translateX(100%);
}
.modal.is-active .modal-card.slide-from-right {
transform: translateX(0);
}
.modal-card.slide-from-left {
position: fixed;
left: 0;
top: 0;
height: 100vh;
max-height: 100vh;
margin: 0;
width: 600px;
max-width: 90vw;
transition: transform 0.3s ease;
transform: translateX(-100%);
}
.modal.is-active .modal-card.slide-from-left {
transform: translateX(0);
}
.modal-card-body {
overflow-y: auto;
}
@media screen and (max-width: 768px) {
.modal-card.slide-from-right,
.modal-card.slide-from-left {
width: 100vw;
max-width: 100vw;
}
}
:global(body.settings-panel-open) {
overflow: hidden;
}
#main_container {
transition: transform 0.3s ease;
}
@media screen and (min-width: 769px) {
:global(.settings-open #main_container) {
transform: translateX(-300px);
}
}
</style>

View file

@ -1,10 +1,11 @@
<template>
<div class="basic-wrapper">
<div class="basic-wrapper" :class="{ 'settings-open': settingsOpen }">
<div class="form-container" :class="{ 'is-centered': shouldCenterForm }">
<section class="download-form box">
<form class="download-form__body" autocomplete="off" @submit.prevent="addDownload">
<label class="label" for="download-url">
{{ greetingMessage }}
<template v-if="!isMobile">{{ greetingMessage }}</template>
<template v-else>What would you like to download?</template>
<span class="is-pulled-right">
<span class="icon is-pointer" :class="connectionStatusColor" @click="$emit('show_settings')"
v-tooltip="'WebUI Settings'">
@ -106,7 +107,9 @@
</figure>
<div class="media-content is-grid">
<p class="title is-6 mb-0 queue-title">
<NuxtLink target="_blank" :href="entry.item.url">{{ entry.item.title }}</NuxtLink>
<NuxtLink target="_blank" :href="entry.item.url" v-tooltip="entry.item.title">
{{ entry.item.title || 'Untitled' }}
</NuxtLink>
</p>
<div class="field is-grouped is-unselectable">
<div class="control">
@ -205,7 +208,6 @@
</div>
<button class="modal-close is-large" aria-label="close" @click="closePlayer"></button>
</div>
</div>
</template>
@ -225,10 +227,19 @@ import { ag, encodePath, formatTime, makeDownload, request, stripPath, ucFirst,
defineEmits<{ (e: 'show_settings'): void }>()
withDefaults(defineProps<{
settingsOpen?: boolean
}>(), {
settingsOpen: false
})
const configStore = useConfigStore()
const stateStore = useStateStore()
const socketStore = useSocketStore()
const toast = useNotification()
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const isMobile = useMediaQuery({ maxWidth: 1024 })
const { app, paused, presets } = storeToRefs(configStore)
const { queue, history } = storeToRefs(stateStore)
@ -241,12 +252,9 @@ const formUrl = ref<string>('')
const formPreset = ref<{ preset: string }>({ preset: app.value.default_preset || '' })
const addInProgress = ref<boolean>(false)
const showExtras = ref<boolean>(false)
const dlFields = useStorage<Record<string, any>>('dl_fields', {})
const show_thumbnail = useStorage<boolean>('show_thumbnail', true)
const paginationInfo = computed(() => stateStore.getPagination())
const queueItems = computed<StoreItem[]>(() => Object.values(queue.value ?? {}).slice().sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)))
const historyEntries = computed<StoreItem[]>(() => {
const items = Object.values(history.value ?? {})
return items.slice().sort((a, b) => new Date(b.datetime).getTime() - new Date(a.datetime).getTime())
@ -654,6 +662,10 @@ onMounted(async () => {
if (route.query?.simple !== undefined) {
const simpleMode = useStorage<boolean>('simple_mode', configStore.app.simple_mode || false)
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
await nextTick()
const url = new URL(window.location.href)
url.searchParams.delete('simple')
window.history.replaceState({}, '', url.toString())
}
if (socketStore.isConnected && !paginationInfo.value.isLoaded) {
@ -718,6 +730,17 @@ useIntersectionObserver(loadMoreTrigger, ([entry]) => {
align-items: center;
gap: 2.5rem;
padding: 2rem 1rem 4rem;
transition: transform 0.3s ease;
}
.basic-wrapper.settings-open {
transform: translateX(-300px);
}
@media screen and (max-width: 768px) {
.basic-wrapper.settings-open {
transform: translateX(0);
}
}
.form-container {

View file

@ -2,38 +2,16 @@
<template v-if="simpleMode">
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
<Simple @show_settings="() => show_settings = true" />
<Simple @show_settings="() =>show_settings = true"
:class="{ 'settings-open': show_settings }" />
</template>
<template v-if="show_settings">
<Modal @close="closeSettings()"
:content-class="isMobile ? 'modal-content-max is-overflow-scroll ' : 'modal-content-max'">
<div class="columns is-multiline" style="width:100%">
<div class="column is-12">
<div class="card">
<header class="card-header">
<p class="card-header-title">WebUI Settings</p>
<span class="card-header-icon">
<span class="icon"><i class="fas fa-cog" /></span>
</span>
</header>
<div class="card-content">
<div class="columns is-multiline">
<div class="column is-12">
<settings v-if="show_settings" :isLoading="loadingImage" @reload_bg="() => loadImage(true)"
@close="closeSettings()" />
</div>
</div>
</div>
</div>
</div>
</div>
</Modal>
</template>
<SettingsPanel :isOpen="show_settings" :isLoading="loadingImage" @close="closeSettings()"
@reload_bg="() => loadImage(true)" direction="right" />
<template v-if="!simpleMode">
<Shutdown v-if="app_shutdown" />
<div id="main_container" class="container" v-else>
<div id="main_container" class="container" :class="{ 'settings-open': show_settings }" v-else>
<NewVersion v-if="newVersionIsAvailable" />
<Connection :status="socket.connectionStatus" @reconnect="() => socket.reconnect()" />
<nav class="navbar is-mobile is-dark">
@ -142,11 +120,11 @@
</div>
<div class="navbar-item">
<NuxtLink class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }" to="/settings"
@click.prevent="(e: MouseEvent) => changeRoute(e)">
<button class="button is-dark " :class="{ 'has-tooltip-bottom mr-4': !isMobile }"
@click="show_settings = !show_settings">
<span class="icon"><i class="fas fa-cog" /></span>
<span v-if="isMobile">WebUI Settings</span>
</NuxtLink>
</button>
</div>
</div>
@ -226,7 +204,6 @@ import Simple from '~/components/Simple.vue'
import Shutdown from '~/components/shutdown.vue'
import Markdown from '~/components/Markdown.vue'
import Connection from '~/components/Connection.vue'
import Settings from "~/pages/settings.vue";
const Year = new Date().getFullYear()
const selectedTheme = useStorage('theme', 'auto')
@ -241,7 +218,6 @@ const isMobile = useMediaQuery({ maxWidth: 1024 })
const app_shutdown = ref<boolean>(false)
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
const show_settings = ref(false)
const doc = ref<{ file: string }>({ file: '' })
const applyPreferredColorScheme = (scheme: string) => {
@ -525,3 +501,23 @@ const connectionStatusColor = computed(() => {
const scrollToTop = () => document.getElementById('top')?.scrollIntoView({ behavior: 'smooth' });
</script>
<style>
#main_container,
.basic-wrapper {
transition: transform 0.3s ease, margin-right 0.3s ease;
}
#main_container.settings-open,
.basic-wrapper.settings-open {
transform: translateX(-300px);
}
@media screen and (max-width: 768px) {
#main_container.settings-open,
.basic-wrapper.settings-open {
transform: translateX(0);
}
}
</style>

View file

@ -50,9 +50,19 @@
<header class="card-header">
<div class="card-header-title is-text-overflow is-block" v-text="cond.name" />
<div class="card-header-icon">
<a class="has-text-info" v-tooltip="'Export item.'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<div class="field is-grouped">
<div class="control" @click="toggleEnabled(cond)">
<span class="icon" :class="cond.enabled ? 'has-text-success' : 'has-text-danger'"
v-tooltip="`Condition is ${cond.enabled !== false ? 'enabled' : 'disabled'}. Click to toggle.`">
<i class="fa-solid" :class="cond.enabled ? 'fa-check-circle' : 'fa-times-circle'" />
</span>
</div>
<div class="control">
<a class="has-text-info" v-tooltip="'Export item'" @click.prevent="exportItem(cond)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
</div>
</div>
</div>
</header>
<div class="card-content is-flex-grow-1">
@ -193,11 +203,11 @@ const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
try {
addInProgress.value = true
const validItems = newItems.map(({ id, name, filter, cli, extras }) => {
const validItems = newItems.map(({ id, name, filter, cli, extras, enabled }) => {
if (!name || !filter) {
throw new Error('Name and filter are required.')
}
return { id, name, filter, cli, extras }
return { id, name, filter, cli, extras, enabled }
})
const response = await request('/api/conditions', {
@ -273,6 +283,26 @@ const editItem = (_item: ConditionItem): void => {
toggleForm.value = true
}
const toggleEnabled = async (cond: ConditionItem): Promise<void> => {
const index = items.value.findIndex(t => t?.id === cond.id)
if (-1 === index) {
toast.error('Item not found.')
return
}
const item = items.value[index]
if (!item) {
toast.error('Item not found.')
return
}
item.enabled = !item.enabled
const status = await updateItems(items.value)
if (status) {
toast[item.enabled ? 'success' : 'warning'](`Condition is ${item.enabled ? 'enabled' : 'disabled'}.`)
}
}
const exportItem = (cond: ConditionItem): void => {
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
delete clone.id

View file

@ -163,12 +163,16 @@ watch(() => route.query.tab, (newTab) => {
activeTab.value = newTab as TabType
})
onMounted(() => {
onMounted(async () => {
const route = useRoute()
if (route.query?.simple !== undefined) {
const simpleMode = useStorage<boolean>('simple_mode', config.app.simple_mode || false)
simpleMode.value = ['true', '1', 'yes', 'on'].includes(route.query.simple as string)
await nextTick()
const url = new URL(window.location.href)
url.searchParams.delete('simple')
window.history.replaceState({}, '', url.toString())
}
activeTab.value = getInitialTab()

12
ui/app/pages/simple.vue Normal file
View file

@ -0,0 +1,12 @@
<template></template>
<script lang="ts" setup>
import { useStorage } from '@vueuse/core'
const simpleMode = useStorage<boolean>('simple_mode', true)
onMounted(async () => {
simpleMode.value = true
await nextTick()
await navigateTo('/')
})
</script>

View file

@ -4,6 +4,7 @@ export type ConditionItem = {
filter: string
cli: string
extras: Record<string, any>
enabled: boolean
}
export type ImportedConditionItem = ConditionItem & {