generated from nhcarrigan/template
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c829ec97c4
|
|||
|
dac875c413
|
|||
| f5e8deca59 | |||
| 6169eb4577 | |||
| 8826c1c1a5 | |||
| 5f84c5ae44 | |||
| 6b5fa40599 | |||
|
38e7f15d93
|
|||
|
6fe566b3f6
|
|||
|
6acef3b770
|
|||
|
b3a4793243
|
|||
|
30ea4ad79d
|
|||
| 80ebbcc651 | |||
| 4dcd768965 | |||
|
369992d665
|
|||
|
1f779f7655
|
@@ -1,10 +1,10 @@
|
|||||||
# Crowdin
|
# Crowdin
|
||||||
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID"
|
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
|
||||||
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url"
|
CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
|
||||||
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token"
|
CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
|
||||||
|
|
||||||
# Github
|
# Github
|
||||||
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token"
|
GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
DISCORD_TOKEN="op://Environment Variables - Development/Scripts/Discord Token"
|
DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token"
|
||||||
+78
-33
@@ -1,46 +1,91 @@
|
|||||||
name: Node.js CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
dependency-pin-check-typescript:
|
||||||
name: Lint and Test
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Source Files
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- name: Copy TypeScript files to root
|
||||||
|
run: |
|
||||||
- name: Use Node.js v22
|
cp typescript/package.json .
|
||||||
uses: actions/setup-node@v4
|
cp typescript/package-lock.json . 2>/dev/null || true
|
||||||
|
cp typescript/pnpm-lock.yaml . 2>/dev/null || true
|
||||||
|
- uses: naomi-lgbt/dependency-pin-check@main
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
language: typescript
|
||||||
|
dev-dependencies: true
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v2
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Ensure Dependencies are Pinned
|
|
||||||
uses: naomi-lgbt/dependency-pin-check@v2.0.0
|
|
||||||
with:
|
|
||||||
language: javascript
|
|
||||||
dev- dependencies: true
|
|
||||||
peer-dependencies: true
|
peer-dependencies: true
|
||||||
optional-dependencies: true
|
optional-dependencies: true
|
||||||
|
|
||||||
- name: Install Dependencies
|
dependency-pin-check-python:
|
||||||
run: pnpm install
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Copy Python files to root
|
||||||
|
run: |
|
||||||
|
cp python/requirements.txt .
|
||||||
|
cp python/pyproject.toml .
|
||||||
|
# Create empty package.json to prevent ENOENT error
|
||||||
|
echo '{}' > package.json
|
||||||
|
- uses: naomi-lgbt/dependency-pin-check@main
|
||||||
|
with:
|
||||||
|
language: python
|
||||||
|
dev-dependencies: true
|
||||||
|
|
||||||
- name: Lint Source Files
|
typescript:
|
||||||
run: pnpm run lint
|
needs: dependency-pin-check-typescript
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Verify Build
|
- uses: pnpm/action-setup@v4
|
||||||
run: pnpm run build
|
with:
|
||||||
|
version: 10.15.0
|
||||||
|
|
||||||
- name: Run Tests
|
- uses: actions/setup-node@v4
|
||||||
run: pnpm run test
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: typescript/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make install-ts
|
||||||
|
|
||||||
|
- name: Run ESLint
|
||||||
|
run: make lint-ts
|
||||||
|
|
||||||
|
- name: Build TypeScript
|
||||||
|
run: make build
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: make test
|
||||||
|
|
||||||
|
python:
|
||||||
|
needs: dependency-pin-check-python
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: make install-py
|
||||||
|
|
||||||
|
- name: Run Ruff linter
|
||||||
|
run: make lint-py
|
||||||
|
|
||||||
|
- name: Check Ruff formatting
|
||||||
|
run: make format-check-py
|
||||||
+20
-1
@@ -1,4 +1,23 @@
|
|||||||
node_modules
|
# Project-specific
|
||||||
prod
|
prod
|
||||||
data
|
data
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
prod.env
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.lcov
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Ephemere Project Guidelines
|
||||||
|
|
||||||
|
This document contains project-specific instructions for working with the Ephemere codebase.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Ephemere is a collection of ephemeral scripts for various tasks, written in TypeScript and Python. It contains utilities for:
|
||||||
|
- S3 operations (upload, bulk upload, delete, content type correction)
|
||||||
|
- Discord bot utilities
|
||||||
|
- Discourse forum management
|
||||||
|
- Gitea/GitHub operations
|
||||||
|
- Security analysis tools
|
||||||
|
- Music-related scripts
|
||||||
|
- Various utility functions
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ephemere/
|
||||||
|
├── typescript/ # TypeScript scripts
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── s3/ # S3 operations (upload, delete, bulk operations)
|
||||||
|
│ │ ├── discord/ # Discord bot and utilities
|
||||||
|
│ │ ├── discourse/ # Discourse forum management
|
||||||
|
│ │ ├── gitea/ # Gitea API interactions
|
||||||
|
│ │ ├── github/ # GitHub API interactions
|
||||||
|
│ │ ├── security/ # Security analysis tools
|
||||||
|
│ │ ├── music/ # Music-related utilities
|
||||||
|
│ │ └── utils/ # Shared utilities
|
||||||
|
│ └── data/ # Data files for S3 uploads
|
||||||
|
├── python/ # Python scripts
|
||||||
|
└── prod.env # 1Password vault references (safe to commit)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Standards
|
||||||
|
|
||||||
|
### TypeScript Scripts
|
||||||
|
- All TypeScript scripts must follow Naomi's Node.js project standards
|
||||||
|
- Use `@nhcarrigan/typescript-config` and `@nhcarrigan/eslint-config`
|
||||||
|
- Run scripts using the Makefile: `make run-ts src/path/to/script.ts`
|
||||||
|
- Interactive scripts should use `@inquirer/prompts` for user input
|
||||||
|
|
||||||
|
### Python Scripts
|
||||||
|
- Use `uv` for package management
|
||||||
|
- Linting and formatting with `ruff`
|
||||||
|
- Run scripts using the Makefile: `make run-py script_name.py`
|
||||||
|
|
||||||
|
### S3 Scripts Specifics
|
||||||
|
All S3 scripts in `typescript/src/s3/` follow these patterns:
|
||||||
|
- Use environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
|
||||||
|
- Interactive prompts using `@inquirer/prompts`
|
||||||
|
- Progress bars using `cli-progress` for bulk operations
|
||||||
|
- Proper error handling with success/error counts
|
||||||
|
- ESLint disable comments for AWS SDK naming conventions
|
||||||
|
- No CLI arguments - everything is prompted interactively
|
||||||
|
|
||||||
|
## Running Scripts
|
||||||
|
|
||||||
|
Always use the Makefile commands to run scripts (they handle 1Password integration):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# TypeScript scripts
|
||||||
|
make run-ts src/s3/deleteContents.ts
|
||||||
|
make run-ts src/discord/bot.ts
|
||||||
|
|
||||||
|
# Python scripts
|
||||||
|
make run-py analyse_availability.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the interactive runner:
|
||||||
|
```bash
|
||||||
|
make run # Interactive menu to select language, category, and script
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script Patterns
|
||||||
|
|
||||||
|
### TypeScript Script Template
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { input, confirm, select } from "@inquirer/prompts";
|
||||||
|
|
||||||
|
// Environment variable checks
|
||||||
|
const requiredVar = process.env.REQUIRED_VAR;
|
||||||
|
if (requiredVar === undefined) {
|
||||||
|
throw new Error("REQUIRED_VAR is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive prompts
|
||||||
|
const userInput = await input({
|
||||||
|
message: "Enter your input:",
|
||||||
|
validate: (value) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return "Input cannot be empty";
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main logic here
|
||||||
|
console.log("Processing...");
|
||||||
|
|
||||||
|
// Always provide feedback
|
||||||
|
console.log("✅ Operation completed successfully!");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Script Template
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script description here.
|
||||||
|
|
||||||
|
@copyright NHCarrigan
|
||||||
|
@license Naomi's Public License
|
||||||
|
@author Naomi Carrigan
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main function."""
|
||||||
|
# Environment variable checks
|
||||||
|
required_var = os.getenv("REQUIRED_VAR")
|
||||||
|
if not required_var:
|
||||||
|
print("Error: REQUIRED_VAR is not set")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Interactive input (if needed)
|
||||||
|
user_input = input("Enter your input: ").strip()
|
||||||
|
if not user_input:
|
||||||
|
print("Error: Input cannot be empty")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Main logic here
|
||||||
|
print("Processing...")
|
||||||
|
|
||||||
|
# Always provide feedback
|
||||||
|
print("✅ Operation completed successfully!")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
All secrets are managed through 1Password and referenced in `prod.env`:
|
||||||
|
- AWS credentials for S3 operations
|
||||||
|
- Discord bot tokens
|
||||||
|
- GitHub/Gitea API tokens
|
||||||
|
- Discourse API credentials
|
||||||
|
|
||||||
|
The `prod.env` file contains 1Password vault references (like `op://Private/Hetzner/S3 Endpoint`) and is safe to commit.
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **No hardcoded values** - All configuration should come from environment variables
|
||||||
|
2. **Interactive scripts** - Scripts should prompt for all required input, not use CLI arguments
|
||||||
|
3. **Progress feedback** - Long-running operations should show progress bars
|
||||||
|
4. **Error handling** - Always track and report success/failure counts
|
||||||
|
5. **Consistent patterns** - Follow the existing script patterns in each category
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Always validate environment variables at script start
|
||||||
|
- Provide clear error messages that help users fix the issue
|
||||||
|
- Use try-catch blocks for external API calls
|
||||||
|
- Track success/failure counts for bulk operations
|
||||||
|
- Exit with appropriate status codes (0 for success, 1 for errors)
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- Use emojis in console output for visual feedback (✅ ❌ ⚠️ 🚀 📦 etc.)
|
||||||
|
- Show progress bars for operations with multiple items
|
||||||
|
- Confirm destructive operations (require typing confirmation + yes/no)
|
||||||
|
- Provide summaries at the end of bulk operations
|
||||||
|
- Keep output concise but informative
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- Add JSDoc comments for TypeScript functions
|
||||||
|
- Use type annotations in Python
|
||||||
|
- Follow the established linting rules (no overrides without good reason)
|
||||||
|
- Keep functions focused and single-purpose
|
||||||
|
- Extract reusable logic to utility functions
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Never log sensitive information (tokens, passwords, keys)
|
||||||
|
- Validate all user inputs
|
||||||
|
- Use parameterized queries for any database operations
|
||||||
|
- Follow the principle of least privilege for API tokens
|
||||||
|
- Sanitize file paths and names
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a new S3 script
|
||||||
|
1. Create the script in `typescript/src/s3/`
|
||||||
|
2. Follow the pattern of existing S3 scripts (see deleteContents.ts)
|
||||||
|
3. Use environment variables: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_ENDPOINT`
|
||||||
|
4. Use @inquirer/prompts for all user input (no CLI arguments)
|
||||||
|
5. Include proper error handling and progress bars for bulk operations
|
||||||
|
6. Add ESLint disable comments for AWS SDK naming conventions
|
||||||
|
|
||||||
|
### Adding a new Discord script
|
||||||
|
1. Create the script in `typescript/src/discord/`
|
||||||
|
2. Use environment variables: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, etc.
|
||||||
|
3. Follow Discord.js best practices
|
||||||
|
4. Use @inquirer/prompts for any configuration input
|
||||||
|
5. Handle rate limits and API errors gracefully
|
||||||
|
|
||||||
|
### Adding a new GitHub/Gitea script
|
||||||
|
1. Create the script in `typescript/src/github/` or `typescript/src/gitea/`
|
||||||
|
2. Use environment variables: `GITHUB_TOKEN` or `GITEA_TOKEN`
|
||||||
|
3. Use the Octokit library for GitHub operations
|
||||||
|
4. Include proper pagination for list operations
|
||||||
|
5. Handle API rate limits appropriately
|
||||||
|
|
||||||
|
### Adding a new Discourse script
|
||||||
|
1. Create the script in `typescript/src/discourse/`
|
||||||
|
2. Use environment variables: `DISCOURSE_URL`, `DISCOURSE_API_KEY`, `DISCOURSE_API_USERNAME`
|
||||||
|
3. Follow the Discourse API documentation
|
||||||
|
4. Use @inquirer/prompts for interactive inputs
|
||||||
|
5. Handle API errors and rate limits
|
||||||
|
|
||||||
|
### Adding a new Security script
|
||||||
|
1. Create the script in `typescript/src/security/`
|
||||||
|
2. Use environment variables for any API tokens (e.g., `DOJO_TOKEN` for DefectDojo)
|
||||||
|
3. Follow security best practices - never log sensitive data
|
||||||
|
4. Include proper validation for all inputs
|
||||||
|
5. Consider adding audit logs for security operations
|
||||||
|
|
||||||
|
### Adding a new Python script
|
||||||
|
1. Create the script in `python/`
|
||||||
|
2. Use type hints for all functions and variables
|
||||||
|
3. Follow PEP 8 style guide (enforced by ruff)
|
||||||
|
4. Add the script to `requirements.txt` if it needs new dependencies
|
||||||
|
5. Use `argparse` for CLI arguments if needed (though prefer interactive)
|
||||||
|
6. Include docstrings for all functions and classes
|
||||||
|
|
||||||
|
### Adding a new utility function
|
||||||
|
1. TypeScript utilities go in `typescript/src/utils/`
|
||||||
|
2. Python utilities can be imported from a shared module
|
||||||
|
3. Utilities should be pure functions when possible
|
||||||
|
4. Include comprehensive JSDoc/docstring documentation
|
||||||
|
5. Add unit tests if the utility is complex
|
||||||
|
|
||||||
|
### Adding a new script category
|
||||||
|
1. Create a new directory under `typescript/src/` or in `python/`
|
||||||
|
2. Follow the naming convention (lowercase, descriptive)
|
||||||
|
3. Create at least one example script showing the pattern
|
||||||
|
4. Update this CLAUDE.md with specific guidelines for the category
|
||||||
|
5. Add any new environment variables to prod.env with 1Password references
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
```bash
|
||||||
|
make lint # Run all linters
|
||||||
|
make build # Type check TypeScript
|
||||||
|
make test # Run tests (if any)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If a script fails with "not set" errors, ensure you're running through the Makefile or with `op run`
|
||||||
|
- For S3 scripts, verify your 1Password vault has the correct S3 endpoint, access key, and secret key
|
||||||
|
- TypeScript import errors: ensure you use `.js` extensions in imports (even for `.ts` files)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
.PHONY: help install install-ts install-py build lint lint-ts lint-py format format-py format-check format-check-py test test-ts test-py coverage coverage-ts coverage-py clean run
|
||||||
|
|
||||||
|
# Default target - show help
|
||||||
|
help:
|
||||||
|
@echo "Available commands:"
|
||||||
|
@echo " make install - Install all dependencies (TypeScript and Python)"
|
||||||
|
@echo " make install-ts - Install TypeScript dependencies only"
|
||||||
|
@echo " make install-py - Install Python dependencies only"
|
||||||
|
@echo " make build - Build TypeScript (type check)"
|
||||||
|
@echo " make lint - Run all linters (TypeScript and Python)"
|
||||||
|
@echo " make lint-ts - Run TypeScript linter only"
|
||||||
|
@echo " make lint-py - Run Python linter only"
|
||||||
|
@echo " make format - Format Python code"
|
||||||
|
@echo " make format-check - Check Python formatting without modifying"
|
||||||
|
@echo " make test - Run all tests (TypeScript and Python)"
|
||||||
|
@echo " make test-ts - Run TypeScript tests only"
|
||||||
|
@echo " make test-py - Run Python tests only"
|
||||||
|
@echo " make coverage - Run all tests with coverage"
|
||||||
|
@echo " make coverage-ts - Run TypeScript tests with coverage"
|
||||||
|
@echo " make coverage-py - Run Python tests with coverage"
|
||||||
|
@echo " make clean - Clean build artifacts and caches"
|
||||||
|
@echo ""
|
||||||
|
@echo "Running scripts:"
|
||||||
|
@echo " make run - Interactive script runner (select language, category, script)"
|
||||||
|
@echo " make run-bash - Run a bash script (e.g., make run-bash SCRIPT=bash/adb/push.sh)"
|
||||||
|
|
||||||
|
# Install all dependencies
|
||||||
|
install: install-ts install-py
|
||||||
|
|
||||||
|
# TypeScript dependencies
|
||||||
|
install-ts:
|
||||||
|
cd typescript && pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Python dependencies
|
||||||
|
install-py:
|
||||||
|
cd python && uv venv
|
||||||
|
cd python && uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Build TypeScript
|
||||||
|
build:
|
||||||
|
cd typescript && pnpm exec tsc --noEmit
|
||||||
|
|
||||||
|
# Run all linters
|
||||||
|
lint: lint-ts lint-py
|
||||||
|
|
||||||
|
# TypeScript linting
|
||||||
|
lint-ts:
|
||||||
|
cd typescript && pnpm exec eslint src --max-warnings 0
|
||||||
|
|
||||||
|
# Python linting
|
||||||
|
lint-py:
|
||||||
|
cd python && uv run ruff check .
|
||||||
|
|
||||||
|
format-ts:
|
||||||
|
cd typescript && pnpm exec eslint src --fix
|
||||||
|
|
||||||
|
# Format Python code
|
||||||
|
format: format-py
|
||||||
|
|
||||||
|
format-py:
|
||||||
|
cd python && uv run ruff format .
|
||||||
|
|
||||||
|
# Check formatting without modifying
|
||||||
|
format-check: format-check-py
|
||||||
|
|
||||||
|
format-check-py:
|
||||||
|
cd python && uv run ruff format --check .
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
test: test-ts test-py
|
||||||
|
|
||||||
|
# Run TypeScript tests
|
||||||
|
test-ts:
|
||||||
|
cd typescript && pnpm test
|
||||||
|
|
||||||
|
# Run Python tests
|
||||||
|
test-py:
|
||||||
|
cd python && uv run pytest -v
|
||||||
|
|
||||||
|
# Run all tests with coverage
|
||||||
|
coverage: coverage-ts coverage-py
|
||||||
|
|
||||||
|
# Run TypeScript tests with coverage
|
||||||
|
coverage-ts:
|
||||||
|
cd typescript && pnpm test:coverage
|
||||||
|
|
||||||
|
# Run Python tests with coverage
|
||||||
|
coverage-py:
|
||||||
|
cd python && uv run pytest --cov=. --cov-report=term-missing -v
|
||||||
|
|
||||||
|
# Clean build artifacts and caches
|
||||||
|
clean:
|
||||||
|
rm -rf typescript/node_modules
|
||||||
|
rm -rf python/.venv
|
||||||
|
rm -rf python/.ruff_cache
|
||||||
|
find python -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find python -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# Interactive script runner
|
||||||
|
run:
|
||||||
|
@./run.sh
|
||||||
|
|
||||||
|
# Run a specific bash script
|
||||||
|
run-bash:
|
||||||
|
@if [ -z "$(SCRIPT)" ]; then \
|
||||||
|
echo "Please specify a script: make run-bash SCRIPT=bash/adb/push.sh"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if [ ! -f "$(SCRIPT)" ]; then \
|
||||||
|
echo "Script not found: $(SCRIPT)"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "Running bash script: $(SCRIPT)"
|
||||||
|
@op run --env-file=prod.env --no-masking -- bash "$(SCRIPT)"
|
||||||
@@ -1,24 +1,136 @@
|
|||||||
# New Repository Template
|
# Ephemere
|
||||||
|
|
||||||
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
|
A collection of ephemeral scripts for various tasks, written in TypeScript and Python.
|
||||||
|
|
||||||
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
|
## Project Structure
|
||||||
|
|
||||||
## Readme
|
```
|
||||||
|
.
|
||||||
|
├── typescript/ # TypeScript project
|
||||||
|
│ ├── src/ # TypeScript source files
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── tsconfig.json
|
||||||
|
│ └── eslint.config.js
|
||||||
|
├── python/ # Python project
|
||||||
|
│ ├── *.py # Python scripts
|
||||||
|
│ ├── pyproject.toml
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── Makefile # Build commands for both projects
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
|
## Setup
|
||||||
|
|
||||||
<!-- # Project Name
|
### Prerequisites
|
||||||
|
|
||||||
Project Description
|
- Node.js (v24+) with nvm
|
||||||
|
- Python 3.10+
|
||||||
|
- pnpm 10.15.0
|
||||||
|
- uv (Python package manager)
|
||||||
|
- 1Password CLI (for secrets management)
|
||||||
|
|
||||||
## Live Version
|
### Installation
|
||||||
|
|
||||||
This page is currently deployed. [View the live website.]
|
Install all dependencies (TypeScript and Python):
|
||||||
|
```bash
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install individually:
|
||||||
|
```bash
|
||||||
|
make install-ts # TypeScript dependencies only
|
||||||
|
make install-py # Python dependencies only
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### TypeScript Scripts
|
||||||
|
|
||||||
|
TypeScript scripts are located in the `typescript/src/` directory. To run a TypeScript script with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the root directory
|
||||||
|
make run-ts src/s3/upload.ts
|
||||||
|
|
||||||
|
# Or manually from typescript directory
|
||||||
|
cd typescript
|
||||||
|
pnpm start path/to/script.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python Scripts
|
||||||
|
|
||||||
|
Python scripts are located in the `python/` directory. To run a Python script with environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the root directory
|
||||||
|
make run-py analyse_availability.py
|
||||||
|
|
||||||
|
# Or manually from python directory
|
||||||
|
cd python
|
||||||
|
uv run python script_name.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Linting and Formatting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all linters (TypeScript and Python)
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Run linters individually
|
||||||
|
make lint-ts # TypeScript linter
|
||||||
|
make lint-py # Python linter
|
||||||
|
|
||||||
|
# Build TypeScript (type check)
|
||||||
|
make build
|
||||||
|
|
||||||
|
# Format Python code
|
||||||
|
make format
|
||||||
|
|
||||||
|
# Check Python formatting without modifying
|
||||||
|
make format-check
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Clean build artifacts and caches
|
||||||
|
make clean
|
||||||
|
|
||||||
|
# Show all available commands
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
The GitHub Actions workflow runs the following checks:
|
||||||
|
|
||||||
|
1. **Dependency pin check** - Ensures all dependencies are pinned to exact versions
|
||||||
|
2. **TypeScript checks**:
|
||||||
|
- ESLint
|
||||||
|
- TypeScript build (type checking)
|
||||||
|
- Tests
|
||||||
|
3. **Python checks**:
|
||||||
|
- Ruff linting
|
||||||
|
- Ruff format checking
|
||||||
|
|
||||||
|
## Secrets Management
|
||||||
|
|
||||||
|
This project uses 1Password CLI for secrets management. Environment variables are stored in `prod.env` as 1Password vault references.
|
||||||
|
|
||||||
|
The `make run-ts` and `make run-py` commands automatically inject secrets from 1Password:
|
||||||
|
```bash
|
||||||
|
# These commands include 1Password integration
|
||||||
|
make run-ts src/discord/bot.ts
|
||||||
|
make run-py evaluate_technical_proficiency.py
|
||||||
|
```
|
||||||
|
|
||||||
|
To manually run scripts with secrets:
|
||||||
|
```bash
|
||||||
|
op run --env-file=prod.env -- <command>
|
||||||
|
```
|
||||||
|
|
||||||
## Feedback and Bugs
|
## Feedback and Bugs
|
||||||
|
|
||||||
If you have feedback or a bug report, please feel free to open a GitHub issue!
|
If you have feedback or a bug report, please [log a ticket on our forum](https://support.nhcarrigan.com).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -36,4 +148,4 @@ Copyright held by Naomi Carrigan.
|
|||||||
|
|
||||||
## Contact
|
## Contact
|
||||||
|
|
||||||
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
|
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# ADB File Transfer Scripts
|
||||||
|
|
||||||
|
Easy-to-use bash scripts for transferring files between your computer and Android device using ADB.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Android Debug Bridge (ADB) installed and in your PATH
|
||||||
|
- USB debugging enabled on your Android device
|
||||||
|
- Device connected via USB cable
|
||||||
|
|
||||||
|
### Installing ADB
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install android-tools-adb
|
||||||
|
```
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
brew install android-platform-tools
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
Download from [Android Developer website](https://developer.android.com/studio/releases/platform-tools)
|
||||||
|
|
||||||
|
## Scripts Overview
|
||||||
|
|
||||||
|
### 🚀 push.sh - Push files to Android
|
||||||
|
Transfer files from your computer to your Android device.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
# Interactive mode (recommended)
|
||||||
|
./push.sh
|
||||||
|
|
||||||
|
# Direct mode
|
||||||
|
./push.sh ~/Documents/file.pdf /sdcard/Download/
|
||||||
|
./push.sh ~/Pictures/vacation/ /sdcard/Pictures/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Interactive mode with common destination suggestions
|
||||||
|
- Automatic path validation
|
||||||
|
- Directory creation if needed
|
||||||
|
- Progress feedback
|
||||||
|
- Support for both files and directories
|
||||||
|
|
||||||
|
### 📥 pull.sh - Pull files from Android
|
||||||
|
Transfer files from your Android device to your computer.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
# Interactive mode (recommended)
|
||||||
|
./pull.sh
|
||||||
|
|
||||||
|
# Direct mode
|
||||||
|
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/
|
||||||
|
./pull.sh /sdcard/Download/document.pdf ~/Downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Browse Android filesystem interactively
|
||||||
|
- Quick access to common folders
|
||||||
|
- File count and size summary
|
||||||
|
- Creates destination directories automatically
|
||||||
|
- Support for both files and directories
|
||||||
|
|
||||||
|
### 📱 adb-transfer.sh - All-in-One Menu
|
||||||
|
Interactive menu system for all file transfer operations.
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
./adb-transfer.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Device status and information display
|
||||||
|
- Quick actions (backup photos, screenshots, WhatsApp media)
|
||||||
|
- Access to push/pull scripts
|
||||||
|
- Device information viewer
|
||||||
|
- Beautiful CLI interface
|
||||||
|
|
||||||
|
## Common Android Paths
|
||||||
|
|
||||||
|
- `/sdcard/Download/` - Downloads folder
|
||||||
|
- `/sdcard/DCIM/Camera/` - Camera photos and videos
|
||||||
|
- `/sdcard/Pictures/` - General pictures folder
|
||||||
|
- `/sdcard/Screenshots/` - Screenshots (varies by device)
|
||||||
|
- `/sdcard/WhatsApp/Media/` - WhatsApp media files
|
||||||
|
- `/sdcard/Documents/` - Documents folder
|
||||||
|
- `/sdcard/Music/` - Music files
|
||||||
|
- `/sdcard/Movies/` - Video files
|
||||||
|
|
||||||
|
## Making Scripts Executable
|
||||||
|
|
||||||
|
First time setup:
|
||||||
|
```bash
|
||||||
|
chmod +x push.sh pull.sh adb-transfer.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Backup all camera photos
|
||||||
|
```bash
|
||||||
|
./pull.sh /sdcard/DCIM/Camera/ ~/Pictures/android-backup/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Push multiple PDFs to Downloads
|
||||||
|
```bash
|
||||||
|
./push.sh ~/Documents/*.pdf /sdcard/Download/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive file browser
|
||||||
|
```bash
|
||||||
|
./pull.sh
|
||||||
|
# Then select option 1 to browse filesystem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Quick backup using menu
|
||||||
|
```bash
|
||||||
|
./adb-transfer.sh
|
||||||
|
# Select option 3 for quick actions
|
||||||
|
# Select option 1 to backup all camera photos
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No device connected" error
|
||||||
|
1. Check USB cable connection
|
||||||
|
2. Enable USB debugging: Settings → Developer options → USB debugging
|
||||||
|
3. Accept the authorization prompt on your phone
|
||||||
|
4. Try `adb devices` to verify connection
|
||||||
|
|
||||||
|
### "Permission denied" errors
|
||||||
|
- Some system directories require root access
|
||||||
|
- Stick to `/sdcard/` paths for normal usage
|
||||||
|
|
||||||
|
### Slow transfer speeds
|
||||||
|
- Use USB 3.0 ports and cables when possible
|
||||||
|
- Large files/directories take time - the scripts show progress
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. **Use interactive mode** - It's easier and prevents typos
|
||||||
|
2. **Backup regularly** - Use the quick actions menu for easy backups
|
||||||
|
3. **Check free space** - Use device info option to see available storage
|
||||||
|
4. **Organize transfers** - The scripts create timestamped folders for backups
|
||||||
|
|
||||||
|
## Created with 💕 by Naomi & Hikari
|
||||||
Executable
+225
@@ -0,0 +1,225 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Interactive ADB file transfer menu
|
||||||
|
# Author: Naomi Carrigan & Hikari 💕
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Color codes for pretty output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
MAGENTA='\033[0;35m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# ASCII art banner
|
||||||
|
banner() {
|
||||||
|
echo -e "${CYAN}"
|
||||||
|
echo "╔═══════════════════════════════════╗"
|
||||||
|
echo "║ 📱 ADB File Transfer 📱 ║"
|
||||||
|
echo "║ Made with 💕 by Hikari ║"
|
||||||
|
echo "╚═══════════════════════════════════╝"
|
||||||
|
echo -e "${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if ADB is available and device connected
|
||||||
|
check_adb_status() {
|
||||||
|
if ! command -v adb &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ ADB not found${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if adb devices | grep -q "device$"; then
|
||||||
|
local device=$(adb devices | grep "device$" | awk '{print $1}')
|
||||||
|
local model=$(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')
|
||||||
|
local android_version=$(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')
|
||||||
|
|
||||||
|
echo -e "${GREEN}✅ Device connected${NC}"
|
||||||
|
echo -e "${YELLOW}📱 Model: ${model:-Unknown}${NC}"
|
||||||
|
echo -e "${YELLOW}🤖 Android: ${android_version:-Unknown}${NC}"
|
||||||
|
echo -e "${YELLOW}🔌 ID: ${device}${NC}"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ No device connected${NC}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Quick actions menu
|
||||||
|
quick_actions() {
|
||||||
|
echo -e "\n${BLUE}⚡ Quick Actions${NC}"
|
||||||
|
echo "1) Pull all photos from camera"
|
||||||
|
echo "2) Pull all screenshots"
|
||||||
|
echo "3) Pull WhatsApp media"
|
||||||
|
echo "4) Push files to Downloads"
|
||||||
|
echo "5) Back to main menu"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Select action (1-5): " action
|
||||||
|
|
||||||
|
case $action in
|
||||||
|
1)
|
||||||
|
# Pull camera photos
|
||||||
|
echo -e "${CYAN}📸 Pulling camera photos...${NC}"
|
||||||
|
local backup_dir="$HOME/Pictures/android-camera-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$backup_dir"
|
||||||
|
|
||||||
|
if adb pull /sdcard/DCIM/Camera/ "$backup_dir/"; then
|
||||||
|
echo -e "${GREEN}✅ Photos backed up to: $backup_dir${NC}"
|
||||||
|
echo "Files: $(find "$backup_dir" -type f | wc -l)"
|
||||||
|
echo "Size: $(du -sh "$backup_dir" | cut -f1)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to pull photos${NC}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
# Pull screenshots
|
||||||
|
echo -e "${CYAN}📸 Pulling screenshots...${NC}"
|
||||||
|
local screenshot_dir="$HOME/Pictures/android-screenshots-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$screenshot_dir"
|
||||||
|
|
||||||
|
# Try multiple possible screenshot locations
|
||||||
|
local found=false
|
||||||
|
for path in "/sdcard/Pictures/Screenshots" "/sdcard/Screenshots" "/sdcard/DCIM/Screenshots"; do
|
||||||
|
if adb shell "test -d '$path' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
|
||||||
|
if adb pull "$path" "$screenshot_dir/"; then
|
||||||
|
found=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$found" == true ]]; then
|
||||||
|
echo -e "${GREEN}✅ Screenshots backed up to: $screenshot_dir${NC}"
|
||||||
|
echo "Files: $(find "$screenshot_dir" -type f | wc -l)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ No screenshots found or failed to pull${NC}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
# Pull WhatsApp media
|
||||||
|
echo -e "${CYAN}💬 Pulling WhatsApp media...${NC}"
|
||||||
|
local whatsapp_dir="$HOME/Pictures/whatsapp-backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$whatsapp_dir"
|
||||||
|
|
||||||
|
if adb shell "test -d '/sdcard/WhatsApp/Media' && echo 'exists'" 2>/dev/null | grep -q "exists"; then
|
||||||
|
if adb pull /sdcard/WhatsApp/Media/ "$whatsapp_dir/"; then
|
||||||
|
echo -e "${GREEN}✅ WhatsApp media backed up to: $whatsapp_dir${NC}"
|
||||||
|
echo "Files: $(find "$whatsapp_dir" -type f | wc -l)"
|
||||||
|
echo "Size: $(du -sh "$whatsapp_dir" | cut -f1)"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to pull WhatsApp media${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ WhatsApp media folder not found${NC}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
# Push to Downloads
|
||||||
|
echo -e "${CYAN}📤 Push files to Downloads folder${NC}"
|
||||||
|
read -p "Enter file/folder path to push: " -e local_path
|
||||||
|
local_path="${local_path/#\~/$HOME}"
|
||||||
|
|
||||||
|
if [[ -e "$local_path" ]]; then
|
||||||
|
if adb push "$local_path" /sdcard/Download/; then
|
||||||
|
echo -e "${GREEN}✅ Files pushed to Downloads folder${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to push files${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Path not found: $local_path${NC}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to continue..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Device info
|
||||||
|
device_info() {
|
||||||
|
echo -e "\n${BLUE}📱 Device Information${NC}"
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
echo -e "${YELLOW}Model:${NC} $(adb shell getprop ro.product.model 2>/dev/null | tr -d '\r\n')"
|
||||||
|
echo -e "${YELLOW}Manufacturer:${NC} $(adb shell getprop ro.product.manufacturer 2>/dev/null | tr -d '\r\n')"
|
||||||
|
echo -e "${YELLOW}Android Version:${NC} $(adb shell getprop ro.build.version.release 2>/dev/null | tr -d '\r\n')"
|
||||||
|
echo -e "${YELLOW}SDK Version:${NC} $(adb shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r\n')"
|
||||||
|
|
||||||
|
# Storage info
|
||||||
|
echo -e "\n${CYAN}Storage:${NC}"
|
||||||
|
adb shell df -h /sdcard | tail -n 1 | awk '{print " Used: " $3 " / " $2 " (" $5 ")"}'
|
||||||
|
|
||||||
|
# Battery info
|
||||||
|
echo -e "\n${CYAN}Battery:${NC}"
|
||||||
|
local battery_level=$(adb shell dumpsys battery | grep "level:" | awk '{print $2}')
|
||||||
|
local battery_status=$(adb shell dumpsys battery | grep "status:" | awk '{print $2}')
|
||||||
|
echo " Level: ${battery_level}%"
|
||||||
|
echo " Status: ${battery_status}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to continue..."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main menu
|
||||||
|
main_menu() {
|
||||||
|
while true; do
|
||||||
|
clear
|
||||||
|
banner
|
||||||
|
|
||||||
|
# Check device status
|
||||||
|
echo -e "${MAGENTA}Device Status:${NC}"
|
||||||
|
if ! check_adb_status; then
|
||||||
|
echo -e "\n${YELLOW}Please connect your Android device and enable USB debugging${NC}"
|
||||||
|
echo ""
|
||||||
|
read -p "Press Enter to retry or Ctrl+C to exit..."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}Main Menu:${NC}"
|
||||||
|
echo "1) Push files to Android"
|
||||||
|
echo "2) Pull files from Android"
|
||||||
|
echo "3) Quick actions"
|
||||||
|
echo "4) Device information"
|
||||||
|
echo "5) Exit"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Select option (1-5): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
echo -e "\n${CYAN}Starting push mode...${NC}\n"
|
||||||
|
bash "$(dirname "$0")/push.sh"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo -e "\n${CYAN}Starting pull mode...${NC}\n"
|
||||||
|
bash "$(dirname "$0")/pull.sh"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
quick_actions
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
device_info
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
echo -e "${GREEN}👋 Goodbye!${NC}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
sleep 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main menu
|
||||||
|
main_menu
|
||||||
Executable
+298
@@ -0,0 +1,298 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Pull files from Android device via ADB
|
||||||
|
# Author: Naomi Carrigan & Hikari 💕
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Color codes for pretty output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to display usage
|
||||||
|
usage() {
|
||||||
|
echo -e "${BLUE}Usage: $0 [android_source] [local_destination]${NC}"
|
||||||
|
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Common Android paths:"
|
||||||
|
echo " /sdcard/Download/ - Downloads folder"
|
||||||
|
echo " /sdcard/DCIM/ - Camera folder"
|
||||||
|
echo " /sdcard/Pictures/ - Pictures folder"
|
||||||
|
echo " /sdcard/WhatsApp/ - WhatsApp media"
|
||||||
|
echo " /sdcard/Screenshots/ - Screenshots"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 /sdcard/DCIM/Camera/ ~/Pictures/phone-backup/"
|
||||||
|
echo " $0 /sdcard/Download/document.pdf ~/Downloads/"
|
||||||
|
echo " $0"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check if ADB is installed and device is connected
|
||||||
|
check_adb() {
|
||||||
|
if ! command -v adb &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
|
||||||
|
echo "Please install Android Debug Bridge (ADB) first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if device is connected
|
||||||
|
if ! adb devices | grep -q "device$"; then
|
||||||
|
echo -e "${RED}❌ Error: No Android device connected${NC}"
|
||||||
|
echo "Please connect your device and enable USB debugging"
|
||||||
|
echo ""
|
||||||
|
echo "Current devices:"
|
||||||
|
adb devices
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to browse Android filesystem
|
||||||
|
browse_android_fs() {
|
||||||
|
local current_path="${1:-/sdcard/}"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
echo -e "${CYAN}📱 Current path: $current_path${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# List contents
|
||||||
|
echo -e "${YELLOW}Contents:${NC}"
|
||||||
|
local items=$(adb shell "ls -la '$current_path' 2>/dev/null" | tail -n +2 | awk '{print $NF}' | grep -v "^\.$" | grep -v "^\.\.$")
|
||||||
|
|
||||||
|
local i=1
|
||||||
|
local -a entries=()
|
||||||
|
|
||||||
|
# Add parent directory option if not at root
|
||||||
|
if [[ "$current_path" != "/" ]]; then
|
||||||
|
echo "0) .. (Go up)"
|
||||||
|
entries[0]=".."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# List items
|
||||||
|
while IFS= read -r item; do
|
||||||
|
if [[ -n "$item" ]]; then
|
||||||
|
# Check if directory
|
||||||
|
if adb shell "test -d '$current_path/$item' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
|
||||||
|
echo "$i) $item/"
|
||||||
|
else
|
||||||
|
echo "$i) $item"
|
||||||
|
fi
|
||||||
|
entries[$i]="$item"
|
||||||
|
((i++))
|
||||||
|
fi
|
||||||
|
done <<< "$items"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "s) Select this path"
|
||||||
|
echo "q) Quit"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Enter choice: " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
s|S)
|
||||||
|
echo "$current_path"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
q|Q)
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
0)
|
||||||
|
if [[ "$current_path" != "/" ]]; then
|
||||||
|
current_path=$(dirname "$current_path")
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
[0-9]*)
|
||||||
|
if [[ -n "${entries[$choice]:-}" ]]; then
|
||||||
|
local selected="${entries[$choice]}"
|
||||||
|
local new_path="$current_path/$selected"
|
||||||
|
# Clean up path
|
||||||
|
new_path=$(echo "$new_path" | sed 's|//|/|g')
|
||||||
|
|
||||||
|
# Check if it's a directory
|
||||||
|
if adb shell "test -d '$new_path' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
|
||||||
|
current_path="$new_path"
|
||||||
|
else
|
||||||
|
# It's a file, return it
|
||||||
|
echo "$new_path"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive mode
|
||||||
|
interactive_mode() {
|
||||||
|
echo -e "${BLUE}📲 ADB Pull - Interactive Mode${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Choose method
|
||||||
|
echo -e "${YELLOW}How would you like to select the source?${NC}"
|
||||||
|
echo "1) Browse Android filesystem"
|
||||||
|
echo "2) Enter path directly"
|
||||||
|
echo "3) Quick access to common folders"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Select method (1-3): " method
|
||||||
|
|
||||||
|
case $method in
|
||||||
|
1)
|
||||||
|
# Browse mode
|
||||||
|
if source_path=$(browse_android_fs); then
|
||||||
|
echo -e "${GREEN}Selected: $source_path${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Cancelled${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
# Direct path entry
|
||||||
|
read -p "Enter Android source path: " -e source_path
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
# Quick access
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Common locations:${NC}"
|
||||||
|
echo "1) /sdcard/DCIM/Camera/"
|
||||||
|
echo "2) /sdcard/Pictures/"
|
||||||
|
echo "3) /sdcard/Download/"
|
||||||
|
echo "4) /sdcard/WhatsApp/Media/"
|
||||||
|
echo "5) /sdcard/Screenshots/"
|
||||||
|
echo "6) /sdcard/Documents/"
|
||||||
|
echo "7) /sdcard/Music/"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Select location (1-7): " quick_choice
|
||||||
|
|
||||||
|
case $quick_choice in
|
||||||
|
1) source_path="/sdcard/DCIM/Camera/" ;;
|
||||||
|
2) source_path="/sdcard/Pictures/" ;;
|
||||||
|
3) source_path="/sdcard/Download/" ;;
|
||||||
|
4) source_path="/sdcard/WhatsApp/Media/" ;;
|
||||||
|
5) source_path="/sdcard/Screenshots/" ;;
|
||||||
|
6) source_path="/sdcard/Documents/" ;;
|
||||||
|
7) source_path="/sdcard/Music/" ;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}❌ Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}❌ Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Get destination
|
||||||
|
echo ""
|
||||||
|
read -p "Enter local destination path (default: current directory): " -e dest_path
|
||||||
|
dest_path="${dest_path:-.}"
|
||||||
|
dest_path="${dest_path/#\~/$HOME}"
|
||||||
|
|
||||||
|
# Pull the file/directory
|
||||||
|
pull_from_android "$source_path" "$dest_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to pull files from Android
|
||||||
|
pull_from_android() {
|
||||||
|
local source="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
# Validate source exists
|
||||||
|
if ! adb shell "test -e '$source' 2>/dev/null && echo 'exists'" | grep -q "exists"; then
|
||||||
|
echo -e "${RED}❌ Error: Source '$source' does not exist on device${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create destination directory if needed
|
||||||
|
if [[ ! -d "$dest" ]]; then
|
||||||
|
mkdir -p "$dest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}📥 Pulling from Android device...${NC}"
|
||||||
|
echo "Source: $source"
|
||||||
|
echo "Destination: $dest"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if source is directory
|
||||||
|
if adb shell "test -d '$source' 2>/dev/null && echo 'dir'" | grep -q "dir"; then
|
||||||
|
echo -e "${YELLOW}Pulling directory...${NC}"
|
||||||
|
|
||||||
|
# Count files for progress
|
||||||
|
local file_count=$(adb shell "find '$source' -type f 2>/dev/null | wc -l" | tr -d '\r\n')
|
||||||
|
echo "Found $file_count files to pull"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if adb pull "$source" "$dest"; then
|
||||||
|
echo -e "${GREEN}✅ Directory pulled successfully!${NC}"
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
local pulled_dir="$dest/$(basename "$source")"
|
||||||
|
if [[ -d "$pulled_dir" ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📊 Summary:${NC}"
|
||||||
|
echo "Location: $pulled_dir"
|
||||||
|
echo "Files: $(find "$pulled_dir" -type f | wc -l)"
|
||||||
|
echo "Total size: $(du -sh "$pulled_dir" | cut -f1)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to pull directory${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Single file
|
||||||
|
if adb pull "$source" "$dest"; then
|
||||||
|
echo -e "${GREEN}✅ File pulled successfully!${NC}"
|
||||||
|
|
||||||
|
# Show file info
|
||||||
|
local filename=$(basename "$source")
|
||||||
|
local full_path="$dest/$filename"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📄 File info:${NC}"
|
||||||
|
ls -lh "$full_path"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to pull file${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script
|
||||||
|
main() {
|
||||||
|
check_adb
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
# No arguments, run interactive mode
|
||||||
|
interactive_mode
|
||||||
|
elif [[ $# -eq 1 ]]; then
|
||||||
|
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Error: Missing destination path${NC}"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
elif [[ $# -eq 2 ]]; then
|
||||||
|
# Arguments provided
|
||||||
|
dest_path="${2/#\~/$HOME}"
|
||||||
|
pull_from_android "$1" "$dest_path"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Error: Too many arguments${NC}"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Push files to Android device via ADB
|
||||||
|
# Author: Naomi Carrigan & Hikari 💕
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Color codes for pretty output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Function to display usage
|
||||||
|
usage() {
|
||||||
|
echo -e "${BLUE}Usage: $0 [source_file/directory] [android_destination]${NC}"
|
||||||
|
echo -e "${YELLOW}If no arguments provided, interactive mode will start${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Common Android paths:"
|
||||||
|
echo " /sdcard/Download/ - Downloads folder"
|
||||||
|
echo " /sdcard/DCIM/ - Camera folder"
|
||||||
|
echo " /sdcard/Pictures/ - Pictures folder"
|
||||||
|
echo " /sdcard/Documents/ - Documents folder"
|
||||||
|
echo " /sdcard/Music/ - Music folder"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 photo.jpg /sdcard/Pictures/"
|
||||||
|
echo " $0 ~/Documents/file.pdf /sdcard/Download/"
|
||||||
|
echo " $0"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check if ADB is installed and device is connected
|
||||||
|
check_adb() {
|
||||||
|
if ! command -v adb &> /dev/null; then
|
||||||
|
echo -e "${RED}❌ Error: ADB is not installed or not in PATH${NC}"
|
||||||
|
echo "Please install Android Debug Bridge (ADB) first"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if device is connected
|
||||||
|
if ! adb devices | grep -q "device$"; then
|
||||||
|
echo -e "${RED}❌ Error: No Android device connected${NC}"
|
||||||
|
echo "Please connect your device and enable USB debugging"
|
||||||
|
echo ""
|
||||||
|
echo "Current devices:"
|
||||||
|
adb devices
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to validate Android path
|
||||||
|
validate_android_path() {
|
||||||
|
local path="$1"
|
||||||
|
|
||||||
|
# Check if path starts with /
|
||||||
|
if [[ ! "$path" =~ ^/ ]]; then
|
||||||
|
echo -e "${YELLOW}⚠️ Warning: Path doesn't start with /, prepending /sdcard/${NC}"
|
||||||
|
path="/sdcard/$path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if destination exists (create if it's a directory)
|
||||||
|
if [[ "$path" =~ /$ ]]; then
|
||||||
|
adb shell "mkdir -p '$path' 2>/dev/null || true"
|
||||||
|
else
|
||||||
|
# Check if parent directory exists
|
||||||
|
local parent_dir=$(dirname "$path")
|
||||||
|
adb shell "mkdir -p '$parent_dir' 2>/dev/null || true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Interactive mode
|
||||||
|
interactive_mode() {
|
||||||
|
echo -e "${BLUE}🚀 ADB Push - Interactive Mode${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get source file/directory
|
||||||
|
read -p "Enter source file/directory path: " -e source_path
|
||||||
|
|
||||||
|
# Expand tilde and validate source
|
||||||
|
source_path="${source_path/#\~/$HOME}"
|
||||||
|
|
||||||
|
if [[ ! -e "$source_path" ]]; then
|
||||||
|
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show common destinations
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Common Android destinations:${NC}"
|
||||||
|
echo "1) /sdcard/Download/"
|
||||||
|
echo "2) /sdcard/Pictures/"
|
||||||
|
echo "3) /sdcard/DCIM/"
|
||||||
|
echo "4) /sdcard/Documents/"
|
||||||
|
echo "5) /sdcard/Music/"
|
||||||
|
echo "6) /sdcard/Movies/"
|
||||||
|
echo "7) Custom path"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
read -p "Select destination (1-7): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1) dest_path="/sdcard/Download/" ;;
|
||||||
|
2) dest_path="/sdcard/Pictures/" ;;
|
||||||
|
3) dest_path="/sdcard/DCIM/" ;;
|
||||||
|
4) dest_path="/sdcard/Documents/" ;;
|
||||||
|
5) dest_path="/sdcard/Music/" ;;
|
||||||
|
6) dest_path="/sdcard/Movies/" ;;
|
||||||
|
7)
|
||||||
|
read -p "Enter custom destination path: " -e dest_path
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}❌ Invalid choice${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Push the file/directory
|
||||||
|
push_to_android "$source_path" "$dest_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to push files to Android
|
||||||
|
push_to_android() {
|
||||||
|
local source="$1"
|
||||||
|
local dest="$2"
|
||||||
|
|
||||||
|
# Validate destination path
|
||||||
|
dest=$(validate_android_path "$dest")
|
||||||
|
|
||||||
|
echo -e "${BLUE}📦 Pushing to Android device...${NC}"
|
||||||
|
echo "Source: $source"
|
||||||
|
echo "Destination: $dest"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if source is directory
|
||||||
|
if [[ -d "$source" ]]; then
|
||||||
|
echo -e "${YELLOW}Pushing directory...${NC}"
|
||||||
|
# For directories, adb push handles recursion automatically
|
||||||
|
if adb push "$source" "$dest"; then
|
||||||
|
echo -e "${GREEN}✅ Directory pushed successfully!${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to push directory${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Single file
|
||||||
|
if adb push "$source" "$dest"; then
|
||||||
|
echo -e "${GREEN}✅ File pushed successfully!${NC}"
|
||||||
|
|
||||||
|
# Show file info on device
|
||||||
|
if [[ "$dest" =~ /$ ]]; then
|
||||||
|
# Destination is a directory
|
||||||
|
filename=$(basename "$source")
|
||||||
|
full_path="${dest}${filename}"
|
||||||
|
else
|
||||||
|
# Destination is a file
|
||||||
|
full_path="$dest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}📱 File on device:${NC}"
|
||||||
|
adb shell "ls -lh '$full_path'" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Failed to push file${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main script
|
||||||
|
main() {
|
||||||
|
check_adb
|
||||||
|
|
||||||
|
if [[ $# -eq 0 ]]; then
|
||||||
|
# No arguments, run interactive mode
|
||||||
|
interactive_mode
|
||||||
|
elif [[ $# -eq 1 ]]; then
|
||||||
|
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Error: Missing destination path${NC}"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
elif [[ $# -eq 2 ]]; then
|
||||||
|
# Arguments provided
|
||||||
|
source_path="${1/#\~/$HOME}"
|
||||||
|
|
||||||
|
if [[ ! -e "$source_path" ]]; then
|
||||||
|
echo -e "${RED}❌ Error: Source '$source_path' does not exist${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
push_to_android "$source_path" "$2"
|
||||||
|
else
|
||||||
|
echo -e "${RED}❌ Error: Too many arguments${NC}"
|
||||||
|
usage
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
# Crowdin
|
# Crowdin
|
||||||
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID"
|
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
|
||||||
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url"
|
CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
|
||||||
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token"
|
CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
|
||||||
|
|
||||||
# Github
|
# Github
|
||||||
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token"
|
GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
|
||||||
|
|
||||||
# Discord
|
# Discord
|
||||||
DISCORD_TOKEN="op://Environment Variables - Development/Scripts/Discord Token"
|
DISCORD_TOKEN="op://Environment Variables - Development/Ephemere/Discord Token"
|
||||||
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
|
DISCORD_CLIENT_ID="op://Private/Guild Counter/client id"
|
||||||
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
|
DISCORD_CLIENT_SECRET="op://Private/Guild Counter/client secret"
|
||||||
|
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
|
||||||
|
|
||||||
# AWS
|
# AWS
|
||||||
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
|
AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
|
||||||
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
|
AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
|
||||||
|
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
GITEA_TOKEN="op://Private/Gitea/token"
|
GITEA_TOKEN="op://Private/Gitea/token"
|
||||||
|
|
||||||
# DefectDojo
|
# DefectDojo
|
||||||
DOJO_TOKEN="op://Private/DefectDojo/token"
|
DOJO_TOKEN="op://Private/DefectDojo/token"
|
||||||
|
|
||||||
|
# Discourse
|
||||||
|
DISCOURSE_URL="op://Environment Variables - Development/Ephemere/Discourse URL"
|
||||||
|
DISCOURSE_API_KEY="op://Environment Variables - Development/Ephemere/Discourse Key"
|
||||||
|
DISCOURSE_API_USERNAME="op://Environment Variables - Development/Ephemere/Discourse Username"
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# uv
|
||||||
|
.uv/
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
.pytest_cache/
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.pylintrc
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
.PHONY: help install lint format format-check clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "Python project commands:"
|
||||||
|
@echo " make install - Set up virtual environment and install dependencies"
|
||||||
|
@echo " make lint - Run Ruff linter"
|
||||||
|
@echo " make format - Format code with Ruff"
|
||||||
|
@echo " make format-check - Check formatting without modifying"
|
||||||
|
@echo " make clean - Clean Python artifacts"
|
||||||
|
|
||||||
|
install:
|
||||||
|
uv venv
|
||||||
|
uv pip install -r requirements.txt
|
||||||
|
|
||||||
|
lint:
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
format:
|
||||||
|
uv run ruff format .
|
||||||
|
|
||||||
|
format-check:
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .venv
|
||||||
|
rm -rf .ruff_cache
|
||||||
|
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Add GitHub users to their appropriate teams in nhcarrigan-spring-2026-cohort org"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Load team assignments and Discord to GitHub mappings
|
||||||
|
with open("team_assignments.json") as f:
|
||||||
|
teams = json.load(f)
|
||||||
|
|
||||||
|
with open("discord_to_github.json") as f:
|
||||||
|
discord_to_github = json.load(f)
|
||||||
|
|
||||||
|
# Map team names to GitHub team slugs
|
||||||
|
team_name_to_slug = {
|
||||||
|
"Jade Jasmine": "jade-jasmine",
|
||||||
|
"Crimson Dahlia": "crimson-dahlia",
|
||||||
|
"Rose Camellia": "rose-camellia",
|
||||||
|
"Amber Wisteria": "amber-wisteria",
|
||||||
|
"Ivory Orchid": "ivory-orchid",
|
||||||
|
"Teal Iris": "teal-iris",
|
||||||
|
"Peach Gardenia": "peach-gardenia",
|
||||||
|
"Violet Carnation": "violet-carnation",
|
||||||
|
"Azure Lotus": "azure-lotus",
|
||||||
|
"Coral Sunflower": "coral-sunflower",
|
||||||
|
"Indigo Tulip": "indigo-tulip",
|
||||||
|
"Scarlet Hydrangea": "scarlet-hydrangea",
|
||||||
|
"Mint Narcissus": "mint-narcissus",
|
||||||
|
"Sage Marigold": "sage-marigold",
|
||||||
|
}
|
||||||
|
|
||||||
|
org = "nhcarrigan-spring-2026-cohort"
|
||||||
|
total_added = 0
|
||||||
|
total_skipped = 0
|
||||||
|
total_errors = 0
|
||||||
|
|
||||||
|
|
||||||
|
def add_user_to_team(username, team_slug, role="member"):
|
||||||
|
"""Add a user to a GitHub team"""
|
||||||
|
try:
|
||||||
|
# Check if user is already a member
|
||||||
|
check_cmd = (
|
||||||
|
f"gh api orgs/{org}/teams/{team_slug}/memberships/{username} 2>/dev/null"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
check_cmd, shell=True, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" ✓ {username} is already in {team_slug}")
|
||||||
|
return "already_member"
|
||||||
|
|
||||||
|
# Add user to team
|
||||||
|
add_cmd = (
|
||||||
|
f"gh api -X PUT orgs/{org}/teams/{team_slug}/memberships/{username} "
|
||||||
|
f"-f role={role}"
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
add_cmd, shell=True, capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
print(f" ✓ Added {username} to {team_slug} as {role}")
|
||||||
|
return "added"
|
||||||
|
else:
|
||||||
|
print(f" ✗ Failed to add {username} to {team_slug}: {result.stderr}")
|
||||||
|
return "error"
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ✗ Error adding {username} to {team_slug}: {str(e)}")
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
|
||||||
|
# Process each team
|
||||||
|
for team_data in teams:
|
||||||
|
team_name = team_data["name"]
|
||||||
|
team_slug = team_name_to_slug[team_name]
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"Processing Team {team_data['team_id']}: {team_name}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
# Add leaders to leaders team
|
||||||
|
leaders_team_slug = f"{team_slug}-leaders"
|
||||||
|
print(f"\nAdding leaders to {leaders_team_slug}:")
|
||||||
|
|
||||||
|
for discord_id in team_data["leaders"]:
|
||||||
|
github_username = discord_to_github.get(discord_id)
|
||||||
|
if not github_username or github_username == "nhcarrigan-2025-hackathon":
|
||||||
|
print(
|
||||||
|
f" ⚠ Skipping Discord ID {discord_id} - "
|
||||||
|
"Missing/invalid GitHub username"
|
||||||
|
)
|
||||||
|
total_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = add_user_to_team(github_username, leaders_team_slug, "member")
|
||||||
|
if result == "added":
|
||||||
|
total_added += 1
|
||||||
|
elif result == "error":
|
||||||
|
total_errors += 1
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# Add participants to main team
|
||||||
|
print(f"\nAdding participants to {team_slug}:")
|
||||||
|
|
||||||
|
for discord_id in team_data["participants"]:
|
||||||
|
github_username = discord_to_github.get(discord_id)
|
||||||
|
if not github_username or github_username == "nhcarrigan-2025-hackathon":
|
||||||
|
print(
|
||||||
|
f" ⚠ Skipping Discord ID {discord_id} - "
|
||||||
|
"Missing/invalid GitHub username"
|
||||||
|
)
|
||||||
|
total_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = add_user_to_team(github_username, team_slug, "member")
|
||||||
|
if result == "added":
|
||||||
|
total_added += 1
|
||||||
|
elif result == "error":
|
||||||
|
total_errors += 1
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print("Summary:")
|
||||||
|
print(f"- Total users added: {total_added}")
|
||||||
|
print(f"- Total users skipped (missing GitHub): {total_skipped}")
|
||||||
|
print(f"- Total errors: {total_errors}")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
|
||||||
|
UTC_BLOCKS = {
|
||||||
|
"mornings": (6, 12), # 06:00 - 12:00 UTC
|
||||||
|
"afternoons": (12, 18), # 12:00 - 18:00 UTC
|
||||||
|
"evenings": (18, 24), # 18:00 - 00:00 UTC
|
||||||
|
"nights": (0, 6), # 00:00 - 06:00 UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_utc_offset(timezone_str: str) -> float:
|
||||||
|
"""Extract UTC offset from timezone string like 'America/New_York (UTC-5)'"""
|
||||||
|
match = re.search(r"UTC([+-]?\d+(?::\d+)?)", timezone_str)
|
||||||
|
if match:
|
||||||
|
offset_str = match.group(1)
|
||||||
|
if ":" in offset_str:
|
||||||
|
parts = offset_str.split(":")
|
||||||
|
hours = int(parts[0])
|
||||||
|
minutes = int(parts[1]) if len(parts) > 1 else 0
|
||||||
|
if hours < 0:
|
||||||
|
return hours - minutes / 60
|
||||||
|
return hours + minutes / 60
|
||||||
|
return float(offset_str)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_slots(time_str: str) -> list[tuple[int, int]]:
|
||||||
|
"""Parse time slots like '17:00-18:00' or '07:00-08:00; 19:00-20:00'"""
|
||||||
|
slots = []
|
||||||
|
if not time_str or time_str.lower() in ["n/a", "na", ""]:
|
||||||
|
return slots
|
||||||
|
|
||||||
|
parts = time_str.split(";")
|
||||||
|
for part in parts:
|
||||||
|
part = part.strip()
|
||||||
|
match = re.search(r"(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})", part)
|
||||||
|
if match:
|
||||||
|
start_hour = int(match.group(1))
|
||||||
|
end_hour = int(match.group(3))
|
||||||
|
slots.append((start_hour, end_hour))
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
|
def local_hour_to_utc(local_hour: int, utc_offset: float) -> int:
|
||||||
|
"""Convert local hour to UTC hour"""
|
||||||
|
utc_hour = local_hour - utc_offset
|
||||||
|
return int(utc_hour) % 24
|
||||||
|
|
||||||
|
|
||||||
|
def get_utc_blocks_for_hour(utc_hour: int) -> list[str]:
|
||||||
|
"""Determine which UTC block(s) an hour falls into"""
|
||||||
|
blocks = []
|
||||||
|
for block_name, (start, end) in UTC_BLOCKS.items():
|
||||||
|
if block_name == "nights":
|
||||||
|
if utc_hour >= 0 and utc_hour < 6:
|
||||||
|
blocks.append(block_name)
|
||||||
|
elif block_name == "evenings":
|
||||||
|
if utc_hour >= 18 and utc_hour < 24:
|
||||||
|
blocks.append(block_name)
|
||||||
|
elif utc_hour >= start and utc_hour < end:
|
||||||
|
blocks.append(block_name)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_applicant_availability(timezone_str: str, day_slots: dict) -> dict:
|
||||||
|
"""Analyze availability for one applicant"""
|
||||||
|
utc_offset = parse_utc_offset(timezone_str)
|
||||||
|
|
||||||
|
block_counts = defaultdict(int)
|
||||||
|
all_utc_hours = set()
|
||||||
|
|
||||||
|
for day in DAYS:
|
||||||
|
slots = day_slots.get(day, [])
|
||||||
|
for start_hour, end_hour in slots:
|
||||||
|
for hour in range(start_hour, end_hour):
|
||||||
|
utc_hour = local_hour_to_utc(hour, utc_offset)
|
||||||
|
all_utc_hours.add(utc_hour)
|
||||||
|
blocks = get_utc_blocks_for_hour(utc_hour)
|
||||||
|
for block in blocks:
|
||||||
|
block_counts[block] += 1
|
||||||
|
|
||||||
|
available_blocks = []
|
||||||
|
for block in ["mornings", "afternoons", "evenings", "nights"]:
|
||||||
|
if block_counts[block] >= 3:
|
||||||
|
available_blocks.append(block)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"utc_offset": utc_offset,
|
||||||
|
"timezone": timezone_str,
|
||||||
|
"available_blocks": available_blocks,
|
||||||
|
"block_counts": dict(block_counts),
|
||||||
|
"total_unique_utc_hours": len(all_utc_hours),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_table_md() -> list[dict]:
|
||||||
|
"""Parse table.md and extract availability data"""
|
||||||
|
with open("table.md") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
lines = content.strip().split("\n")
|
||||||
|
|
||||||
|
header_idx = None
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("| Discord ID"):
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if header_idx is None:
|
||||||
|
raise ValueError("Could not find table header")
|
||||||
|
|
||||||
|
headers = [h.strip() for h in lines[header_idx].split("|")[1:-1]]
|
||||||
|
|
||||||
|
applicants = []
|
||||||
|
for line in lines[header_idx + 2 :]:
|
||||||
|
if not line.startswith("|"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cells = [c.strip() for c in line.split("|")[1:-1]]
|
||||||
|
if len(cells) < len(headers):
|
||||||
|
continue
|
||||||
|
|
||||||
|
row = dict(zip(headers, cells))
|
||||||
|
applicants.append(row)
|
||||||
|
|
||||||
|
return applicants
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open("discord_verification.json") as f:
|
||||||
|
verification = json.load(f)
|
||||||
|
|
||||||
|
verified_ids = {v[0] for v in verification["verified"]}
|
||||||
|
print(f"Verified applicants: {len(verified_ids)}")
|
||||||
|
|
||||||
|
applicants = parse_table_md()
|
||||||
|
print(f"Total applicants in table: {len(applicants)}")
|
||||||
|
|
||||||
|
availability_results = []
|
||||||
|
|
||||||
|
for applicant in applicants:
|
||||||
|
discord_id = applicant.get("Discord ID", "")
|
||||||
|
if discord_id not in verified_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
timezone = applicant.get("Timezone", "")
|
||||||
|
|
||||||
|
day_slots = {}
|
||||||
|
for day in DAYS:
|
||||||
|
time_str = applicant.get(day, "")
|
||||||
|
day_slots[day] = parse_time_slots(time_str)
|
||||||
|
|
||||||
|
analysis = analyze_applicant_availability(timezone, day_slots)
|
||||||
|
|
||||||
|
availability_results.append(
|
||||||
|
{
|
||||||
|
"discord_id": discord_id,
|
||||||
|
"timezone": timezone,
|
||||||
|
"utc_offset": analysis["utc_offset"],
|
||||||
|
"available_blocks": analysis["available_blocks"],
|
||||||
|
"block_counts": analysis["block_counts"],
|
||||||
|
"total_unique_utc_hours": analysis["total_unique_utc_hours"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with open("availability_analysis.json", "w") as f:
|
||||||
|
json.dump(availability_results, f, indent=2)
|
||||||
|
|
||||||
|
block_distribution = defaultdict(int)
|
||||||
|
for result in availability_results:
|
||||||
|
for block in result["available_blocks"]:
|
||||||
|
block_distribution[block] += 1
|
||||||
|
|
||||||
|
print("\n=== AVAILABILITY ANALYSIS COMPLETE ===")
|
||||||
|
print(f"Analyzed: {len(availability_results)} applicants")
|
||||||
|
print("\nBlock Distribution (applicants available in each block):")
|
||||||
|
for block in ["mornings", "afternoons", "evenings", "nights"]:
|
||||||
|
print(f" {block.capitalize()}: {block_distribution[block]}")
|
||||||
|
|
||||||
|
no_blocks = sum(1 for r in availability_results if not r["available_blocks"])
|
||||||
|
print(f"\nApplicants with no clear block availability: {no_blocks}")
|
||||||
|
|
||||||
|
print("\nResults saved to availability_analysis.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Assign the Cohort role to all 155 participants.
|
||||||
|
Respects Discord rate limits with proper backoff and retry logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
COHORT_ROLE_ID = "1464314780935258112"
|
||||||
|
|
||||||
|
BASE_URL = "https://discord.com/api/v10"
|
||||||
|
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"}
|
||||||
|
|
||||||
|
|
||||||
|
def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool:
|
||||||
|
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}"
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
response = requests.put(url, headers=HEADERS)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
return True
|
||||||
|
elif response.status_code == 429:
|
||||||
|
# Check headers first, fall back to JSON body
|
||||||
|
retry_after = response.headers.get("Retry-After")
|
||||||
|
if retry_after is None:
|
||||||
|
retry_after = response.headers.get("X-RateLimit-Reset-After")
|
||||||
|
if retry_after is None:
|
||||||
|
try:
|
||||||
|
retry_after = response.json().get("retry_after", 1)
|
||||||
|
except Exception:
|
||||||
|
retry_after = 1
|
||||||
|
retry_after = float(retry_after)
|
||||||
|
print(f" Rate limited! Waiting {retry_after:.2f}s before retry...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
else:
|
||||||
|
print(f" Error {response.status_code}: {response.text}")
|
||||||
|
backoff_time = (2**attempt) * 0.5
|
||||||
|
print(f" Retrying in {backoff_time:.2f}s...")
|
||||||
|
time.sleep(backoff_time)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open("team_assignments.json") as f:
|
||||||
|
teams = json.load(f)
|
||||||
|
|
||||||
|
all_users = []
|
||||||
|
for team in teams:
|
||||||
|
all_users.extend(team["leaders"])
|
||||||
|
all_users.extend(team["participants"])
|
||||||
|
|
||||||
|
unique_users = list(dict.fromkeys(all_users))
|
||||||
|
|
||||||
|
print(f"Assigning Cohort role to {len(unique_users)} users...")
|
||||||
|
print(f"Role ID: {COHORT_ROLE_ID}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for i, user_id in enumerate(unique_users, 1):
|
||||||
|
print(f"[{i}/{len(unique_users)}] Assigning to {user_id}...", end=" ")
|
||||||
|
|
||||||
|
if assign_role_with_retry(user_id, COHORT_ROLE_ID):
|
||||||
|
print("✓")
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
print("✗ FAILED")
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# Small delay between requests to be nice to the API
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Assign team-specific roles to all 155 participants.
|
||||||
|
Respects Discord rate limits with proper backoff and retry logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
|
||||||
|
BASE_URL = "https://discord.com/api/v10"
|
||||||
|
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Length": "0"}
|
||||||
|
|
||||||
|
TEAM_ROLE_IDS = {
|
||||||
|
"Jade Jasmine": "1464314923780931677",
|
||||||
|
"Crimson Dahlia": "1464315093402784015",
|
||||||
|
"Rose Camellia": "1464315098452726106",
|
||||||
|
"Amber Wisteria": "1464315105264275600",
|
||||||
|
"Ivory Orchid": "1464315109873684593",
|
||||||
|
"Teal Iris": "1464315114378498152",
|
||||||
|
"Peach Gardenia": "1464315118904152107",
|
||||||
|
"Violet Carnation": "1464315124251754559",
|
||||||
|
"Azure Lotus": "1464315128437801177",
|
||||||
|
"Coral Sunflower": "1464315132896088168",
|
||||||
|
"Indigo Tulip": "1464315138428633241",
|
||||||
|
"Scarlet Hydrangea": "1464315142710890520",
|
||||||
|
"Mint Narcissus": "1464315149203804405",
|
||||||
|
"Sage Marigold": "1464315153599299803",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assign_role_with_retry(user_id: str, role_id: str, max_retries: int = 5) -> bool:
|
||||||
|
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{user_id}/roles/{role_id}"
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
response = requests.put(url, headers=HEADERS)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
return True
|
||||||
|
elif response.status_code == 429:
|
||||||
|
# Check headers first, fall back to JSON body
|
||||||
|
retry_after = response.headers.get("Retry-After")
|
||||||
|
if retry_after is None:
|
||||||
|
retry_after = response.headers.get("X-RateLimit-Reset-After")
|
||||||
|
if retry_after is None:
|
||||||
|
try:
|
||||||
|
retry_after = response.json().get("retry_after", 1)
|
||||||
|
except Exception:
|
||||||
|
retry_after = 1
|
||||||
|
retry_after = float(retry_after)
|
||||||
|
print(f" Rate limited! Waiting {retry_after:.2f}s before retry...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
else:
|
||||||
|
print(f" Error {response.status_code}: {response.text}")
|
||||||
|
backoff_time = (2**attempt) * 0.5
|
||||||
|
print(f" Retrying in {backoff_time:.2f}s...")
|
||||||
|
time.sleep(backoff_time)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with open("team_assignments.json") as f:
|
||||||
|
teams = json.load(f)
|
||||||
|
|
||||||
|
print(f"Assigning team roles to {len(teams)} teams...")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
total_success = 0
|
||||||
|
total_fail = 0
|
||||||
|
|
||||||
|
for team in teams:
|
||||||
|
team_name = team["name"]
|
||||||
|
role_id = TEAM_ROLE_IDS[team_name]
|
||||||
|
all_members = team["leaders"] + team["participants"]
|
||||||
|
|
||||||
|
print(f"\n[{team_name}] Assigning role to {len(all_members)} members...")
|
||||||
|
|
||||||
|
for user_id in all_members:
|
||||||
|
print(f" {user_id}...", end=" ")
|
||||||
|
|
||||||
|
if assign_role_with_retry(user_id, role_id):
|
||||||
|
print("✓")
|
||||||
|
total_success += 1
|
||||||
|
else:
|
||||||
|
print("✗ FAILED")
|
||||||
|
total_fail += 1
|
||||||
|
|
||||||
|
# Small delay between requests to be nice to the API
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"Complete! Success: {total_success}, Failed: {total_fail}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create private voice channels for each team in the specified category.
|
||||||
|
Each channel will be visible and joinable only by the team's role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Discord configuration
|
||||||
|
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
CATEGORY_ID = "1464311813620502638"
|
||||||
|
BASE_URL = "https://discord.com/api/v10"
|
||||||
|
|
||||||
|
# Team role IDs from send_team_messages.py
|
||||||
|
TEAMS = {
|
||||||
|
"Jade Jasmine": {"role_id": "1464314923780931677"},
|
||||||
|
"Crimson Dahlia": {"role_id": "1464315093402784015"},
|
||||||
|
"Rose Camellia": {"role_id": "1464315098452726106"},
|
||||||
|
"Amber Wisteria": {"role_id": "1464315105264275600"},
|
||||||
|
"Ivory Orchid": {"role_id": "1464315109873684593"},
|
||||||
|
"Teal Iris": {"role_id": "1464315114378498152"},
|
||||||
|
"Peach Gardenia": {"role_id": "1464315118904152107"},
|
||||||
|
"Violet Carnation": {"role_id": "1464315124251754559"},
|
||||||
|
"Azure Lotus": {"role_id": "1464315128437801177"},
|
||||||
|
"Coral Sunflower": {"role_id": "1464315132896088168"},
|
||||||
|
"Indigo Tulip": {"role_id": "1464315138428633241"},
|
||||||
|
"Scarlet Hydrangea": {"role_id": "1464315142710890520"},
|
||||||
|
"Mint Narcissus": {"role_id": "1464315149203804405"},
|
||||||
|
"Sage Marigold": {"role_id": "1464315153599299803"},
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def create_voice_channel(team_name, role_id):
|
||||||
|
"""Create a voice channel with specific permissions"""
|
||||||
|
url = f"{BASE_URL}/guilds/{GUILD_ID}/channels"
|
||||||
|
|
||||||
|
# Permission overwrites:
|
||||||
|
# - Deny @everyone from viewing and connecting
|
||||||
|
# - Allow the team role to view and connect
|
||||||
|
permission_overwrites = [
|
||||||
|
{
|
||||||
|
"id": GUILD_ID, # @everyone role ID is same as guild ID
|
||||||
|
"type": 0, # Role type
|
||||||
|
"deny": "1049600", # VIEW_CHANNEL (1 << 10) + CONNECT (1 << 20) = 1049600
|
||||||
|
"allow": "0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": role_id, # Team role
|
||||||
|
"type": 0, # Role type
|
||||||
|
"allow": "1049600", # VIEW_CHANNEL + CONNECT
|
||||||
|
"deny": "0",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"name": f"{team_name} Voice",
|
||||||
|
"type": 2, # Voice channel
|
||||||
|
"parent_id": CATEGORY_ID,
|
||||||
|
"permission_overwrites": permission_overwrites,
|
||||||
|
"user_limit": 0, # No user limit
|
||||||
|
"bitrate": 64000, # 64 kbps
|
||||||
|
"rtc_region": None, # Auto-select region
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=HEADERS, json=data)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
channel_data = response.json()
|
||||||
|
print(f"✓ Created voice channel for {team_name} (ID: {channel_data['id']})")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 429:
|
||||||
|
retry_after = float(response.headers.get("Retry-After", 1))
|
||||||
|
print(f" Rate limited! Waiting {retry_after:.2f}s...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
return create_voice_channel(team_name, role_id)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"✗ Failed to create channel for {team_name}: "
|
||||||
|
f"{response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Creating private voice channels for {len(TEAMS)} teams...")
|
||||||
|
print(f"Category ID: {CATEGORY_ID}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
created_channels = []
|
||||||
|
|
||||||
|
for team_name, team_data in TEAMS.items():
|
||||||
|
if create_voice_channel(team_name, team_data["role_id"]):
|
||||||
|
success_count += 1
|
||||||
|
created_channels.append(team_name)
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# Small delay between requests
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
|
||||||
|
|
||||||
|
if success_count == len(TEAMS):
|
||||||
|
print("\n✅ All team voice channels have been created!")
|
||||||
|
print("\nEach voice channel:")
|
||||||
|
print(" - Is named '[Team Name] Voice'")
|
||||||
|
print(" - Is only visible to members with that team's role")
|
||||||
|
print(" - Can only be joined by members with that team's role")
|
||||||
|
print(" - Has no user limit")
|
||||||
|
print(" - Uses 64 kbps bitrate")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+251
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Discord Team Activity Checker
|
||||||
|
Checks for team members who haven't sent messages in their channels within 36 hours
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DISCORD_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
|
INACTIVE_THRESHOLD_HOURS = 36
|
||||||
|
|
||||||
|
# Load team assignments from file
|
||||||
|
with open("team_assignments.json") as f:
|
||||||
|
team_data = json.load(f)
|
||||||
|
|
||||||
|
# Build TEAMS dictionary with channel IDs and member lists
|
||||||
|
TEAMS = {}
|
||||||
|
CHANNEL_IDS = {
|
||||||
|
"Jade Jasmine": "1464316501573107886",
|
||||||
|
"Crimson Dahlia": "1464316744909852682",
|
||||||
|
"Rose Camellia": "1464316751268286611",
|
||||||
|
"Amber Wisteria": "1464316761410113641",
|
||||||
|
"Ivory Orchid": "1464316770889240730",
|
||||||
|
"Teal Iris": "1464316776459407448",
|
||||||
|
"Peach Gardenia": "1464316785040953543",
|
||||||
|
"Violet Carnation": "1464316805261824032",
|
||||||
|
"Azure Lotus": "1464316814455472139",
|
||||||
|
"Coral Sunflower": "1464316819711066263",
|
||||||
|
"Indigo Tulip": "1464316826384072925",
|
||||||
|
"Scarlet Hydrangea": "1464316839306985506",
|
||||||
|
"Mint Narcissus": "1464316844251807952",
|
||||||
|
"Sage Marigold": "1464316850669093040",
|
||||||
|
}
|
||||||
|
|
||||||
|
for team in team_data:
|
||||||
|
team_name = team["name"]
|
||||||
|
if team_name in CHANNEL_IDS:
|
||||||
|
TEAMS[team_name] = {
|
||||||
|
"channel_id": CHANNEL_IDS[team_name],
|
||||||
|
"member_ids": team["leaders"] + team["participants"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordActivityChecker:
|
||||||
|
def __init__(self, token: str):
|
||||||
|
self.token = token
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bot {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
self.session = None
|
||||||
|
self.cutoff_time = datetime.now(timezone.utc) - timedelta(
|
||||||
|
hours=INACTIVE_THRESHOLD_HOURS
|
||||||
|
)
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
self.session = aiohttp.ClientSession()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def get_user_info(self, user_id: str) -> dict:
|
||||||
|
"""Get information about a specific user"""
|
||||||
|
url = f"{DISCORD_API_BASE}/users/{user_id}"
|
||||||
|
|
||||||
|
async with self.session.get(url, headers=self.headers) as response:
|
||||||
|
if response.status == 429:
|
||||||
|
# Rate limited, wait a bit
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
return None
|
||||||
|
elif response.status != 200:
|
||||||
|
print(f"Failed to get user {user_id}: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_data = await response.json()
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
async def get_recent_message_authors(self, channel_id: str) -> set[str]:
|
||||||
|
"""Get user IDs of everyone who sent a message in the last 36 hours"""
|
||||||
|
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages?limit=100"
|
||||||
|
active_users = set()
|
||||||
|
|
||||||
|
async with self.session.get(url, headers=self.headers) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
print(
|
||||||
|
f"Failed to get messages for channel {channel_id}: "
|
||||||
|
f"{response.status}"
|
||||||
|
)
|
||||||
|
return active_users
|
||||||
|
|
||||||
|
messages = await response.json()
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
# Parse message timestamp
|
||||||
|
timestamp = datetime.fromisoformat(
|
||||||
|
message["timestamp"].replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
|
||||||
|
# If message is within our threshold, add the author
|
||||||
|
if timestamp > self.cutoff_time:
|
||||||
|
active_users.add(message["author"]["id"])
|
||||||
|
else:
|
||||||
|
# Messages are returned newest first, so we can break early
|
||||||
|
break
|
||||||
|
|
||||||
|
return active_users
|
||||||
|
|
||||||
|
async def send_message_to_channel(self, channel_id: str, content: str) -> bool:
|
||||||
|
"""Send a message to a Discord channel"""
|
||||||
|
url = f"{DISCORD_API_BASE}/channels/{channel_id}/messages"
|
||||||
|
data = {"content": content}
|
||||||
|
|
||||||
|
async with self.session.post(url, headers=self.headers, json=data) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
print(f"✅ Message sent to channel {channel_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"❌ Failed to send message to channel {channel_id}: "
|
||||||
|
f"{response.status}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def check_team_activity(
|
||||||
|
self, team_name: str, channel_id: str, member_ids: list[str]
|
||||||
|
) -> dict:
|
||||||
|
"""Check activity for a specific team"""
|
||||||
|
print(f"Checking {team_name}...")
|
||||||
|
|
||||||
|
# Get active users in the channel
|
||||||
|
active_users = await self.get_recent_message_authors(channel_id)
|
||||||
|
|
||||||
|
# Find inactive members
|
||||||
|
inactive_members = []
|
||||||
|
total_members = len(member_ids)
|
||||||
|
|
||||||
|
for member_id in member_ids:
|
||||||
|
if member_id not in active_users:
|
||||||
|
# Get user information
|
||||||
|
user_info = await self.get_user_info(member_id)
|
||||||
|
if user_info:
|
||||||
|
# Skip bots
|
||||||
|
if user_info.get("bot", False):
|
||||||
|
total_members -= 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
inactive_members.append(
|
||||||
|
{"id": member_id, "mention": f"<@{member_id}>"}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# If we can't get user info, still track them as inactive
|
||||||
|
inactive_members.append(
|
||||||
|
{"id": member_id, "mention": f"<@{member_id}>"}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"team": team_name,
|
||||||
|
"total_members": total_members,
|
||||||
|
"inactive_members": inactive_members,
|
||||||
|
"inactive_count": len(inactive_members),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Main function to check all teams"""
|
||||||
|
print(
|
||||||
|
f"Discord Activity Checker - Checking for inactivity over "
|
||||||
|
f"{INACTIVE_THRESHOLD_HOURS} hours"
|
||||||
|
)
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(hours=INACTIVE_THRESHOLD_HOURS)
|
||||||
|
print(f"Cutoff time: {cutoff}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
async with DiscordActivityChecker(DISCORD_TOKEN) as checker:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for team_name, team_info in TEAMS.items():
|
||||||
|
channel_id = team_info["channel_id"]
|
||||||
|
member_ids = team_info["member_ids"]
|
||||||
|
|
||||||
|
result = await checker.check_team_activity(
|
||||||
|
team_name, channel_id, member_ids
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
# Display results
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
print("INACTIVE MEMBERS REPORT")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
# Check if user wants to send messages (via command line arg)
|
||||||
|
send_messages = "--send" in sys.argv
|
||||||
|
|
||||||
|
for team_result in results:
|
||||||
|
print(f"\n📋 {team_result['team']}")
|
||||||
|
print(f" Total Members: {team_result['total_members']}")
|
||||||
|
print(f" Inactive: {team_result['inactive_count']}")
|
||||||
|
|
||||||
|
if team_result["inactive_members"]:
|
||||||
|
print(" Inactive Members:")
|
||||||
|
for member in team_result["inactive_members"]:
|
||||||
|
print(f" - {member['mention']}")
|
||||||
|
|
||||||
|
# Send message to team channel if requested
|
||||||
|
if send_messages and team_result["inactive_count"] > 0:
|
||||||
|
channel_id = TEAMS[team_result["team"]]["channel_id"]
|
||||||
|
|
||||||
|
# Build message
|
||||||
|
mentions = "\n".join(
|
||||||
|
[m["mention"] for m in team_result["inactive_members"]]
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
"Good morning, the following people have not sent a "
|
||||||
|
"message here in the last 36 hours. If you are on this "
|
||||||
|
"list, please confirm you are still participating.\n\n"
|
||||||
|
f"{mentions}"
|
||||||
|
)
|
||||||
|
|
||||||
|
await checker.send_message_to_channel(channel_id, message)
|
||||||
|
await asyncio.sleep(1) # Small delay between messages
|
||||||
|
else:
|
||||||
|
print(" ✅ All members are active!")
|
||||||
|
|
||||||
|
print("\n" + "=" * 80)
|
||||||
|
|
||||||
|
# Save results to JSON
|
||||||
|
with open("discord_activity_report.json", "w") as f:
|
||||||
|
json.dump(
|
||||||
|
{
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"threshold_hours": INACTIVE_THRESHOLD_HOURS,
|
||||||
|
"results": results,
|
||||||
|
},
|
||||||
|
f,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n📄 Detailed report saved to discord_activity_report.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# GitHub API (no auth needed for public repos, but rate limited)
|
||||||
|
GITHUB_API = "https://api.github.com"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_github_info(url: str) -> tuple[str | None, str | None]:
|
||||||
|
"""Extract owner and repo from GitHub URL."""
|
||||||
|
# Handle various GitHub URL formats
|
||||||
|
patterns = [
|
||||||
|
r"github\.com/([^/]+)/([^/\s?#]+)", # github.com/owner/repo
|
||||||
|
r"github\.com/([^/\s?#]+)/?$", # github.com/owner (profile)
|
||||||
|
]
|
||||||
|
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, url)
|
||||||
|
if match:
|
||||||
|
groups = match.groups()
|
||||||
|
if len(groups) == 2:
|
||||||
|
return groups[0], groups[1].rstrip(".git")
|
||||||
|
elif len(groups) == 1:
|
||||||
|
return groups[0], None
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_github_user(username: str) -> dict | None:
|
||||||
|
"""Fetch GitHub user profile."""
|
||||||
|
url = f"{GITHUB_API}/users/{username}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("Accept", "application/vnd.github.v3+json")
|
||||||
|
req.add_header("User-Agent", "Cohort-Evaluator")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_github_repos(username: str) -> list:
|
||||||
|
"""Fetch user's public repos."""
|
||||||
|
url = f"{GITHUB_API}/users/{username}/repos?per_page=100&sort=updated"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("Accept", "application/vnd.github.v3+json")
|
||||||
|
req.add_header("User-Agent", "Cohort-Evaluator")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_repo_languages(owner: str, repo: str) -> dict:
|
||||||
|
"""Fetch languages used in a repo."""
|
||||||
|
url = f"{GITHUB_API}/repos/{owner}/{repo}/languages"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("Accept", "application/vnd.github.v3+json")
|
||||||
|
req.add_header("User-Agent", "Cohort-Evaluator")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req, timeout=10)
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_proficiency_text(text: str) -> tuple[str, list[str]]:
|
||||||
|
"""Analyze self-described proficiency text."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
# Extract languages/technologies mentioned
|
||||||
|
tech_patterns = [
|
||||||
|
r"\b(python|java|javascript|typescript|c\+\+|c#|ruby|go|rust|swift|kotlin|php|perl|scala|r)\b",
|
||||||
|
r"\b(react|angular|vue|node|express|django|flask|spring|rails|laravel)\b",
|
||||||
|
r"\b(html|css|sass|scss|tailwind|bootstrap)\b",
|
||||||
|
r"\b(sql|mysql|postgresql|mongodb|redis|firebase)\b",
|
||||||
|
r"\b(docker|kubernetes|aws|azure|gcp|git)\b",
|
||||||
|
r"\b(machine learning|ml|ai|data science|tensorflow|pytorch)\b",
|
||||||
|
]
|
||||||
|
|
||||||
|
technologies = set()
|
||||||
|
for pattern in tech_patterns:
|
||||||
|
matches = re.findall(pattern, text_lower)
|
||||||
|
technologies.update(matches)
|
||||||
|
|
||||||
|
# Determine level from keywords
|
||||||
|
beginner_keywords = [
|
||||||
|
"beginner",
|
||||||
|
"learning",
|
||||||
|
"new to",
|
||||||
|
"just started",
|
||||||
|
"basic",
|
||||||
|
"novice",
|
||||||
|
"early",
|
||||||
|
]
|
||||||
|
intermediate_keywords = [
|
||||||
|
"intermediate",
|
||||||
|
"comfortable",
|
||||||
|
"familiar",
|
||||||
|
"some experience",
|
||||||
|
"worked with",
|
||||||
|
]
|
||||||
|
advanced_keywords = [
|
||||||
|
"advanced",
|
||||||
|
"expert",
|
||||||
|
"senior",
|
||||||
|
"professional",
|
||||||
|
"years of experience",
|
||||||
|
"proficient",
|
||||||
|
"strong",
|
||||||
|
]
|
||||||
|
|
||||||
|
level = "intermediate" # default
|
||||||
|
|
||||||
|
if any(kw in text_lower for kw in advanced_keywords):
|
||||||
|
level = "advanced"
|
||||||
|
elif any(kw in text_lower for kw in beginner_keywords):
|
||||||
|
level = "beginner"
|
||||||
|
elif any(kw in text_lower for kw in intermediate_keywords):
|
||||||
|
level = "intermediate"
|
||||||
|
|
||||||
|
return level, list(technologies)
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_applicant(applicant: dict, index: int, total: int) -> dict:
|
||||||
|
"""Evaluate a single applicant's technical proficiency."""
|
||||||
|
discord_id = applicant["discord_id"]
|
||||||
|
project_url = applicant["project_url"]
|
||||||
|
proficiency_self = applicant["proficiency_self"]
|
||||||
|
project_reason = applicant["project_reason"]
|
||||||
|
|
||||||
|
print(f"[{index + 1}/{total}] Evaluating {discord_id}...")
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"discord_id": discord_id,
|
||||||
|
"github_username": None,
|
||||||
|
"github_repos_count": 0,
|
||||||
|
"github_followers": 0,
|
||||||
|
"languages_from_github": [],
|
||||||
|
"languages_from_text": [],
|
||||||
|
"self_described_level": None,
|
||||||
|
"final_proficiency": "intermediate", # default
|
||||||
|
"tech_stack": [],
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Analyze self-description
|
||||||
|
text_level, text_techs = analyze_proficiency_text(
|
||||||
|
proficiency_self + " " + project_reason
|
||||||
|
)
|
||||||
|
result["self_described_level"] = text_level
|
||||||
|
result["languages_from_text"] = text_techs
|
||||||
|
|
||||||
|
# Fetch GitHub data if URL provided
|
||||||
|
if project_url and "github.com" in project_url:
|
||||||
|
owner, repo = extract_github_info(project_url)
|
||||||
|
|
||||||
|
if owner:
|
||||||
|
result["github_username"] = owner
|
||||||
|
|
||||||
|
# Fetch user profile
|
||||||
|
user_data = fetch_github_user(owner)
|
||||||
|
if user_data:
|
||||||
|
result["github_repos_count"] = user_data.get("public_repos", 0)
|
||||||
|
result["github_followers"] = user_data.get("followers", 0)
|
||||||
|
|
||||||
|
# Fetch repos to get languages
|
||||||
|
repos = fetch_github_repos(owner)
|
||||||
|
all_languages = set()
|
||||||
|
for r in repos[:10]: # Check top 10 repos
|
||||||
|
if r.get("language"):
|
||||||
|
all_languages.add(r["language"].lower())
|
||||||
|
result["languages_from_github"] = list(all_languages)
|
||||||
|
|
||||||
|
# If specific repo provided, get its languages
|
||||||
|
if repo:
|
||||||
|
repo_langs = fetch_repo_languages(owner, repo)
|
||||||
|
for lang in repo_langs:
|
||||||
|
all_languages.add(lang.lower())
|
||||||
|
result["languages_from_github"] = list(all_languages)
|
||||||
|
|
||||||
|
time.sleep(0.5) # Rate limiting
|
||||||
|
|
||||||
|
# Combine tech stack
|
||||||
|
all_tech = set(result["languages_from_github"]) | set(result["languages_from_text"])
|
||||||
|
result["tech_stack"] = sorted(all_tech)
|
||||||
|
|
||||||
|
# Determine final proficiency
|
||||||
|
# Factors: self-description, GitHub activity, tech diversity
|
||||||
|
github_score = 0
|
||||||
|
if result["github_repos_count"] >= 20:
|
||||||
|
github_score += 2
|
||||||
|
elif result["github_repos_count"] >= 10:
|
||||||
|
github_score += 1
|
||||||
|
|
||||||
|
if result["github_followers"] >= 50:
|
||||||
|
github_score += 2
|
||||||
|
elif result["github_followers"] >= 10:
|
||||||
|
github_score += 1
|
||||||
|
|
||||||
|
tech_count = len(result["tech_stack"])
|
||||||
|
if tech_count >= 6:
|
||||||
|
github_score += 2
|
||||||
|
elif tech_count >= 3:
|
||||||
|
github_score += 1
|
||||||
|
|
||||||
|
# Map self-described level to score
|
||||||
|
level_scores = {"beginner": 0, "intermediate": 2, "advanced": 4}
|
||||||
|
self_score = level_scores.get(text_level, 2)
|
||||||
|
|
||||||
|
# Combined score
|
||||||
|
total_score = github_score + self_score
|
||||||
|
|
||||||
|
if total_score >= 7:
|
||||||
|
result["final_proficiency"] = "advanced"
|
||||||
|
elif total_score >= 3:
|
||||||
|
result["final_proficiency"] = "intermediate"
|
||||||
|
else:
|
||||||
|
result["final_proficiency"] = "beginner"
|
||||||
|
|
||||||
|
# Add notes
|
||||||
|
if not project_url or "github.com" not in project_url:
|
||||||
|
result["notes"].append("No GitHub URL provided")
|
||||||
|
if result["github_repos_count"] == 0 and result["github_username"]:
|
||||||
|
result["notes"].append("GitHub profile has no public repos")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Load applicants
|
||||||
|
with open("applicants_to_evaluate.json") as f:
|
||||||
|
applicants = json.load(f)
|
||||||
|
|
||||||
|
print(f"Evaluating {len(applicants)} applicants...\n")
|
||||||
|
|
||||||
|
evaluations = []
|
||||||
|
for i, applicant in enumerate(applicants):
|
||||||
|
result = evaluate_applicant(applicant, i, len(applicants))
|
||||||
|
evaluations.append(result)
|
||||||
|
|
||||||
|
# Progress update every 10
|
||||||
|
if (i + 1) % 10 == 0:
|
||||||
|
print(f" Progress: {i + 1}/{len(applicants)} complete")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
with open("proficiency_evaluations.json", "w") as f:
|
||||||
|
json.dump(evaluations, f, indent=2)
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
beginner = sum(1 for e in evaluations if e["final_proficiency"] == "beginner")
|
||||||
|
intermediate = sum(
|
||||||
|
1 for e in evaluations if e["final_proficiency"] == "intermediate"
|
||||||
|
)
|
||||||
|
advanced = sum(1 for e in evaluations if e["final_proficiency"] == "advanced")
|
||||||
|
|
||||||
|
print("\n=== EVALUATION COMPLETE ===")
|
||||||
|
print(f"Beginner: {beginner}")
|
||||||
|
print(f"Intermediate: {intermediate}")
|
||||||
|
print(f"Advanced: {advanced}")
|
||||||
|
print(f"Total: {len(evaluations)}")
|
||||||
|
print("\nResults saved to proficiency_evaluations.json")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
BLOCK_EMOJIS = {"mornings": "🌅", "afternoons": "☀️", "evenings": "🌆", "nights": "🌙"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_data():
|
||||||
|
"""Load all evaluation data files"""
|
||||||
|
with open("discord_verification.json") as f:
|
||||||
|
verification = json.load(f)
|
||||||
|
|
||||||
|
with open("proficiency_evaluations.json") as f:
|
||||||
|
proficiency = json.load(f)
|
||||||
|
|
||||||
|
with open("availability_analysis.json") as f:
|
||||||
|
availability = json.load(f)
|
||||||
|
|
||||||
|
with open("leadership_candidates.json") as f:
|
||||||
|
candidates = json.load(f)
|
||||||
|
|
||||||
|
with open("leadership_evaluations.json") as f:
|
||||||
|
leadership = json.load(f)
|
||||||
|
|
||||||
|
return verification, proficiency, availability, candidates, leadership
|
||||||
|
|
||||||
|
|
||||||
|
def build_lookup_dicts(verification, proficiency, availability, leadership):
|
||||||
|
"""Build lookup dictionaries by discord_id"""
|
||||||
|
verified_usernames = {v[0]: v[1] for v in verification["verified"]}
|
||||||
|
|
||||||
|
prof_by_id = {p["discord_id"]: p for p in proficiency}
|
||||||
|
|
||||||
|
avail_by_id = {a["discord_id"]: a for a in availability}
|
||||||
|
|
||||||
|
lead_by_id = {l["discord_id"]: l for l in leadership}
|
||||||
|
|
||||||
|
return verified_usernames, prof_by_id, avail_by_id, lead_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def format_availability_blocks(blocks):
|
||||||
|
"""Format availability blocks with emojis"""
|
||||||
|
if not blocks:
|
||||||
|
return "No consistent availability"
|
||||||
|
|
||||||
|
formatted = []
|
||||||
|
for block in ["mornings", "afternoons", "evenings", "nights"]:
|
||||||
|
if block in blocks:
|
||||||
|
formatted.append(f"{BLOCK_EMOJIS[block]} {block.capitalize()}")
|
||||||
|
return ", ".join(formatted)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tech_stack(tech_stack):
|
||||||
|
"""Format tech stack list"""
|
||||||
|
if not tech_stack:
|
||||||
|
return "Not specified"
|
||||||
|
return ", ".join(sorted(tech_stack))
|
||||||
|
|
||||||
|
|
||||||
|
def generate_participants_md(
|
||||||
|
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
|
||||||
|
):
|
||||||
|
"""Generate participants.md for non-leaders"""
|
||||||
|
lines = [
|
||||||
|
"# Cohort Participants",
|
||||||
|
"",
|
||||||
|
f"**Total Participants**: {len(non_leader_ids)}",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
beginner_count = 0
|
||||||
|
intermediate_count = 0
|
||||||
|
advanced_count = 0
|
||||||
|
|
||||||
|
for discord_id in sorted(non_leader_ids):
|
||||||
|
if discord_id not in verified_usernames:
|
||||||
|
continue
|
||||||
|
|
||||||
|
username = verified_usernames.get(discord_id, "Unknown")
|
||||||
|
prof = prof_by_id.get(discord_id, {})
|
||||||
|
avail = avail_by_id.get(discord_id, {})
|
||||||
|
|
||||||
|
proficiency = prof.get("final_proficiency", "unknown")
|
||||||
|
tech_stack = prof.get("tech_stack", [])
|
||||||
|
blocks = avail.get("available_blocks", [])
|
||||||
|
notes = prof.get("notes", [])
|
||||||
|
|
||||||
|
if proficiency == "beginner":
|
||||||
|
beginner_count += 1
|
||||||
|
elif proficiency == "intermediate":
|
||||||
|
intermediate_count += 1
|
||||||
|
elif proficiency == "advanced":
|
||||||
|
advanced_count += 1
|
||||||
|
|
||||||
|
lines.append(f"## {discord_id}")
|
||||||
|
lines.append(f"**Username**: @{username}")
|
||||||
|
lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}")
|
||||||
|
lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}")
|
||||||
|
lines.append(f"**Availability**: {format_availability_blocks(blocks)}")
|
||||||
|
if notes:
|
||||||
|
lines.append(f"**Notes**: {', '.join(notes)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
verified_count = len([d for d in non_leader_ids if d in verified_usernames])
|
||||||
|
summary = [
|
||||||
|
"# Cohort Participants",
|
||||||
|
"",
|
||||||
|
f"**Total Participants**: {verified_count}",
|
||||||
|
"",
|
||||||
|
"### Proficiency Breakdown",
|
||||||
|
f"- Beginner: {beginner_count}",
|
||||||
|
f"- Intermediate: {intermediate_count}",
|
||||||
|
f"- Advanced: {advanced_count}",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\n".join(summary + lines[6:])
|
||||||
|
|
||||||
|
|
||||||
|
def leadership_fit_label(score):
|
||||||
|
"""Convert leadership score to label"""
|
||||||
|
if score >= 6:
|
||||||
|
return "Excellent"
|
||||||
|
elif score >= 4:
|
||||||
|
return "Good"
|
||||||
|
elif score >= 2:
|
||||||
|
return "Adequate"
|
||||||
|
else:
|
||||||
|
return "Needs Review"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_leaders_md(
|
||||||
|
leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id
|
||||||
|
):
|
||||||
|
"""Generate leaders.md for leadership candidates"""
|
||||||
|
verified_leaders = [id for id in leader_ids if id in verified_usernames]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Cohort Leaders",
|
||||||
|
"",
|
||||||
|
f"**Total Leaders**: {len(verified_leaders)}",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
sorted_leaders = sorted(
|
||||||
|
verified_leaders,
|
||||||
|
key=lambda x: lead_by_id.get(x, {}).get("leadership_score", 0),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
for discord_id in sorted_leaders:
|
||||||
|
username = verified_usernames.get(discord_id, "Unknown")
|
||||||
|
prof = prof_by_id.get(discord_id, {})
|
||||||
|
avail = avail_by_id.get(discord_id, {})
|
||||||
|
lead = lead_by_id.get(discord_id, {})
|
||||||
|
|
||||||
|
proficiency = prof.get("final_proficiency", "unknown")
|
||||||
|
tech_stack = prof.get("tech_stack", [])
|
||||||
|
blocks = avail.get("available_blocks", [])
|
||||||
|
|
||||||
|
leadership_score = lead.get("leadership_score", 0)
|
||||||
|
leadership_fit = lead.get("leadership_fit", "unknown")
|
||||||
|
leadership_notes = lead.get("notes", [])
|
||||||
|
prof_notes = prof.get("notes", [])
|
||||||
|
|
||||||
|
lines.append(f"## {discord_id}")
|
||||||
|
lines.append(f"**Username**: @{username}")
|
||||||
|
fit = leadership_fit.capitalize()
|
||||||
|
lines.append(f"**Leadership Fit**: {fit} (Score: {leadership_score})")
|
||||||
|
lines.append(f"**Technical Proficiency**: {proficiency.capitalize()}")
|
||||||
|
lines.append(f"**Tech Stack**: {format_tech_stack(tech_stack)}")
|
||||||
|
lines.append(f"**Availability**: {format_availability_blocks(blocks)}")
|
||||||
|
if leadership_notes:
|
||||||
|
lines.append(f"**Leadership Notes**: {', '.join(leadership_notes)}")
|
||||||
|
if prof_notes:
|
||||||
|
lines.append(f"**Technical Notes**: {', '.join(prof_notes)}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
excellent = sum(
|
||||||
|
1
|
||||||
|
for id in verified_leaders
|
||||||
|
if lead_by_id.get(id, {}).get("leadership_fit") == "excellent"
|
||||||
|
)
|
||||||
|
good = sum(
|
||||||
|
1
|
||||||
|
for id in verified_leaders
|
||||||
|
if lead_by_id.get(id, {}).get("leadership_fit") == "good"
|
||||||
|
)
|
||||||
|
adequate = sum(
|
||||||
|
1
|
||||||
|
for id in verified_leaders
|
||||||
|
if lead_by_id.get(id, {}).get("leadership_fit") == "adequate"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = [
|
||||||
|
"# Cohort Leaders",
|
||||||
|
"",
|
||||||
|
f"**Total Leaders**: {len(verified_leaders)}",
|
||||||
|
"",
|
||||||
|
"### Leadership Fit Breakdown",
|
||||||
|
f"- Excellent: {excellent}",
|
||||||
|
f"- Good: {good}",
|
||||||
|
f"- Adequate: {adequate}",
|
||||||
|
"",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
return "\n".join(summary + lines[6:])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
verification, proficiency, availability, candidates, leadership = load_all_data()
|
||||||
|
|
||||||
|
verified_usernames, prof_by_id, avail_by_id, lead_by_id = build_lookup_dicts(
|
||||||
|
verification, proficiency, availability, leadership
|
||||||
|
)
|
||||||
|
|
||||||
|
leader_ids = set(candidates["leaders"])
|
||||||
|
non_leader_ids = set(candidates["non_leaders"])
|
||||||
|
|
||||||
|
verified_ids = set(verified_usernames.keys())
|
||||||
|
leader_ids = leader_ids & verified_ids
|
||||||
|
non_leader_ids = non_leader_ids & verified_ids
|
||||||
|
|
||||||
|
participants_md = generate_participants_md(
|
||||||
|
non_leader_ids, verified_usernames, prof_by_id, avail_by_id
|
||||||
|
)
|
||||||
|
with open("participants.md", "w") as f:
|
||||||
|
f.write(participants_md)
|
||||||
|
print(f"Generated participants.md with {len(non_leader_ids)} participants")
|
||||||
|
|
||||||
|
leaders_md = generate_leaders_md(
|
||||||
|
leader_ids, verified_usernames, prof_by_id, avail_by_id, lead_by_id
|
||||||
|
)
|
||||||
|
with open("leaders.md", "w") as f:
|
||||||
|
f.write(leaders_md)
|
||||||
|
print(f"Generated leaders.md with {len(leader_ids)} leaders")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Generate hourly time slots from Feb 1 to March 3, 2026
|
||||||
|
# 24 hours a day, America/Los_Angeles timezone
|
||||||
|
start_date = datetime(2026, 2, 1, 0, 0) # Feb 1, 2026, midnight
|
||||||
|
end_date = datetime(2026, 3, 3, 23, 0) # March 3, 2026, 11pm
|
||||||
|
|
||||||
|
times = []
|
||||||
|
current = start_date
|
||||||
|
while current <= end_date:
|
||||||
|
# Format: YYYY-MM-DDTHH:MM
|
||||||
|
times.append(current.strftime("%Y-%m-%dT%H:%M"))
|
||||||
|
current += timedelta(hours=1)
|
||||||
|
|
||||||
|
print(f"Generated {len(times)} time slots")
|
||||||
|
print(f"First: {times[0]}")
|
||||||
|
print(f"Last: {times[-1]}")
|
||||||
|
|
||||||
|
# Save to file for use
|
||||||
|
with open("/home/naomi/docs/cohort/crabfit_timeslots.json", "w") as f:
|
||||||
|
json.dump(times, f)
|
||||||
|
|
||||||
|
print("Saved to crabfit_timeslots.json")
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Amari's bot token
|
||||||
|
TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
|
||||||
|
# File to save message IDs
|
||||||
|
MESSAGE_IDS_FILE = "team_message_ids.json"
|
||||||
|
|
||||||
|
# Team channel IDs and role IDs
|
||||||
|
TEAMS = {
|
||||||
|
"Jade Jasmine": {
|
||||||
|
"channel_id": "1464316501573107886",
|
||||||
|
"role_id": "1464314923780931677",
|
||||||
|
},
|
||||||
|
"Crimson Dahlia": {
|
||||||
|
"channel_id": "1464316744909852682",
|
||||||
|
"role_id": "1464315093402784015",
|
||||||
|
},
|
||||||
|
"Rose Camellia": {
|
||||||
|
"channel_id": "1464316751268286611",
|
||||||
|
"role_id": "1464315098452726106",
|
||||||
|
},
|
||||||
|
"Amber Wisteria": {
|
||||||
|
"channel_id": "1464316761410113641",
|
||||||
|
"role_id": "1464315105264275600",
|
||||||
|
},
|
||||||
|
"Ivory Orchid": {
|
||||||
|
"channel_id": "1464316770889240730",
|
||||||
|
"role_id": "1464315109873684593",
|
||||||
|
},
|
||||||
|
"Teal Iris": {
|
||||||
|
"channel_id": "1464316776459407448",
|
||||||
|
"role_id": "1464315114378498152",
|
||||||
|
},
|
||||||
|
"Peach Gardenia": {
|
||||||
|
"channel_id": "1464316785040953543",
|
||||||
|
"role_id": "1464315118904152107",
|
||||||
|
},
|
||||||
|
"Violet Carnation": {
|
||||||
|
"channel_id": "1464316805261824032",
|
||||||
|
"role_id": "1464315124251754559",
|
||||||
|
},
|
||||||
|
"Azure Lotus": {
|
||||||
|
"channel_id": "1464316814455472139",
|
||||||
|
"role_id": "1464315128437801177",
|
||||||
|
},
|
||||||
|
"Coral Sunflower": {
|
||||||
|
"channel_id": "1464316819711066263",
|
||||||
|
"role_id": "1464315132896088168",
|
||||||
|
},
|
||||||
|
"Indigo Tulip": {
|
||||||
|
"channel_id": "1464316826384072925",
|
||||||
|
"role_id": "1464315138428633241",
|
||||||
|
},
|
||||||
|
"Scarlet Hydrangea": {
|
||||||
|
"channel_id": "1464316839306985506",
|
||||||
|
"role_id": "1464315142710890520",
|
||||||
|
},
|
||||||
|
"Mint Narcissus": {
|
||||||
|
"channel_id": "1464316844251807952",
|
||||||
|
"role_id": "1464315149203804405",
|
||||||
|
},
|
||||||
|
"Sage Marigold": {
|
||||||
|
"channel_id": "1464316850669093040",
|
||||||
|
"role_id": "1464315153599299803",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load team assignments and convert to dict by team name
|
||||||
|
with open("team_assignments.json") as f:
|
||||||
|
team_list = json.load(f)
|
||||||
|
team_data = {team["name"]: team for team in team_list}
|
||||||
|
|
||||||
|
# Load applicants to get project_url by discord_id
|
||||||
|
with open("applicants_to_evaluate.json") as f:
|
||||||
|
applicants = json.load(f)
|
||||||
|
applicant_lookup = {str(a["discord_id"]): a for a in applicants}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_github_username(url):
|
||||||
|
"""Extract GitHub username from various URL formats"""
|
||||||
|
if not url:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
# Handle GitLab special case (RashiqAzhan)
|
||||||
|
if "gitlab.com" in url:
|
||||||
|
# We know this is RashiqAzhan from earlier confirmation
|
||||||
|
return "RashiqAzhan"
|
||||||
|
|
||||||
|
# Handle GitHub Pages URLs
|
||||||
|
if ".github.io" in url:
|
||||||
|
# Extract username from username.github.io format
|
||||||
|
parts = url.replace("https://", "").replace("http://", "").split(".")
|
||||||
|
if parts:
|
||||||
|
return parts[0]
|
||||||
|
|
||||||
|
# Handle plain usernames (no URL)
|
||||||
|
if not url.startswith("http"):
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Handle standard GitHub URLs
|
||||||
|
if "github.com" in url:
|
||||||
|
# Remove protocol and github.com
|
||||||
|
path = (
|
||||||
|
url.replace("https://", "")
|
||||||
|
.replace("http://", "")
|
||||||
|
.replace("github.com/", "")
|
||||||
|
)
|
||||||
|
# Get just the username (first part of path)
|
||||||
|
username = path.split("/")[0]
|
||||||
|
return username
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(team_name, role_id, leader_ids, participant_ids):
|
||||||
|
"""Build the welcome message for a team"""
|
||||||
|
lines = [
|
||||||
|
f"# {team_name}",
|
||||||
|
"",
|
||||||
|
f"Welcome, <@&{role_id}>. This is your private team channel — a space "
|
||||||
|
"for you to collaborate, support one another, and build something "
|
||||||
|
"meaningful together.",
|
||||||
|
"",
|
||||||
|
"## Roster",
|
||||||
|
"",
|
||||||
|
"**Leadership**",
|
||||||
|
]
|
||||||
|
|
||||||
|
for discord_id in leader_ids:
|
||||||
|
applicant = applicant_lookup.get(str(discord_id), {})
|
||||||
|
project_url = applicant.get("project_url", "")
|
||||||
|
github_username = extract_github_username(project_url)
|
||||||
|
lines.append(f"- <@{discord_id}>: https://github.com/{github_username}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("**Participants**")
|
||||||
|
|
||||||
|
for discord_id in participant_ids:
|
||||||
|
applicant = applicant_lookup.get(str(discord_id), {})
|
||||||
|
project_url = applicant.get("project_url", "")
|
||||||
|
github_username = extract_github_username(project_url)
|
||||||
|
lines.append(f"- <@{discord_id}>: https://github.com/{github_username}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Project Info")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Coming soon. 💜")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def send_message(channel_id, content):
|
||||||
|
"""Send a message to a channel"""
|
||||||
|
url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
|
||||||
|
headers = {"Authorization": f"Bot {TOKEN}", "Content-Type": "application/json"}
|
||||||
|
data = {"content": content}
|
||||||
|
|
||||||
|
response = requests.post(url, headers=headers, json=data)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = float(
|
||||||
|
response.headers.get(
|
||||||
|
"Retry-After", response.headers.get("X-RateLimit-Reset-After", 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"Rate limited, waiting {retry_after}s...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
return send_message(channel_id, content)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
print(f"Error sending message: {response.status_code} - {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def pin_message(channel_id, message_id):
|
||||||
|
"""Pin a message in a channel"""
|
||||||
|
url = f"https://discord.com/api/v10/channels/{channel_id}/pins/{message_id}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bot {TOKEN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=headers)
|
||||||
|
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry_after = float(
|
||||||
|
response.headers.get(
|
||||||
|
"Retry-After", response.headers.get("X-RateLimit-Reset-After", 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"Rate limited, waiting {retry_after}s...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
return pin_message(channel_id, message_id)
|
||||||
|
|
||||||
|
return response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
message_ids = {}
|
||||||
|
|
||||||
|
for team_name, team_info in TEAMS.items():
|
||||||
|
channel_id = team_info["channel_id"]
|
||||||
|
role_id = team_info["role_id"]
|
||||||
|
|
||||||
|
# Get team members from team_data
|
||||||
|
team = team_data.get(team_name, {"leaders": [], "participants": []})
|
||||||
|
leaders = team.get("leaders", [])
|
||||||
|
participants = team.get("participants", [])
|
||||||
|
|
||||||
|
# Build the message
|
||||||
|
message_content = build_message(team_name, role_id, leaders, participants)
|
||||||
|
|
||||||
|
print(f"Sending message to {team_name}...")
|
||||||
|
|
||||||
|
# Send the message
|
||||||
|
result = send_message(channel_id, message_content)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
message_id = result["id"]
|
||||||
|
message_ids[team_name] = {
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"message_id": message_id,
|
||||||
|
"role_id": role_id,
|
||||||
|
}
|
||||||
|
print(f" Message sent! ID: {message_id}")
|
||||||
|
|
||||||
|
# Pin the message
|
||||||
|
print(" Pinning message...")
|
||||||
|
if pin_message(channel_id, message_id):
|
||||||
|
print(" Pinned!")
|
||||||
|
else:
|
||||||
|
print(" Failed to pin")
|
||||||
|
else:
|
||||||
|
print(" Failed to send message")
|
||||||
|
|
||||||
|
# Small delay between teams
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
# Save message IDs to file
|
||||||
|
with open(MESSAGE_IDS_FILE, "w") as f:
|
||||||
|
json.dump(message_ids, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\nDone! Message IDs saved to {MESSAGE_IDS_FILE}")
|
||||||
|
print(f"Successfully sent and pinned messages for {len(message_ids)} teams")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Update Cohort Leads role permissions to allow pinging in team channels."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Discord configuration
|
||||||
|
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
BASE_URL = "https://discord.com/api/v10"
|
||||||
|
|
||||||
|
# Team channel IDs from send_team_messages.py
|
||||||
|
TEAM_CHANNELS = {
|
||||||
|
"Jade Jasmine": {
|
||||||
|
"channel_id": "1464316501573107886",
|
||||||
|
"role_id": "1464314923780931677",
|
||||||
|
},
|
||||||
|
"Crimson Dahlia": {
|
||||||
|
"channel_id": "1464316744909852682",
|
||||||
|
"role_id": "1464315093402784015",
|
||||||
|
},
|
||||||
|
"Rose Camellia": {
|
||||||
|
"channel_id": "1464316751268286611",
|
||||||
|
"role_id": "1464315098452726106",
|
||||||
|
},
|
||||||
|
"Amber Wisteria": {
|
||||||
|
"channel_id": "1464316761410113641",
|
||||||
|
"role_id": "1464315105264275600",
|
||||||
|
},
|
||||||
|
"Ivory Orchid": {
|
||||||
|
"channel_id": "1464316770889240730",
|
||||||
|
"role_id": "1464315109873684593",
|
||||||
|
},
|
||||||
|
"Teal Iris": {
|
||||||
|
"channel_id": "1464316776459407448",
|
||||||
|
"role_id": "1464315114378498152",
|
||||||
|
},
|
||||||
|
"Peach Gardenia": {
|
||||||
|
"channel_id": "1464316785040953543",
|
||||||
|
"role_id": "1464315118904152107",
|
||||||
|
},
|
||||||
|
"Violet Carnation": {
|
||||||
|
"channel_id": "1464316805261824032",
|
||||||
|
"role_id": "1464315124251754559",
|
||||||
|
},
|
||||||
|
"Azure Lotus": {
|
||||||
|
"channel_id": "1464316814455472139",
|
||||||
|
"role_id": "1464315128437801177",
|
||||||
|
},
|
||||||
|
"Coral Sunflower": {
|
||||||
|
"channel_id": "1464316819711066263",
|
||||||
|
"role_id": "1464315132896088168",
|
||||||
|
},
|
||||||
|
"Indigo Tulip": {
|
||||||
|
"channel_id": "1464316826384072925",
|
||||||
|
"role_id": "1464315138428633241",
|
||||||
|
},
|
||||||
|
"Scarlet Hydrangea": {
|
||||||
|
"channel_id": "1464316839306985506",
|
||||||
|
"role_id": "1464315142710890520",
|
||||||
|
},
|
||||||
|
"Mint Narcissus": {
|
||||||
|
"channel_id": "1464316844251807952",
|
||||||
|
"role_id": "1464315149203804405",
|
||||||
|
},
|
||||||
|
"Sage Marigold": {
|
||||||
|
"channel_id": "1464316850669093040",
|
||||||
|
"role_id": "1464315153599299803",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
HEADERS = {"Authorization": f"Bot {BOT_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def get_guild_roles():
|
||||||
|
"""Get all roles in the guild to find Cohort Leads"""
|
||||||
|
url = f"{BASE_URL}/guilds/{GUILD_ID}/roles"
|
||||||
|
response = requests.get(url, headers=HEADERS)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
else:
|
||||||
|
print(f"Error getting roles: {response.status_code} - {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def find_cohort_leads_role(roles):
|
||||||
|
"""Find the Cohort Leads role from the list"""
|
||||||
|
for role in roles:
|
||||||
|
if "cohort" in role["name"].lower() and "lead" in role["name"].lower():
|
||||||
|
return role
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_channel_permissions(channel_id, role_id, team_name):
|
||||||
|
"""Update channel permissions for a specific role"""
|
||||||
|
url = f"{BASE_URL}/channels/{channel_id}/permissions/{role_id}"
|
||||||
|
|
||||||
|
# Permission bits:
|
||||||
|
# MENTION_EVERYONE = 1 << 17 = 131072
|
||||||
|
# PIN_MESSAGES = 1 << 51 = 2251799813685248
|
||||||
|
# Combined: 131072 + 2251799813685248 = 2251799813816320
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"allow": "2251799813816320", # MENTION_EVERYONE + PIN_MESSAGES permissions
|
||||||
|
"deny": "0",
|
||||||
|
"type": 0, # Role permission type
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, headers=HEADERS, json=data)
|
||||||
|
|
||||||
|
if response.status_code == 204:
|
||||||
|
print(f"✓ Updated permissions for {team_name} channel")
|
||||||
|
return True
|
||||||
|
elif response.status_code == 429:
|
||||||
|
retry_after = float(response.headers.get("Retry-After", 1))
|
||||||
|
print(f" Rate limited! Waiting {retry_after:.2f}s...")
|
||||||
|
time.sleep(retry_after)
|
||||||
|
return update_channel_permissions(channel_id, role_id, team_name)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"✗ Failed to update {team_name} channel: "
|
||||||
|
f"{response.status_code} - {response.text}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Fetching guild roles...")
|
||||||
|
roles = get_guild_roles()
|
||||||
|
|
||||||
|
if not roles:
|
||||||
|
print("Failed to fetch roles!")
|
||||||
|
return
|
||||||
|
|
||||||
|
cohort_leads_role = find_cohort_leads_role(roles)
|
||||||
|
|
||||||
|
if not cohort_leads_role:
|
||||||
|
print("Could not find Cohort Leads role!")
|
||||||
|
print("\nAvailable roles:")
|
||||||
|
for role in roles:
|
||||||
|
print(f" - {role['name']} (ID: {role['id']})")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\nFound Cohort Leads role: {cohort_leads_role['name']} "
|
||||||
|
f"(ID: {cohort_leads_role['id']})"
|
||||||
|
)
|
||||||
|
print(f"Updating permissions for {len(TEAM_CHANNELS)} team channels...")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
|
||||||
|
for team_name, team_data in TEAM_CHANNELS.items():
|
||||||
|
if update_channel_permissions(
|
||||||
|
team_data["channel_id"], cohort_leads_role["id"], team_name
|
||||||
|
):
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
# Small delay between requests
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"Complete! Success: {success_count}, Failed: {fail_count}")
|
||||||
|
|
||||||
|
if success_count == len(TEAM_CHANNELS):
|
||||||
|
print("\n✅ All team channels have been updated!")
|
||||||
|
print("Cohort Leads can now:")
|
||||||
|
print(" - Use @everyone, @here, and mention all roles")
|
||||||
|
print(" - Pin and unpin messages")
|
||||||
|
print(
|
||||||
|
"\nNote: They cannot view these channels unless they have "
|
||||||
|
"the specific team role."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
|
||||||
|
GUILD_ID = "692816967895220344"
|
||||||
|
BASE_URL = "https://discord.com/api/v10"
|
||||||
|
|
||||||
|
# Read Discord IDs from table.md
|
||||||
|
with open("table.md") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
lines = content.strip().split("\n")
|
||||||
|
|
||||||
|
# Find the table header line (starts with |)
|
||||||
|
header_line = None
|
||||||
|
header_idx = 0
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if line.startswith("| Discord"):
|
||||||
|
header_line = line
|
||||||
|
header_idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_line:
|
||||||
|
print("Could not find table header!")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
headers = [h.strip() for h in header_line.split("|")[1:-1]]
|
||||||
|
|
||||||
|
discord_idx = 0 # Discord ID is the first column
|
||||||
|
|
||||||
|
discord_ids = []
|
||||||
|
for line in lines[header_idx + 2 :]: # Skip header and separator
|
||||||
|
if not line.startswith("|"):
|
||||||
|
continue
|
||||||
|
cols = [c.strip() for c in line.split("|")[1:-1]]
|
||||||
|
if len(cols) > discord_idx:
|
||||||
|
discord_id = cols[discord_idx].strip()
|
||||||
|
if discord_id and discord_id.isdigit():
|
||||||
|
discord_ids.append(discord_id)
|
||||||
|
|
||||||
|
print(f"Found {len(discord_ids)} Discord IDs to verify")
|
||||||
|
|
||||||
|
# Verify each ID against the guild
|
||||||
|
verified = []
|
||||||
|
missing = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for i, discord_id in enumerate(discord_ids):
|
||||||
|
url = f"{BASE_URL}/guilds/{GUILD_ID}/members/{discord_id}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("Authorization", f"Bot {BOT_TOKEN}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = urllib.request.urlopen(req)
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
username = data.get("user", {}).get("username", "Unknown")
|
||||||
|
verified.append((discord_id, username))
|
||||||
|
print(f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id} - {username}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
missing.append(discord_id)
|
||||||
|
print(f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id} - NOT IN SERVER")
|
||||||
|
elif e.code == 429:
|
||||||
|
# Rate limited - wait and retry
|
||||||
|
retry_after = json.loads(e.read().decode()).get("retry_after", 1)
|
||||||
|
print(
|
||||||
|
f"[{i + 1}/{len(discord_ids)}] Rate limited, waiting {retry_after}s..."
|
||||||
|
)
|
||||||
|
time.sleep(retry_after + 0.5)
|
||||||
|
# Retry
|
||||||
|
try:
|
||||||
|
req2 = urllib.request.Request(url)
|
||||||
|
req2.add_header("Authorization", f"Bot {BOT_TOKEN}")
|
||||||
|
response = urllib.request.urlopen(req2)
|
||||||
|
data = json.loads(response.read().decode())
|
||||||
|
username = data.get("user", {}).get("username", "Unknown")
|
||||||
|
verified.append((discord_id, username))
|
||||||
|
msg = f"[{i + 1}/{len(discord_ids)}] ✓ {discord_id}"
|
||||||
|
print(f"{msg} - {username} (after retry)")
|
||||||
|
except urllib.error.HTTPError as e2:
|
||||||
|
if e2.code == 404:
|
||||||
|
missing.append(discord_id)
|
||||||
|
msg = f"[{i + 1}/{len(discord_ids)}] ✗ {discord_id}"
|
||||||
|
print(f"{msg} - NOT IN SERVER (after retry)")
|
||||||
|
else:
|
||||||
|
errors.append((discord_id, f"HTTP {e2.code}"))
|
||||||
|
print(
|
||||||
|
f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e2.code}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
errors.append((discord_id, f"HTTP {e.code}"))
|
||||||
|
print(f"[{i + 1}/{len(discord_ids)}] ? {discord_id} - Error {e.code}")
|
||||||
|
|
||||||
|
# Small delay to avoid rate limits
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
print("\n=== SUMMARY ===")
|
||||||
|
print(f"Verified: {len(verified)}")
|
||||||
|
print(f"Missing: {len(missing)}")
|
||||||
|
print(f"Errors: {len(errors)}")
|
||||||
|
|
||||||
|
# Save results
|
||||||
|
with open("discord_verification.json", "w") as f:
|
||||||
|
json.dump({"verified": verified, "missing": missing, "errors": errors}, f, indent=2)
|
||||||
|
|
||||||
|
print("\nResults saved to discord_verification.json")
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
[project]
|
||||||
|
name = "ephemere"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Collection of ephemeral scripts"
|
||||||
|
authors = [
|
||||||
|
{ name = "Naomi Carrigan", email = "nhcarrigan@gmail.com" }
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"ruff==0.14.14"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py310"
|
||||||
|
line-length = 88
|
||||||
|
indent-width = 4
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
# pycodestyle
|
||||||
|
"E",
|
||||||
|
# pyflakes
|
||||||
|
"F",
|
||||||
|
# isort
|
||||||
|
"I",
|
||||||
|
# pydocstyle
|
||||||
|
"D",
|
||||||
|
# pyupgrade
|
||||||
|
"UP",
|
||||||
|
# flake8-bugbear
|
||||||
|
"B",
|
||||||
|
# flake8-comprehensions
|
||||||
|
"C4",
|
||||||
|
# flake8-datetimez
|
||||||
|
"DTZ",
|
||||||
|
# flake8-implicit-str-concat
|
||||||
|
"ISC",
|
||||||
|
# flake8-logging-format
|
||||||
|
"G",
|
||||||
|
# flake8-print
|
||||||
|
"T20",
|
||||||
|
# flake8-pytest-style
|
||||||
|
"PT",
|
||||||
|
# flake8-quotes
|
||||||
|
"Q",
|
||||||
|
# flake8-simplify
|
||||||
|
"SIM",
|
||||||
|
# flake8-tidy-imports
|
||||||
|
"TID",
|
||||||
|
# pylint
|
||||||
|
"PL",
|
||||||
|
]
|
||||||
|
ignore = [
|
||||||
|
# Missing docstrings
|
||||||
|
"D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107",
|
||||||
|
# Allow print statements in scripts
|
||||||
|
"T201",
|
||||||
|
# Docstring punctuation - not critical for scripts
|
||||||
|
"D415",
|
||||||
|
# Magic values - acceptable in simple scripts
|
||||||
|
"PLR2004",
|
||||||
|
# Loop variable overwritten - common pattern
|
||||||
|
"PLW2901",
|
||||||
|
# Use sys.exit instead of exit - not critical
|
||||||
|
"PLR1722",
|
||||||
|
# Collapsible if statements - readability preference
|
||||||
|
"PLR5501",
|
||||||
|
# zip strict - not critical for scripts
|
||||||
|
"B905",
|
||||||
|
# Docstring summary line spacing - not critical
|
||||||
|
"D205",
|
||||||
|
# Function complexity - acceptable for scripts
|
||||||
|
"PLR0912", "PLR0915",
|
||||||
|
# Datetime timezone - scripts use local context
|
||||||
|
"DTZ001",
|
||||||
|
# Ambiguous variable names - context makes it clear
|
||||||
|
"E741",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.pydocstyle]
|
||||||
|
convention = "google"
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["py"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Development dependencies
|
||||||
|
ruff==0.14.14
|
||||||
|
pytest==8.3.5
|
||||||
|
pytest-mock==3.14.0
|
||||||
|
responses==0.25.3
|
||||||
|
pytest-asyncio==0.24.0
|
||||||
|
|
||||||
|
# Runtime dependencies
|
||||||
|
requests==2.32.3
|
||||||
|
aiohttp==3.11.12
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Test package for ephemere Python scripts
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"""Tests for analyse_availability functions.
|
||||||
|
|
||||||
|
@copyright NHCarrigan
|
||||||
|
@license Naomi's Public License
|
||||||
|
@author Naomi Carrigan
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Add the cohort directory to the path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "cohort"))
|
||||||
|
|
||||||
|
from analyse_availability import (
|
||||||
|
analyze_applicant_availability,
|
||||||
|
get_utc_blocks_for_hour,
|
||||||
|
local_hour_to_utc,
|
||||||
|
parse_time_slots,
|
||||||
|
parse_utc_offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseUtcOffset:
|
||||||
|
"""Tests for parse_utc_offset function."""
|
||||||
|
|
||||||
|
def test_positive_offset(self):
|
||||||
|
"""Should parse positive UTC offset."""
|
||||||
|
assert parse_utc_offset("Europe/London (UTC+0)") == 0
|
||||||
|
assert parse_utc_offset("Europe/Paris (UTC+1)") == 1
|
||||||
|
assert parse_utc_offset("Asia/Tokyo (UTC+9)") == 9
|
||||||
|
|
||||||
|
def test_negative_offset(self):
|
||||||
|
"""Should parse negative UTC offset."""
|
||||||
|
assert parse_utc_offset("America/New_York (UTC-5)") == -5
|
||||||
|
assert parse_utc_offset("America/Los_Angeles (UTC-8)") == -8
|
||||||
|
|
||||||
|
def test_offset_with_minutes(self):
|
||||||
|
"""Should parse offset with minutes component."""
|
||||||
|
assert parse_utc_offset("Asia/Kolkata (UTC+5:30)") == 5.5
|
||||||
|
assert parse_utc_offset("Asia/Kathmandu (UTC+5:45)") == 5.75
|
||||||
|
|
||||||
|
def test_negative_offset_with_minutes(self):
|
||||||
|
"""Should parse negative offset with minutes."""
|
||||||
|
assert parse_utc_offset("Canada/Newfoundland (UTC-3:30)") == -3.5
|
||||||
|
|
||||||
|
def test_no_match_returns_zero(self):
|
||||||
|
"""Should return 0 when no UTC offset found."""
|
||||||
|
assert parse_utc_offset("Unknown/Timezone") == 0
|
||||||
|
assert parse_utc_offset("") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseTimeSlots:
|
||||||
|
"""Tests for parse_time_slots function."""
|
||||||
|
|
||||||
|
def test_single_slot(self):
|
||||||
|
"""Should parse a single time slot."""
|
||||||
|
result = parse_time_slots("17:00-18:00")
|
||||||
|
assert result == [(17, 18)]
|
||||||
|
|
||||||
|
def test_multiple_slots(self):
|
||||||
|
"""Should parse multiple time slots separated by semicolon."""
|
||||||
|
result = parse_time_slots("07:00-08:00; 19:00-20:00")
|
||||||
|
assert result == [(7, 8), (19, 20)]
|
||||||
|
|
||||||
|
def test_na_values(self):
|
||||||
|
"""Should return empty list for N/A values."""
|
||||||
|
assert parse_time_slots("N/A") == []
|
||||||
|
assert parse_time_slots("na") == []
|
||||||
|
assert parse_time_slots("") == []
|
||||||
|
|
||||||
|
def test_wider_time_range(self):
|
||||||
|
"""Should parse wider time ranges."""
|
||||||
|
result = parse_time_slots("09:00-17:00")
|
||||||
|
assert result == [(9, 17)]
|
||||||
|
|
||||||
|
def test_midnight_crossing_slots(self):
|
||||||
|
"""Should parse time slots that approach midnight."""
|
||||||
|
result = parse_time_slots("22:00-23:00")
|
||||||
|
assert result == [(22, 23)]
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocalHourToUtc:
|
||||||
|
"""Tests for local_hour_to_utc function."""
|
||||||
|
|
||||||
|
def test_positive_offset(self):
|
||||||
|
"""Should convert local hour with positive UTC offset."""
|
||||||
|
assert local_hour_to_utc(12, 1) == 11 # Noon in UTC+1 -> 11:00 UTC
|
||||||
|
assert local_hour_to_utc(0, 9) == 15 # Midnight in UTC+9 -> 15:00 UTC
|
||||||
|
|
||||||
|
def test_negative_offset(self):
|
||||||
|
"""Should convert local hour with negative UTC offset."""
|
||||||
|
assert local_hour_to_utc(12, -5) == 17 # Noon in UTC-5 -> 17:00 UTC
|
||||||
|
assert local_hour_to_utc(20, -8) == 4 # 8pm in UTC-8 -> 4:00 UTC (next day)
|
||||||
|
|
||||||
|
def test_wrapping_around_midnight(self):
|
||||||
|
"""Should wrap around correctly at midnight."""
|
||||||
|
assert local_hour_to_utc(1, 5) == 20 # 1am in UTC+5 -> 20:00 UTC (prev day)
|
||||||
|
assert local_hour_to_utc(23, -3) == 2 # 11pm in UTC-3 -> 2:00 UTC (next day)
|
||||||
|
|
||||||
|
def test_zero_offset(self):
|
||||||
|
"""Should return same hour for zero offset."""
|
||||||
|
assert local_hour_to_utc(15, 0) == 15
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUtcBlocksForHour:
|
||||||
|
"""Tests for get_utc_blocks_for_hour function."""
|
||||||
|
|
||||||
|
def test_mornings_block(self):
|
||||||
|
"""Should return mornings for 6-11 UTC."""
|
||||||
|
assert "mornings" in get_utc_blocks_for_hour(6)
|
||||||
|
assert "mornings" in get_utc_blocks_for_hour(9)
|
||||||
|
assert "mornings" in get_utc_blocks_for_hour(11)
|
||||||
|
assert "mornings" not in get_utc_blocks_for_hour(12)
|
||||||
|
|
||||||
|
def test_afternoons_block(self):
|
||||||
|
"""Should return afternoons for 12-17 UTC."""
|
||||||
|
assert "afternoons" in get_utc_blocks_for_hour(12)
|
||||||
|
assert "afternoons" in get_utc_blocks_for_hour(15)
|
||||||
|
assert "afternoons" in get_utc_blocks_for_hour(17)
|
||||||
|
assert "afternoons" not in get_utc_blocks_for_hour(18)
|
||||||
|
|
||||||
|
def test_evenings_block(self):
|
||||||
|
"""Should return evenings for 18-23 UTC."""
|
||||||
|
assert "evenings" in get_utc_blocks_for_hour(18)
|
||||||
|
assert "evenings" in get_utc_blocks_for_hour(21)
|
||||||
|
assert "evenings" in get_utc_blocks_for_hour(23)
|
||||||
|
assert "evenings" not in get_utc_blocks_for_hour(0)
|
||||||
|
|
||||||
|
def test_nights_block(self):
|
||||||
|
"""Should return nights for 0-5 UTC."""
|
||||||
|
assert "nights" in get_utc_blocks_for_hour(0)
|
||||||
|
assert "nights" in get_utc_blocks_for_hour(3)
|
||||||
|
assert "nights" in get_utc_blocks_for_hour(5)
|
||||||
|
assert "nights" not in get_utc_blocks_for_hour(6)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnalyzeApplicantAvailability:
|
||||||
|
"""Tests for analyze_applicant_availability function."""
|
||||||
|
|
||||||
|
def test_basic_availability(self):
|
||||||
|
"""Should analyze basic availability correctly."""
|
||||||
|
day_slots = {
|
||||||
|
"Monday": [(9, 12)],
|
||||||
|
"Tuesday": [(9, 12)],
|
||||||
|
"Wednesday": [(9, 12)],
|
||||||
|
"Thursday": [],
|
||||||
|
"Friday": [],
|
||||||
|
"Saturday": [],
|
||||||
|
"Sunday": [],
|
||||||
|
}
|
||||||
|
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
|
||||||
|
|
||||||
|
assert result["utc_offset"] == 0
|
||||||
|
assert "mornings" in result["available_blocks"]
|
||||||
|
|
||||||
|
def test_no_availability(self):
|
||||||
|
"""Should return empty blocks when no availability."""
|
||||||
|
day_slots = {
|
||||||
|
"Monday": [],
|
||||||
|
"Tuesday": [],
|
||||||
|
"Wednesday": [],
|
||||||
|
"Thursday": [],
|
||||||
|
"Friday": [],
|
||||||
|
"Saturday": [],
|
||||||
|
"Sunday": [],
|
||||||
|
}
|
||||||
|
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
|
||||||
|
|
||||||
|
assert result["available_blocks"] == []
|
||||||
|
assert result["total_unique_utc_hours"] == 0
|
||||||
|
|
||||||
|
def test_timezone_conversion(self):
|
||||||
|
"""Should correctly convert timezones."""
|
||||||
|
day_slots = {
|
||||||
|
"Monday": [(17, 20)], # 5pm-8pm local
|
||||||
|
"Tuesday": [(17, 20)],
|
||||||
|
"Wednesday": [(17, 20)],
|
||||||
|
"Thursday": [],
|
||||||
|
"Friday": [],
|
||||||
|
"Saturday": [],
|
||||||
|
"Sunday": [],
|
||||||
|
}
|
||||||
|
result = analyze_applicant_availability(
|
||||||
|
"America/New_York (UTC-5)", day_slots
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["utc_offset"] == -5
|
||||||
|
# 5pm-8pm in UTC-5 = 22:00-01:00 UTC -> evenings/nights
|
||||||
|
assert "evenings" in result["available_blocks"]
|
||||||
|
|
||||||
|
def test_block_count_threshold(self):
|
||||||
|
"""Should only include blocks with 3+ occurrences."""
|
||||||
|
day_slots = {
|
||||||
|
"Monday": [(9, 10)], # Only 1 hour
|
||||||
|
"Tuesday": [(9, 10)], # Only 1 hour
|
||||||
|
"Wednesday": [],
|
||||||
|
"Thursday": [],
|
||||||
|
"Friday": [],
|
||||||
|
"Saturday": [],
|
||||||
|
"Sunday": [],
|
||||||
|
}
|
||||||
|
result = analyze_applicant_availability("UTC (UTC+0)", day_slots)
|
||||||
|
|
||||||
|
# Only 2 hours, threshold is 3
|
||||||
|
assert "mornings" not in result["available_blocks"]
|
||||||
Generated
+43
@@ -0,0 +1,43 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ephemere"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.14.14" }]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.14.14"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/06/f71e3a86b2df0dfa2d2f72195941cd09b44f87711cb7fa5193732cb9a5fc/ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b", size = 4515732, upload-time = "2026-01-22T22:30:17.527Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/89/20a12e97bc6b9f9f68343952da08a8099c57237aef953a56b82711d55edd/ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed", size = 10467650, upload-time = "2026-01-22T22:30:08.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/b1/c5de3fd2d5a831fcae21beda5e3589c0ba67eec8202e992388e4b17a6040/ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c", size = 10883245, upload-time = "2026-01-22T22:30:04.155Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/7c/3c1db59a10e7490f8f6f8559d1db8636cbb13dccebf18686f4e3c9d7c772/ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de", size = 10231273, upload-time = "2026-01-22T22:30:34.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/6e/5e0e0d9674be0f8581d1f5e0f0a04761203affce3232c1a1189d0e3b4dad/ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e", size = 10585753, upload-time = "2026-01-22T22:30:31.781Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/09/754ab09f46ff1884d422dc26d59ba18b4e5d355be147721bb2518aa2a014/ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8", size = 10286052, upload-time = "2026-01-22T22:30:24.827Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/cc/e71f88dd2a12afb5f50733851729d6b571a7c3a35bfdb16c3035132675a0/ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906", size = 11043637, upload-time = "2026-01-22T22:30:13.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/b2/397245026352494497dac935d7f00f1468c03a23a0c5db6ad8fc49ca3fb2/ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480", size = 12194761, upload-time = "2026-01-22T22:30:22.542Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/06/06ef271459f778323112c51b7587ce85230785cd64e91772034ddb88f200/ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df", size = 12005701, upload-time = "2026-01-22T22:30:20.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/d6/99364514541cf811ccc5ac44362f88df66373e9fec1b9d1c4cc830593fe7/ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b", size = 11282455, upload-time = "2026-01-22T22:29:59.679Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/71/37daa46f89475f8582b7762ecd2722492df26421714a33e72ccc9a84d7a5/ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974", size = 11215882, upload-time = "2026-01-22T22:29:57.032Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/10/a31f86169ec91c0705e618443ee74ede0bdd94da0a57b28e72db68b2dbac/ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66", size = 11180549, upload-time = "2026-01-22T22:30:27.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/1e/c723f20536b5163adf79bdd10c5f093414293cdf567eed9bdb7b83940f3f/ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13", size = 10543416, upload-time = "2026-01-22T22:30:01.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/34/8a84cea7e42c2d94ba5bde1d7a4fae164d6318f13f933d92da6d7c2041ff/ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412", size = 10285491, upload-time = "2026-01-22T22:30:29.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/ef/b7c5ea0be82518906c978e365e56a77f8de7678c8bb6651ccfbdc178c29f/ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3", size = 10733525, upload-time = "2026-01-22T22:30:06.499Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/5b/aaf1dfbcc53a2811f6cc0a1759de24e4b03e02ba8762daabd9b6bd8c59e3/ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b", size = 11315626, upload-time = "2026-01-22T22:30:36.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/aa/9f89c719c467dfaf8ad799b9bae0df494513fb21d31a6059cb5870e57e74/ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167", size = 10502442, upload-time = "2026-01-22T22:30:38.93Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/44/90fa543014c45560cae1fffc63ea059fb3575ee6e1cb654562197e5d16fb/ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd", size = 11630486, upload-time = "2026-01-22T22:30:10.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Colors and formatting
|
||||||
|
PINK='\033[38;5;213m'
|
||||||
|
BLUE='\033[38;5;117m'
|
||||||
|
GREEN='\033[38;5;156m'
|
||||||
|
YELLOW='\033[38;5;229m'
|
||||||
|
CYAN='\033[38;5;123m'
|
||||||
|
WHITE='\033[1;37m'
|
||||||
|
DIM='\033[2m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
RESET='\033[0m'
|
||||||
|
|
||||||
|
# Box drawing characters
|
||||||
|
TOP_LEFT='╭'
|
||||||
|
TOP_RIGHT='╮'
|
||||||
|
BOTTOM_LEFT='╰'
|
||||||
|
BOTTOM_RIGHT='╯'
|
||||||
|
HORIZONTAL='─'
|
||||||
|
VERTICAL='│'
|
||||||
|
ARROW='❯'
|
||||||
|
SPARKLE='✨'
|
||||||
|
STAR='★'
|
||||||
|
|
||||||
|
# Clear screen and show header
|
||||||
|
clear
|
||||||
|
echo -e "${PINK}${BOLD} ${TOP_LEFT}────────────────────────────────────${TOP_RIGHT}"
|
||||||
|
echo -e " ${VERTICAL} ${SPARKLE} ${WHITE}Ephemere Script Runner${PINK} ${SPARKLE} ${VERTICAL}"
|
||||||
|
echo -e " ${BOTTOM_LEFT}────────────────────────────────────${BOTTOM_RIGHT}${RESET}"
|
||||||
|
|
||||||
|
# Function to display a menu and get selection
|
||||||
|
select_option() {
|
||||||
|
local prompt="$1"
|
||||||
|
shift
|
||||||
|
local options=("$@")
|
||||||
|
local selected=0
|
||||||
|
local key
|
||||||
|
|
||||||
|
# Hide cursor
|
||||||
|
tput civis
|
||||||
|
|
||||||
|
# Trap to restore cursor on exit
|
||||||
|
trap 'tput cnorm' EXIT
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Clear previous menu (move up and clear lines)
|
||||||
|
if [ -n "$menu_drawn" ]; then
|
||||||
|
for ((i=0; i<=${#options[@]}+1; i++)); do
|
||||||
|
tput cuu1
|
||||||
|
tput el
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
menu_drawn=1
|
||||||
|
|
||||||
|
# Print prompt
|
||||||
|
echo -e "${CYAN}${BOLD} $prompt${RESET}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Print options
|
||||||
|
for i in "${!options[@]}"; do
|
||||||
|
if [ $i -eq $selected ]; then
|
||||||
|
echo -e " ${PINK}${BOLD}$ARROW ${WHITE}${options[$i]}${RESET}"
|
||||||
|
else
|
||||||
|
echo -e " ${DIM}${options[$i]}${RESET}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read a single keypress
|
||||||
|
read -rsn1 key
|
||||||
|
|
||||||
|
# Handle arrow keys (they come as escape sequences)
|
||||||
|
if [[ $key == $'\x1b' ]]; then
|
||||||
|
read -rsn2 key
|
||||||
|
case $key in
|
||||||
|
'[A') # Up arrow
|
||||||
|
((selected--))
|
||||||
|
[ $selected -lt 0 ] && selected=$((${#options[@]}-1))
|
||||||
|
;;
|
||||||
|
'[B') # Down arrow
|
||||||
|
((selected++))
|
||||||
|
[ $selected -ge ${#options[@]} ] && selected=0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
elif [[ $key == "" ]]; then
|
||||||
|
# Enter pressed
|
||||||
|
tput cnorm # Show cursor
|
||||||
|
unset menu_drawn
|
||||||
|
return $selected
|
||||||
|
elif [[ $key == "q" || $key == "Q" ]]; then
|
||||||
|
tput cnorm
|
||||||
|
echo -e "\n${YELLOW} Bye bye! $SPARKLE${RESET}\n"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Select Language
|
||||||
|
echo ""
|
||||||
|
languages=("TypeScript" "Python" "Bash")
|
||||||
|
select_option "Select a language:" "${languages[@]}"
|
||||||
|
lang_index=$?
|
||||||
|
language="${languages[$lang_index]}"
|
||||||
|
|
||||||
|
echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$language${RESET}\n"
|
||||||
|
|
||||||
|
# Step 2: Get categories based on language
|
||||||
|
if [ "$language" == "TypeScript" ]; then
|
||||||
|
script_dir="typescript/src"
|
||||||
|
runner="pnpm tsx"
|
||||||
|
# Get subdirectories as categories (excluding utils and interfaces)
|
||||||
|
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name 'utils' ! -name 'interfaces' -exec basename {} \; | sort)
|
||||||
|
elif [ "$language" == "Python" ]; then
|
||||||
|
script_dir="python"
|
||||||
|
runner="uv run python"
|
||||||
|
# Get subdirectories as categories (excluding __pycache__ and .venv)
|
||||||
|
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d ! -name '__pycache__' ! -name '.venv' ! -name '*.egg-info' -exec basename {} \; | sort)
|
||||||
|
# Add "Root Scripts" option for Python files in root
|
||||||
|
if ls "$script_dir"/*.py &>/dev/null 2>&1; then
|
||||||
|
categories=("Root Scripts" "${categories[@]}")
|
||||||
|
fi
|
||||||
|
else # Bash
|
||||||
|
script_dir="bash"
|
||||||
|
runner="bash"
|
||||||
|
# Get subdirectories as categories
|
||||||
|
mapfile -t categories < <(find "$script_dir" -mindepth 1 -maxdepth 1 -type d -exec basename {} \; | sort)
|
||||||
|
# Add "Root Scripts" option for bash files in root
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#categories[@]} -eq 0 ]; then
|
||||||
|
echo -e " ${YELLOW}No script categories found!${RESET}\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
select_option "Select a category:" "${categories[@]}"
|
||||||
|
cat_index=$?
|
||||||
|
category="${categories[$cat_index]}"
|
||||||
|
|
||||||
|
echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$category${RESET}\n"
|
||||||
|
|
||||||
|
# Step 3: Get scripts in category
|
||||||
|
if [ "$category" == "Root Scripts" ]; then
|
||||||
|
search_dir="$script_dir"
|
||||||
|
if [ "$language" == "Python" ]; then
|
||||||
|
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.py" -exec basename {} \; | sort)
|
||||||
|
elif [ "$language" == "Bash" ]; then
|
||||||
|
mapfile -t scripts < <(find "$search_dir" -maxdepth 1 -name "*.sh" -exec basename {} \; | sort)
|
||||||
|
fi
|
||||||
|
elif [ "$language" == "TypeScript" ]; then
|
||||||
|
search_dir="$script_dir/$category"
|
||||||
|
mapfile -t scripts < <(find "$search_dir" -name "*.ts" -exec basename {} \; | sort)
|
||||||
|
elif [ "$language" == "Python" ]; then
|
||||||
|
search_dir="$script_dir/$category"
|
||||||
|
mapfile -t scripts < <(find "$search_dir" -name "*.py" ! -name "__init__.py" -exec basename {} \; | sort)
|
||||||
|
else # Bash
|
||||||
|
search_dir="$script_dir/$category"
|
||||||
|
mapfile -t scripts < <(find "$search_dir" -name "*.sh" -exec basename {} \; | sort)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#scripts[@]} -eq 0 ]; then
|
||||||
|
echo -e " ${YELLOW}No scripts found in this category!${RESET}\n"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
select_option "Select a script:" "${scripts[@]}"
|
||||||
|
script_index=$?
|
||||||
|
script="${scripts[$script_index]}"
|
||||||
|
|
||||||
|
echo -e "\n ${GREEN}$STAR Selected: ${WHITE}$script${RESET}\n"
|
||||||
|
|
||||||
|
# Build the full script path
|
||||||
|
if [ "$category" == "Root Scripts" ]; then
|
||||||
|
script_path="$script"
|
||||||
|
elif [ "$language" == "TypeScript" ]; then
|
||||||
|
script_path="src/$category/$script"
|
||||||
|
elif [ "$language" == "Python" ]; then
|
||||||
|
script_path="$category/$script"
|
||||||
|
else # Bash
|
||||||
|
script_path="bash/$category/$script"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show what we're about to run
|
||||||
|
echo -e "${BLUE}${BOLD} ${TOP_LEFT}───────────────────────${TOP_RIGHT}"
|
||||||
|
echo -e " ${VERTICAL} ${WHITE}Running script...${BLUE} ${VERTICAL}"
|
||||||
|
echo -e " ${BOTTOM_LEFT}───────────────────────${BOTTOM_RIGHT}${RESET}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${DIM}Language:${RESET} $language"
|
||||||
|
echo -e " ${DIM}Category:${RESET} $category"
|
||||||
|
echo -e " ${DIM}Script:${RESET} $script"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Run the script with 1Password env injection
|
||||||
|
if [ "$language" == "TypeScript" ]; then
|
||||||
|
cd typescript
|
||||||
|
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
|
||||||
|
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
|
||||||
|
elif [ "$language" == "Python" ]; then
|
||||||
|
cd python
|
||||||
|
echo -e " ${DIM}$ op run --env-file=../prod.env -- $runner $script_path${RESET}\n"
|
||||||
|
op run --env-file=../prod.env --no-masking -- $runner "$script_path"
|
||||||
|
else # Bash
|
||||||
|
echo -e " ${DIM}$ op run --env-file=prod.env -- $runner $script_path${RESET}\n"
|
||||||
|
op run --env-file=prod.env --no-masking -- $runner "$script_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [ $exit_code -eq 0 ]; then
|
||||||
|
echo -e " ${GREEN}${BOLD}$SPARKLE Script completed successfully! $SPARKLE${RESET}\n"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}${BOLD}Script exited with code $exit_code${RESET}\n"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
.PHONY: help install build lint test clean
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "TypeScript project commands:"
|
||||||
|
@echo " make install - Install dependencies"
|
||||||
|
@echo " make build - Build TypeScript (type check)"
|
||||||
|
@echo " make lint - Run ESLint"
|
||||||
|
@echo " make test - Run tests"
|
||||||
|
@echo " make clean - Clean build artifacts"
|
||||||
|
|
||||||
|
install:
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
build:
|
||||||
|
pnpm exec tsc --noEmit
|
||||||
|
|
||||||
|
lint:
|
||||||
|
pnpm exec eslint src --max-warnings 0
|
||||||
|
|
||||||
|
format:
|
||||||
|
pnpm exec eslint src --fix
|
||||||
|
|
||||||
|
test:
|
||||||
|
@echo "No tests configured yet"
|
||||||
|
@exit 0
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf node_modules
|
||||||
|
rm -rf dist
|
||||||
|
rm -f *.tsbuildinfo
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit",
|
|
||||||
"lint": "eslint src --max-warnings 0",
|
|
||||||
"start": "op run --env-file=prod.env --no-masking -- tsx",
|
"start": "op run --env-file=prod.env --no-masking -- tsx",
|
||||||
"test": "echo \"Error: no test specified\" && exit 0"
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -27,5 +27,9 @@
|
|||||||
"open": "11.0.0",
|
"open": "11.0.0",
|
||||||
"tsx": "4.20.5",
|
"tsx": "4.20.5",
|
||||||
"typescript": "5.9.2"
|
"typescript": "5.9.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"vitest": "3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+332
@@ -44,9 +44,20 @@ importers:
|
|||||||
typescript:
|
typescript:
|
||||||
specifier: 5.9.2
|
specifier: 5.9.2
|
||||||
version: 5.9.2
|
version: 5.9.2
|
||||||
|
devDependencies:
|
||||||
|
'@vitest/coverage-v8':
|
||||||
|
specifier: ^3.2.4
|
||||||
|
version: 3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))
|
||||||
|
vitest:
|
||||||
|
specifier: 3.2.4
|
||||||
|
version: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@ampproject/remapping@2.3.0':
|
||||||
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -210,10 +221,31 @@ packages:
|
|||||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1':
|
||||||
|
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.27.1':
|
'@babel/helper-validator-identifier@7.27.1':
|
||||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5':
|
||||||
|
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.0':
|
||||||
|
resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
'@bcoe/v8-coverage@1.0.2':
|
||||||
|
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
'@es-joy/jsdoccomment@0.49.0':
|
'@es-joy/jsdoccomment@0.49.0':
|
||||||
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
|
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -589,9 +621,27 @@ packages:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@isaacs/cliui@8.0.2':
|
||||||
|
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
'@istanbuljs/schema@0.1.3':
|
||||||
|
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2':
|
||||||
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5':
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
'@nhcarrigan/eslint-config@5.2.0':
|
'@nhcarrigan/eslint-config@5.2.0':
|
||||||
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
|
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
|
||||||
engines: {node: '>=22', pnpm: '>=9'}
|
engines: {node: '>=22', pnpm: '>=9'}
|
||||||
@@ -672,6 +722,10 @@ packages:
|
|||||||
'@octokit/types@14.1.0':
|
'@octokit/types@14.1.0':
|
||||||
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
|
resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==}
|
||||||
|
|
||||||
|
'@pkgjs/parseargs@0.11.0':
|
||||||
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
'@pkgr/core@0.1.2':
|
'@pkgr/core@0.1.2':
|
||||||
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
|
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -1136,6 +1190,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
|
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@vitest/coverage-v8@3.2.4':
|
||||||
|
resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@vitest/browser': 3.2.4
|
||||||
|
vitest: 3.2.4
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@vitest/browser':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/eslint-plugin@1.1.24':
|
'@vitest/eslint-plugin@1.1.24':
|
||||||
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
|
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1200,10 +1263,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-regex@6.2.2:
|
||||||
|
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@6.2.3:
|
||||||
|
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
are-docs-informative@0.0.2:
|
are-docs-informative@0.0.2:
|
||||||
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
|
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1251,6 +1322,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
ast-v8-to-istanbul@0.3.11:
|
||||||
|
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
|
||||||
|
|
||||||
async-function@1.0.0:
|
async-function@1.0.0:
|
||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1434,12 +1508,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
eastasianwidth@0.2.0:
|
||||||
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
electron-to-chromium@1.5.210:
|
electron-to-chromium@1.5.210:
|
||||||
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
|
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
|
||||||
|
|
||||||
emoji-regex@8.0.0:
|
emoji-regex@8.0.0:
|
||||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
|
emoji-regex@9.2.2:
|
||||||
|
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||||
|
|
||||||
error-ex@1.3.2:
|
error-ex@1.3.2:
|
||||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
||||||
|
|
||||||
@@ -1679,6 +1759,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
foreground-child@3.3.1:
|
||||||
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1722,6 +1806,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
glob@10.5.0:
|
||||||
|
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
globals@13.24.0:
|
globals@13.24.0:
|
||||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1779,6 +1867,9 @@ packages:
|
|||||||
hosted-git-info@2.8.9:
|
hosted-git-info@2.8.9:
|
||||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||||
|
|
||||||
|
html-escaper@2.0.2:
|
||||||
|
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||||
|
|
||||||
iconv-lite@0.7.0:
|
iconv-lite@0.7.0:
|
||||||
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1938,10 +2029,32 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
istanbul-lib-coverage@3.2.2:
|
||||||
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
istanbul-lib-report@3.0.1:
|
||||||
|
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
istanbul-lib-source-maps@5.0.6:
|
||||||
|
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
istanbul-reports@3.2.0:
|
||||||
|
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
jackspeak@3.4.3:
|
||||||
|
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||||
|
|
||||||
|
js-tokens@10.0.0:
|
||||||
|
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -2013,9 +2126,19 @@ packages:
|
|||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
|
lru-cache@10.4.3:
|
||||||
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
magic-string@0.30.18:
|
magic-string@0.30.18:
|
||||||
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
|
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
|
||||||
|
|
||||||
|
magicast@0.3.5:
|
||||||
|
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
|
||||||
|
|
||||||
|
make-dir@4.0.0:
|
||||||
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2042,6 +2165,10 @@ packages:
|
|||||||
minimist@1.2.8:
|
minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
|
minipass@7.1.2:
|
||||||
|
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||||
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -2127,6 +2254,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
package-json-from-dist@1.0.1:
|
||||||
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2150,6 +2280,10 @@ packages:
|
|||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
path-scurry@1.11.1:
|
||||||
|
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
|
||||||
|
engines: {node: '>=16 || 14 >=14.18'}
|
||||||
|
|
||||||
path-type@4.0.0:
|
path-type@4.0.0:
|
||||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2379,6 +2513,9 @@ packages:
|
|||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
|
std-env@3.10.0:
|
||||||
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
std-env@3.9.0:
|
std-env@3.9.0:
|
||||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
||||||
|
|
||||||
@@ -2390,6 +2527,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
string-width@5.1.2:
|
||||||
|
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
string.prototype.matchall@4.0.12:
|
string.prototype.matchall@4.0.12:
|
||||||
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2413,6 +2554,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
strip-ansi@7.1.2:
|
||||||
|
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
strip-bom@3.0.0:
|
strip-bom@3.0.0:
|
||||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -2443,6 +2588,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
|
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
|
||||||
engines: {node: ^14.18.0 || >=16.0.0}
|
engines: {node: ^14.18.0 || >=16.0.0}
|
||||||
|
|
||||||
|
test-exclude@7.0.1:
|
||||||
|
resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
tinybench@2.9.0:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -2658,6 +2807,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
wrap-ansi@7.0.0:
|
||||||
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
wrap-ansi@8.1.0:
|
||||||
|
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
wsl-utils@0.3.0:
|
wsl-utils@0.3.0:
|
||||||
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
|
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -2672,6 +2829,11 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@ampproject/remapping@2.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
'@aws-crypto/crc32@5.2.0':
|
'@aws-crypto/crc32@5.2.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/util': 5.2.0
|
'@aws-crypto/util': 5.2.0
|
||||||
@@ -3157,8 +3319,23 @@ snapshots:
|
|||||||
js-tokens: 4.0.0
|
js-tokens: 4.0.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
|
'@babel/helper-string-parser@7.27.1': {}
|
||||||
|
|
||||||
'@babel/helper-validator-identifier@7.27.1': {}
|
'@babel/helper-validator-identifier@7.27.1': {}
|
||||||
|
|
||||||
|
'@babel/helper-validator-identifier@7.28.5': {}
|
||||||
|
|
||||||
|
'@babel/parser@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@babel/types@7.29.0':
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-string-parser': 7.27.1
|
||||||
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
|
'@bcoe/v8-coverage@1.0.2': {}
|
||||||
|
|
||||||
'@es-joy/jsdoccomment@0.49.0':
|
'@es-joy/jsdoccomment@0.49.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
comment-parser: 1.4.1
|
comment-parser: 1.4.1
|
||||||
@@ -3451,8 +3628,31 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.3.0
|
'@types/node': 24.3.0
|
||||||
|
|
||||||
|
'@isaacs/cliui@8.0.2':
|
||||||
|
dependencies:
|
||||||
|
string-width: 5.1.2
|
||||||
|
string-width-cjs: string-width@4.2.3
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
strip-ansi-cjs: strip-ansi@6.0.1
|
||||||
|
wrap-ansi: 8.1.0
|
||||||
|
wrap-ansi-cjs: wrap-ansi@7.0.0
|
||||||
|
|
||||||
|
'@istanbuljs/schema@0.1.3': {}
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
|
'@nhcarrigan/eslint-config@5.2.0(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(playwright@1.55.0)(react@19.1.1)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
|
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
|
||||||
@@ -3560,6 +3760,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/openapi-types': 25.1.0
|
'@octokit/openapi-types': 25.1.0
|
||||||
|
|
||||||
|
'@pkgjs/parseargs@0.11.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@pkgr/core@0.1.2': {}
|
'@pkgr/core@0.1.2': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.49.0':
|
'@rollup/rollup-android-arm-eabi@4.49.0':
|
||||||
@@ -4163,6 +4366,25 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.41.0
|
'@typescript-eslint/types': 8.41.0
|
||||||
eslint-visitor-keys: 4.2.1
|
eslint-visitor-keys: 4.2.1
|
||||||
|
|
||||||
|
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
|
||||||
|
dependencies:
|
||||||
|
'@ampproject/remapping': 2.3.0
|
||||||
|
'@bcoe/v8-coverage': 1.0.2
|
||||||
|
ast-v8-to-istanbul: 0.3.11
|
||||||
|
debug: 4.4.1
|
||||||
|
istanbul-lib-coverage: 3.2.2
|
||||||
|
istanbul-lib-report: 3.0.1
|
||||||
|
istanbul-lib-source-maps: 5.0.6
|
||||||
|
istanbul-reports: 3.2.0
|
||||||
|
magic-string: 0.30.18
|
||||||
|
magicast: 0.3.5
|
||||||
|
std-env: 3.10.0
|
||||||
|
test-exclude: 7.0.1
|
||||||
|
tinyrainbow: 2.0.0
|
||||||
|
vitest: 3.2.4(@types/node@24.3.0)(tsx@4.20.5)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
|
'@vitest/eslint-plugin@1.1.24(@typescript-eslint/utils@8.41.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2)(vitest@3.2.4(@types/node@24.3.0)(tsx@4.20.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2)
|
'@typescript-eslint/utils': 8.41.0(eslint@9.34.0)(typescript@5.9.2)
|
||||||
@@ -4234,10 +4456,14 @@ snapshots:
|
|||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
|
ansi-regex@6.2.2: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
ansi-styles@6.2.3: {}
|
||||||
|
|
||||||
are-docs-informative@0.0.2: {}
|
are-docs-informative@0.0.2: {}
|
||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
@@ -4313,6 +4539,12 @@ snapshots:
|
|||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
|
ast-v8-to-istanbul@0.3.11:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
js-tokens: 10.0.0
|
||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
@@ -4488,10 +4720,14 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.210: {}
|
electron-to-chromium@1.5.210: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
|
emoji-regex@9.2.2: {}
|
||||||
|
|
||||||
error-ex@1.3.2:
|
error-ex@1.3.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-arrayish: 0.2.1
|
is-arrayish: 0.2.1
|
||||||
@@ -4900,6 +5136,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
||||||
|
foreground-child@3.3.1:
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
fsevents@2.3.2:
|
fsevents@2.3.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4955,6 +5196,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|
||||||
|
glob@10.5.0:
|
||||||
|
dependencies:
|
||||||
|
foreground-child: 3.3.1
|
||||||
|
jackspeak: 3.4.3
|
||||||
|
minimatch: 9.0.5
|
||||||
|
minipass: 7.1.2
|
||||||
|
package-json-from-dist: 1.0.1
|
||||||
|
path-scurry: 1.11.1
|
||||||
|
|
||||||
globals@13.24.0:
|
globals@13.24.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 0.20.2
|
type-fest: 0.20.2
|
||||||
@@ -5005,6 +5255,8 @@ snapshots:
|
|||||||
|
|
||||||
hosted-git-info@2.8.9: {}
|
hosted-git-info@2.8.9: {}
|
||||||
|
|
||||||
|
html-escaper@2.0.2: {}
|
||||||
|
|
||||||
iconv-lite@0.7.0:
|
iconv-lite@0.7.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -5157,6 +5409,27 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
|
|
||||||
|
istanbul-lib-report@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
istanbul-lib-coverage: 3.2.2
|
||||||
|
make-dir: 4.0.0
|
||||||
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
istanbul-lib-source-maps@5.0.6:
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
debug: 4.4.1
|
||||||
|
istanbul-lib-coverage: 3.2.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
istanbul-reports@3.2.0:
|
||||||
|
dependencies:
|
||||||
|
html-escaper: 2.0.2
|
||||||
|
istanbul-lib-report: 3.0.1
|
||||||
|
|
||||||
iterator.prototype@1.1.5:
|
iterator.prototype@1.1.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -5166,6 +5439,14 @@ snapshots:
|
|||||||
has-symbols: 1.1.0
|
has-symbols: 1.1.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
jackspeak@3.4.3:
|
||||||
|
dependencies:
|
||||||
|
'@isaacs/cliui': 8.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@pkgjs/parseargs': 0.11.0
|
||||||
|
|
||||||
|
js-tokens@10.0.0: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
@@ -5226,10 +5507,22 @@ snapshots:
|
|||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
|
lru-cache@10.4.3: {}
|
||||||
|
|
||||||
magic-string@0.30.18:
|
magic-string@0.30.18:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
magicast@0.3.5:
|
||||||
|
dependencies:
|
||||||
|
'@babel/parser': 7.29.0
|
||||||
|
'@babel/types': 7.29.0
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
make-dir@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
semver: 7.7.2
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
@@ -5251,6 +5544,8 @@ snapshots:
|
|||||||
|
|
||||||
minimist@1.2.8: {}
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
|
minipass@7.1.2: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mute-stream@2.0.0: {}
|
mute-stream@2.0.0: {}
|
||||||
@@ -5352,6 +5647,8 @@ snapshots:
|
|||||||
|
|
||||||
p-try@2.2.0: {}
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -5374,6 +5671,11 @@ snapshots:
|
|||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
|
path-scurry@1.11.1:
|
||||||
|
dependencies:
|
||||||
|
lru-cache: 10.4.3
|
||||||
|
minipass: 7.1.2
|
||||||
|
|
||||||
path-type@4.0.0: {}
|
path-type@4.0.0: {}
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
@@ -5627,6 +5929,8 @@ snapshots:
|
|||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
std-env@3.9.0: {}
|
std-env@3.9.0: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
@@ -5640,6 +5944,12 @@ snapshots:
|
|||||||
is-fullwidth-code-point: 3.0.0
|
is-fullwidth-code-point: 3.0.0
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
string-width@5.1.2:
|
||||||
|
dependencies:
|
||||||
|
eastasianwidth: 0.2.0
|
||||||
|
emoji-regex: 9.2.2
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
string.prototype.matchall@4.0.12:
|
string.prototype.matchall@4.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -5688,6 +5998,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
|
|
||||||
|
strip-ansi@7.1.2:
|
||||||
|
dependencies:
|
||||||
|
ansi-regex: 6.2.2
|
||||||
|
|
||||||
strip-bom@3.0.0: {}
|
strip-bom@3.0.0: {}
|
||||||
|
|
||||||
strip-indent@3.0.0:
|
strip-indent@3.0.0:
|
||||||
@@ -5713,6 +6027,12 @@ snapshots:
|
|||||||
'@pkgr/core': 0.1.2
|
'@pkgr/core': 0.1.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
test-exclude@7.0.1:
|
||||||
|
dependencies:
|
||||||
|
'@istanbuljs/schema': 0.1.3
|
||||||
|
glob: 10.5.0
|
||||||
|
minimatch: 9.0.5
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@0.3.2: {}
|
tinyexec@0.3.2: {}
|
||||||
@@ -5960,6 +6280,18 @@ snapshots:
|
|||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
strip-ansi: 6.0.1
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
wrap-ansi@7.0.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 4.3.0
|
||||||
|
string-width: 4.2.3
|
||||||
|
strip-ansi: 6.0.1
|
||||||
|
|
||||||
|
wrap-ansi@8.1.0:
|
||||||
|
dependencies:
|
||||||
|
ansi-styles: 6.2.3
|
||||||
|
string-width: 5.1.2
|
||||||
|
strip-ansi: 7.1.2
|
||||||
|
|
||||||
wsl-utils@0.3.0:
|
wsl-utils@0.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-wsl: 3.1.0
|
is-wsl: 3.1.0
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getFiles } from "../getFiles.js";
|
||||||
|
|
||||||
|
describe("getFiles", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(global, "fetch");
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch files from a project", async() => {
|
||||||
|
const mockFiles = [
|
||||||
|
{ data: { id: 1, name: "file1.json", path: "/file1.json" } },
|
||||||
|
{ data: { id: 2, name: "file2.json", path: "/file2.json" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: mockFiles });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getFiles(
|
||||||
|
"123",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"test-token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockFiles);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle pagination when there are 500+ files", async() => {
|
||||||
|
const files500 = Array.from({ length: 500 }, (_, index) => {
|
||||||
|
return { data: { id: index, name: `file${String(index)}.json` } };
|
||||||
|
});
|
||||||
|
const filesRemaining = [
|
||||||
|
{ data: { id: 500, name: "file500.json" } },
|
||||||
|
{ data: { id: 501, name: "file501.json" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: files500 });
|
||||||
|
},
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: filesRemaining });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getFiles(
|
||||||
|
"123",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(502);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123/files?limit=500",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123/files?limit=500&offset=500",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no files exist", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: [] });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getFiles(
|
||||||
|
"456",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getLanguages } from "../getLanguages.js";
|
||||||
|
|
||||||
|
describe("getLanguages", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(global, "fetch");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch project and return target language IDs", async() => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
name: "Test Project",
|
||||||
|
targetLanguageIds: [ "de", "fr", "es", "ja" ],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockResponse);
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getLanguages(
|
||||||
|
"123",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"test-token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([ "de", "fr", "es", "ja" ]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no target languages", async() => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
id: 2,
|
||||||
|
name: "Empty Project",
|
||||||
|
targetLanguageIds: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockResponse);
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getLanguages(
|
||||||
|
"456",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use the correct API URL", async() => {
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
targetLanguageIds: [ "en" ],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockResponse);
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getLanguages(
|
||||||
|
"789",
|
||||||
|
"https://custom.crowdin.com/v2",
|
||||||
|
"my-token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://custom.crowdin.com/v2/projects/789",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { getStrings } from "../getStrings.js";
|
||||||
|
|
||||||
|
describe("getStrings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(global, "fetch");
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch strings from a project", async() => {
|
||||||
|
const mockStrings = [
|
||||||
|
{ data: { id: 1, text: "Hello", identifier: "greeting" } },
|
||||||
|
{ data: { id: 2, text: "Goodbye", identifier: "farewell" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: mockStrings });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getStrings(
|
||||||
|
"123",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"test-token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{ id: 1, text: "Hello", identifier: "greeting" },
|
||||||
|
{ id: 2, text: "Goodbye", identifier: "farewell" },
|
||||||
|
]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500",
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: "Bearer test-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle pagination when there are 500+ strings", async() => {
|
||||||
|
const strings500 = Array.from({ length: 500 }, (_, index) => {
|
||||||
|
return { data: { id: index, text: `String ${String(index)}` } };
|
||||||
|
});
|
||||||
|
const stringsRemaining = [
|
||||||
|
{ data: { id: 500, text: "String 500" } },
|
||||||
|
{ data: { id: 501, text: "String 501" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: strings500 });
|
||||||
|
},
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: stringsRemaining });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getStrings(
|
||||||
|
"123",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(502);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://api.crowdin.com/api/v2/projects/123/strings?limit=500&offset=500",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array when no strings exist", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: [] });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getStrings(
|
||||||
|
"456",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should unwrap the data from each string object", async() => {
|
||||||
|
const mockStrings = [
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
context: "greeting context",
|
||||||
|
id: 1,
|
||||||
|
identifier: "hello",
|
||||||
|
isHidden: false,
|
||||||
|
text: "Hello World",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: mockStrings });
|
||||||
|
},
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await getStrings(
|
||||||
|
"789",
|
||||||
|
"https://api.crowdin.com/api/v2",
|
||||||
|
"token",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result[0]).toEqual({
|
||||||
|
context: "greeting context",
|
||||||
|
id: 1,
|
||||||
|
identifier: "hello",
|
||||||
|
isHidden: false,
|
||||||
|
text: "Hello World",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||||
|
import type { CategoryGet, CategoryList } from "../interfaces/discourse.js";
|
||||||
|
|
||||||
|
const discourseUrl = process.env.DISCOURSE_URL;
|
||||||
|
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||||
|
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||||
|
|
||||||
|
if (
|
||||||
|
discourseUrl === undefined
|
||||||
|
|| discourseApiKey === undefined
|
||||||
|
|| discourseApiUsername === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryResponse = await backoffAndRetry<CategoryList>(
|
||||||
|
`${discourseUrl}/categories.json`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (categoryResponse === null) {
|
||||||
|
throw new Error("Failed to get categories");
|
||||||
|
}
|
||||||
|
const categories = categoryResponse;
|
||||||
|
|
||||||
|
// Collect all categories including subcategories
|
||||||
|
const allCategories: Array<{ id: number; name: string }> = [];
|
||||||
|
const processedIds = new Set<number>();
|
||||||
|
|
||||||
|
// Process top-level categories and their subcategories
|
||||||
|
for (const category of categories.category_list.categories) {
|
||||||
|
// Add top-level category if not already processed
|
||||||
|
if (!processedIds.has(category.id)) {
|
||||||
|
allCategories.push({ id: category.id, name: category.name });
|
||||||
|
processedIds.add(category.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(category.subcategory_ids)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subcategories if they exist
|
||||||
|
for (const subcategoryId of category.subcategory_ids) {
|
||||||
|
if (!processedIds.has(subcategoryId)) {
|
||||||
|
const subcategoryRequest = await backoffAndRetry<CategoryGet>(
|
||||||
|
`${discourseUrl}/c/${subcategoryId.toString()}/show.json`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (subcategoryRequest === null) {
|
||||||
|
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (subcategoryRequest.category?.name === undefined) {
|
||||||
|
console.error(`Failed to get subcategory ${subcategoryId.toString()}`);
|
||||||
|
console.log(Object.keys(subcategoryRequest));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
allCategories.push({
|
||||||
|
id: subcategoryId,
|
||||||
|
name: subcategoryRequest.category.name,
|
||||||
|
});
|
||||||
|
processedIds.add(subcategoryId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all categories (including subcategories)
|
||||||
|
for (const category of allCategories) {
|
||||||
|
const { id, name } = category;
|
||||||
|
console.log(`Updating category ${id.toString()}: ${name}`);
|
||||||
|
const updateRequest = await backoffAndRetry<CategoryGet>(
|
||||||
|
`${discourseUrl}/categories/${id.toString()}`,
|
||||||
|
{
|
||||||
|
body: JSON.stringify({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||||
|
auto_close_based_on_last_post: true,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required body format.
|
||||||
|
auto_close_hours: 672,
|
||||||
|
name: name,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (updateRequest === null) {
|
||||||
|
console.error(`Failed to update category ${id.toString()}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (updateRequest.category?.auto_close_hours === undefined) {
|
||||||
|
console.error(`Failed to update category ${id.toString()}`);
|
||||||
|
console.error(JSON.stringify(updateRequest, null, 2));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const { auto_close_hours: returnedAutoCloseHours } = updateRequest.category;
|
||||||
|
console.log(
|
||||||
|
`Updated category ${id.toString()}: ${name} to auto_close after ${returnedAutoCloseHours.toString()} hours`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { backoffAndRetry } from "../utils/backoffAndRetry.js";
|
||||||
|
import { sleep } from "../utils/sleep.js";
|
||||||
|
import type { Topic, TopicList } from "../interfaces/discourse.js";
|
||||||
|
|
||||||
|
const discourseUrl = process.env.DISCOURSE_URL;
|
||||||
|
const discourseApiKey = process.env.DISCOURSE_API_KEY;
|
||||||
|
const discourseApiUsername = process.env.DISCOURSE_API_USERNAME;
|
||||||
|
|
||||||
|
if (
|
||||||
|
discourseUrl === undefined
|
||||||
|
|| discourseApiKey === undefined
|
||||||
|
|| discourseApiUsername === undefined
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"DISCOURSE_URL, DISCOURSE_API_KEY, or DISCOURSE_API_USERNAME is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiHeaders = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Key": discourseApiKey,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Api-Username": discourseApiUsername,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single page of topics from Discourse.
|
||||||
|
* @param page - The page number to fetch.
|
||||||
|
* @returns The topics from that page, or null if the request failed.
|
||||||
|
*/
|
||||||
|
const fetchTopicsPage = async(page: number): Promise<Array<Topic> | null> => {
|
||||||
|
const url = `${discourseUrl}/latest.json?page=${page.toString()}`;
|
||||||
|
console.log(`Fetching topics page ${page.toString()}...`);
|
||||||
|
|
||||||
|
const response = await backoffAndRetry<TopicList>(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: apiHeaders,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response === null) {
|
||||||
|
console.error(`Failed to fetch page ${page.toString()}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { topic_list: topicList } = response;
|
||||||
|
const { topics } = topicList;
|
||||||
|
console.log(
|
||||||
|
`Page ${page.toString()}: Received ${topics.length.toString()} topics`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return topics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the last activity date for a topic.
|
||||||
|
* @param topic - The topic to get the activity date for.
|
||||||
|
* @returns The date when the topic was last active.
|
||||||
|
*/
|
||||||
|
const getLastActivityDate = (topic: Topic): Date => {
|
||||||
|
if (topic.last_posted_at !== null) {
|
||||||
|
return new Date(topic.last_posted_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (topic.bumped_at !== null) {
|
||||||
|
return new Date(topic.bumped_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(topic.created_at);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches open topics from Discourse until we hit topics older than 6 months.
|
||||||
|
* The /latest.json endpoint returns topics in a nested structure, so we need
|
||||||
|
* to handle it differently than standard paginated endpoints.
|
||||||
|
* @param sixMonthsAgo - The date cutoff for stopping pagination (6 months ago).
|
||||||
|
* @returns Open topics that are newer than 6 months old.
|
||||||
|
*/
|
||||||
|
const fetchAllOpenTopics = async(sixMonthsAgo: Date): Promise<Array<Topic>> => {
|
||||||
|
const allTopics: Array<Topic> = [];
|
||||||
|
let page = 0;
|
||||||
|
let topics: Array<Topic> | null = await fetchTopicsPage(page);
|
||||||
|
|
||||||
|
while (topics !== null && topics.length > 0) {
|
||||||
|
let foundOldTopic = false;
|
||||||
|
|
||||||
|
for (const topic of topics) {
|
||||||
|
// Skip closed topics
|
||||||
|
if (topic.closed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastActivityDate = getLastActivityDate(topic);
|
||||||
|
|
||||||
|
// If we hit a topic older than 6 months, stop fetching
|
||||||
|
if (lastActivityDate < sixMonthsAgo) {
|
||||||
|
foundOldTopic = true;
|
||||||
|
console.log(
|
||||||
|
`Found topic older than 6 months (${lastActivityDate.toISOString()}), stopping pagination.`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
allTopics.push(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundOldTopic) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue to next page
|
||||||
|
page = page + 1;
|
||||||
|
topics = await fetchTopicsPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Total open topics fetched: ${allTopics.length.toString()}`);
|
||||||
|
return allTopics;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes a topic by ID.
|
||||||
|
* @param topicId - The ID of the topic to close.
|
||||||
|
* @param topicTitle - The title of the topic (for logging).
|
||||||
|
* @returns Whether the topic was successfully closed.
|
||||||
|
*/
|
||||||
|
const closeTopic = async(
|
||||||
|
topicId: number,
|
||||||
|
topicTitle: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const url = `${discourseUrl}/t/${topicId.toString()}/status`;
|
||||||
|
console.log(`Closing topic ${topicId.toString()}: ${topicTitle}`);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
status: "closed",
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
...apiHeaders,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Required header format.
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
console.log("Rate limited, waiting 5 seconds...");
|
||||||
|
await sleep(5000);
|
||||||
|
return await closeTopic(topicId, topicTitle);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
`Failed to close topic ${topicId.toString()}: ${response.status.toString()} ${response.statusText}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully closed topic ${topicId.toString()}: ${topicTitle}`);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
const daysInactive = 28;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approximately 6 months in days.
|
||||||
|
*/
|
||||||
|
const daysSixMonths = 180;
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - daysInactive);
|
||||||
|
|
||||||
|
const sixMonthsAgo = new Date();
|
||||||
|
sixMonthsAgo.setDate(sixMonthsAgo.getDate() - daysSixMonths);
|
||||||
|
|
||||||
|
console.log(`Fetching open topics (stopping at topics older than 6 months)...`);
|
||||||
|
console.log(
|
||||||
|
`Will close topics inactive for ${daysInactive.toString()}+ days but less than ${daysSixMonths.toString()} days old`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Cutoff date: ${cutoffDate.toISOString()} (topics inactive for ${daysInactive.toString()} days)`,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Stop fetching at: ${sixMonthsAgo.toISOString()} (6 months ago)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTopics = await fetchAllOpenTopics(sixMonthsAgo);
|
||||||
|
|
||||||
|
console.log(`\nChecking ${openTopics.length.toString()} open topics for inactivity...`);
|
||||||
|
|
||||||
|
const topicsToClose: Array<Topic> = [];
|
||||||
|
|
||||||
|
for (const topic of openTopics) {
|
||||||
|
const lastActivityDate = getLastActivityDate(topic);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only close topics that are inactive for 28+ days but less than 6 months old.
|
||||||
|
*/
|
||||||
|
if (lastActivityDate < cutoffDate && lastActivityDate >= sixMonthsAgo) {
|
||||||
|
topicsToClose.push(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${topicsToClose.length.toString()} topics to close\n`);
|
||||||
|
|
||||||
|
if (topicsToClose.length === 0) {
|
||||||
|
console.log("No topics need to be closed.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close topics with rate limiting
|
||||||
|
let closedCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const topic of topicsToClose) {
|
||||||
|
const success = await closeTopic(topic.id, topic.title);
|
||||||
|
if (success) {
|
||||||
|
closedCount = closedCount + 1;
|
||||||
|
} else {
|
||||||
|
failedCount = failedCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a small delay between requests to respect rate limits.
|
||||||
|
* (backoffAndRetry handles 429s, but we want to avoid hitting them).
|
||||||
|
*/
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nCompleted:`);
|
||||||
|
console.log(` Closed: ${closedCount.toString()}`);
|
||||||
|
console.log(` Failed: ${failedCount.toString()}`);
|
||||||
|
console.log(` Total: ${topicsToClose.length.toString()}`);
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { input, select } from "@inquirer/prompts";
|
||||||
|
import { paginatedFetch } from "../utils/paginatedFetch.js";
|
||||||
|
import type { File, Repository } from "../interfaces/gitea.js";
|
||||||
|
|
||||||
|
const giteaToken = process.env.GITEA_TOKEN;
|
||||||
|
if (giteaToken === undefined) {
|
||||||
|
throw new Error("GITEA_TOKEN is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const giteaUrl = "https://git.nhcarrigan.com";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will be something like "actions.yml" or "gitea/actions.yml".
|
||||||
|
*/
|
||||||
|
const fileName = await input({
|
||||||
|
message:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'actions.yml' or 'gitea/actions.yml'",
|
||||||
|
});
|
||||||
|
if (fileName === "") {
|
||||||
|
throw new Error("File name is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await readFile(
|
||||||
|
join(import.meta.dirname, "..", "..", "data", fileName),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Will be something like ".gitea/workflows/security.yml".
|
||||||
|
*/
|
||||||
|
const uploadPath = await input({
|
||||||
|
message:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: '.gitea/workflows/security.yml'",
|
||||||
|
});
|
||||||
|
if (uploadPath === "") {
|
||||||
|
throw new Error("Upload path is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file path to check for in each repo to determine if upload should proceed.
|
||||||
|
* Will be something like ".gitea/workflows/ci.yml" or "package.json".
|
||||||
|
*/
|
||||||
|
const conditionFilePath = await input({
|
||||||
|
message:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Enter the file path to check for in each repo (condition file). WITHOUT leading slash. Example: '.gitea/workflows/ci.yml' or 'package.json'",
|
||||||
|
});
|
||||||
|
if (conditionFilePath === "") {
|
||||||
|
throw new Error("Condition file path is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to upload when the condition file exists or doesn't exist.
|
||||||
|
*/
|
||||||
|
const uploadCondition = await select({
|
||||||
|
choices: [
|
||||||
|
{ name: "Upload if condition file EXISTS", value: "exists" },
|
||||||
|
{ name: "Upload if condition file DOES NOT EXIST", value: "not_exists" },
|
||||||
|
],
|
||||||
|
message: "When should the upload proceed?",
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgs = [ "nhcarrigan", "nhcarrigan-private", "nhcarrigan-games" ];
|
||||||
|
|
||||||
|
let totalReposProcessed = 0;
|
||||||
|
let totalReposSucceeded = 0;
|
||||||
|
let totalReposFailed = 0;
|
||||||
|
let totalReposSkipped = 0;
|
||||||
|
|
||||||
|
for (const org of orgs) {
|
||||||
|
console.log(`\n=== Fetching repositories for org: ${org} ===`);
|
||||||
|
const repos = await paginatedFetch<Array<Repository>>(`${giteaUrl}/api/v1/orgs/${org}/repos`, 100, { headers: { authorization: `Bearer ${giteaToken}` } });
|
||||||
|
console.log(`Found ${repos.length.toString()} repositories in ${org}`);
|
||||||
|
|
||||||
|
for (const repo of repos) {
|
||||||
|
totalReposProcessed = totalReposProcessed + 1;
|
||||||
|
console.log(`Checking condition file in ${org}/${repo.name}`);
|
||||||
|
|
||||||
|
// Check if condition file exists
|
||||||
|
const conditionFileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${conditionFilePath}`, {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${giteaToken}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditionFileExists = conditionFileResponse.ok;
|
||||||
|
const shouldUpload = uploadCondition === "exists"
|
||||||
|
? conditionFileExists
|
||||||
|
: !conditionFileExists;
|
||||||
|
|
||||||
|
if (!shouldUpload) {
|
||||||
|
totalReposSkipped = totalReposSkipped + 1;
|
||||||
|
console.log(`Skipping ${org}/${repo.name} (condition file ${conditionFileExists
|
||||||
|
? "exists"
|
||||||
|
: "does not exist"}, but upload condition is "${uploadCondition}")`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Condition met for ${org}/${repo.name}, proceeding with upload`);
|
||||||
|
console.log(`Checking if upload file exists in ${org}/${repo.name}`);
|
||||||
|
const fileResponse = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${giteaToken}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
if (fileResponse.ok) {
|
||||||
|
console.log(`File already exists in ${org}/${repo.name}`);
|
||||||
|
const fileData: File = await fileResponse.json();
|
||||||
|
console.log(`Updating ${fileName} in ${org}/${repo.name}`);
|
||||||
|
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
branch: repo.default_branch,
|
||||||
|
content: Buffer.from(file).toString("base64"),
|
||||||
|
message: `feat: automated upload of ${uploadPath}`,
|
||||||
|
sha: fileData.sha,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"authorization": `Bearer ${giteaToken}`,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
totalReposFailed = totalReposFailed + 1;
|
||||||
|
console.error(`Failed to update ${fileName} in ${org}/${repo.name}: ${response.statusText}`);
|
||||||
|
console.error(await response.text());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalReposSucceeded = totalReposSucceeded + 1;
|
||||||
|
console.log(`Updated ${fileName} in ${org}/${repo.name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`Uploading ${fileName} to ${org}/${repo.name}`);
|
||||||
|
const response = await fetch(`${giteaUrl}/api/v1/repos/${org}/${repo.name}/contents/${uploadPath}`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
branch: repo.default_branch,
|
||||||
|
content: Buffer.from(file).toString("base64"),
|
||||||
|
message: `feat: automated upload of ${uploadPath}`,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"authorization": `Bearer ${giteaToken}`,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- Standard header convention.
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
totalReposFailed = totalReposFailed + 1;
|
||||||
|
console.error(`Failed to upload ${fileName} to ${org}/${repo.name}: ${response.statusText}`);
|
||||||
|
console.error(await response.text());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
totalReposSucceeded = totalReposSucceeded + 1;
|
||||||
|
console.log(`Uploaded ${fileName} to ${org}/${repo.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Summary ===`);
|
||||||
|
console.log(`Total repositories processed: ${totalReposProcessed.toString()}`);
|
||||||
|
console.log(`Successfully uploaded/updated: ${totalReposSucceeded.toString()}`);
|
||||||
|
console.log(`Failed: ${totalReposFailed.toString()}`);
|
||||||
|
console.log(`Skipped (condition not met): ${totalReposSkipped.toString()}`);
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Octokit } from "@octokit/rest";
|
||||||
|
|
||||||
|
if (process.env.GITHUB_TOKEN === undefined) {
|
||||||
|
throw new Error("Missing Github Token - did you run this with `op`?");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change this to the desired organization name.
|
||||||
|
*/
|
||||||
|
const orgName = "freeCodeCamp-Alpha-and-Omega";
|
||||||
|
|
||||||
|
const gh = new Octokit({
|
||||||
|
auth: process.env.GITHUB_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const storiesDirectory = join(
|
||||||
|
import.meta.dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"data",
|
||||||
|
"stories",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read all markdown files from the stories directory
|
||||||
|
const files = await readdir(storiesDirectory);
|
||||||
|
const markdownFiles = files.filter((file) => {
|
||||||
|
return file.endsWith(".md");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${markdownFiles.length.toString()} story files to process.`);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const file of markdownFiles) {
|
||||||
|
// Parse filename: format is "{repo-name}-{issue-number}.md"
|
||||||
|
const filenameRegex = /^(?<repoName>.+)-(?<issueNumber>\d+)\.md$/u;
|
||||||
|
const match = filenameRegex.exec(file);
|
||||||
|
const groups = match?.groups;
|
||||||
|
if (!groups) {
|
||||||
|
console.error(`Skipping ${file}: filename format not recognized (expected: repo-name-issue-number.md)`);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { issueNumber: issueNumberString, repoName } = groups;
|
||||||
|
if (
|
||||||
|
repoName === undefined
|
||||||
|
|| issueNumberString === undefined
|
||||||
|
|| repoName === ""
|
||||||
|
|| issueNumberString === ""
|
||||||
|
) {
|
||||||
|
console.error(`Skipping ${file}: failed to extract repo name or issue number`);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueNumber = Number.parseInt(issueNumberString, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(issueNumber)) {
|
||||||
|
console.error(`Skipping ${file}: invalid issue number ${issueNumberString}`);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file content
|
||||||
|
const filePath = join(storiesDirectory, file);
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Updating issue #${issueNumber.toString()} in ${orgName}/${repoName}...`);
|
||||||
|
|
||||||
|
await gh.rest.issues.update({
|
||||||
|
body: content,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- API parameter name.
|
||||||
|
issue_number: issueNumber,
|
||||||
|
owner: orgName,
|
||||||
|
repo: repoName,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Successfully updated issue #${issueNumber.toString()} in ${orgName}/${repoName}`);
|
||||||
|
successCount = successCount + 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`✗ Failed to update issue #${issueNumber.toString()} in ${orgName}/${repoName}:`, error);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n=== Summary ===`);
|
||||||
|
console.log(`Total files processed: ${markdownFiles.length.toString()}`);
|
||||||
|
console.log(`Successful updates: ${successCount.toString()}`);
|
||||||
|
console.log(`Errors: ${errorCount.toString()}`);
|
||||||
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- The names of the properties match the API responses. */
|
||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CategoryList {
|
||||||
|
category_list: {
|
||||||
|
can_create_category: boolean;
|
||||||
|
can_create_topic: boolean;
|
||||||
|
categories: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
text_color: string;
|
||||||
|
style_type: string;
|
||||||
|
emoji: string;
|
||||||
|
icon: string;
|
||||||
|
slug: string;
|
||||||
|
topic_count: number;
|
||||||
|
post_count: number;
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
description_text: string;
|
||||||
|
description_excerpt: string;
|
||||||
|
topic_url: string;
|
||||||
|
read_restricted: boolean;
|
||||||
|
permission: number;
|
||||||
|
notification_level: number;
|
||||||
|
can_edit: boolean;
|
||||||
|
topic_template: string;
|
||||||
|
has_children: boolean;
|
||||||
|
subcategory_count: number;
|
||||||
|
sort_order: string;
|
||||||
|
sort_ascending: string;
|
||||||
|
show_subcategory_list: boolean;
|
||||||
|
num_featured_topics: number;
|
||||||
|
default_view: string;
|
||||||
|
subcategory_list_style: string;
|
||||||
|
default_top_period: string;
|
||||||
|
default_list_filter: string;
|
||||||
|
minimum_required_tags: number;
|
||||||
|
navigate_to_first_post_after_read: boolean;
|
||||||
|
topics_day: number;
|
||||||
|
topics_week: number;
|
||||||
|
topics_month: number;
|
||||||
|
topics_year: number;
|
||||||
|
topics_all_time: number;
|
||||||
|
is_uncategorized: boolean;
|
||||||
|
subcategory_ids: Array<number>;
|
||||||
|
subcategory_list: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
uploaded_logo: string;
|
||||||
|
uploaded_logo_dark: string;
|
||||||
|
uploaded_background: string;
|
||||||
|
uploaded_background_dark: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGet {
|
||||||
|
category?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
text_color: string;
|
||||||
|
style_type: string;
|
||||||
|
emoji: string;
|
||||||
|
icon: string;
|
||||||
|
slug: string;
|
||||||
|
topic_count: number;
|
||||||
|
post_count: number;
|
||||||
|
position: number;
|
||||||
|
description: string;
|
||||||
|
description_text: string;
|
||||||
|
description_excerpt: string;
|
||||||
|
topic_url: string;
|
||||||
|
read_restricted: boolean;
|
||||||
|
permission: number;
|
||||||
|
notification_level: number;
|
||||||
|
can_edit: boolean;
|
||||||
|
topic_template: string;
|
||||||
|
form_template_ids: Array<unknown>;
|
||||||
|
has_children: boolean;
|
||||||
|
subcategory_count: number;
|
||||||
|
sort_order: string;
|
||||||
|
sort_ascending: string;
|
||||||
|
show_subcategory_list: boolean;
|
||||||
|
num_featured_topics: number;
|
||||||
|
default_view: string;
|
||||||
|
subcategory_list_style: string;
|
||||||
|
default_top_period: string;
|
||||||
|
default_list_filter: string;
|
||||||
|
minimum_required_tags: number;
|
||||||
|
navigate_to_first_post_after_read: boolean;
|
||||||
|
custom_fields: Record<string, unknown>;
|
||||||
|
allowed_tags: Array<unknown>;
|
||||||
|
allowed_tag_groups: Array<unknown>;
|
||||||
|
allow_global_tags: boolean;
|
||||||
|
required_tag_groups: Array<{
|
||||||
|
name: string;
|
||||||
|
min_count: number;
|
||||||
|
}>;
|
||||||
|
category_setting: {
|
||||||
|
auto_bump_cooldown_days: number;
|
||||||
|
num_auto_bump_daily: number;
|
||||||
|
require_reply_approval: boolean;
|
||||||
|
require_topic_approval: boolean;
|
||||||
|
};
|
||||||
|
category_localizations: Array<unknown>;
|
||||||
|
read_only_banner: string;
|
||||||
|
available_groups: Array<unknown>;
|
||||||
|
auto_close_hours: string;
|
||||||
|
auto_close_based_on_last_post: boolean;
|
||||||
|
allow_unlimited_owner_edits_on_first_post: boolean;
|
||||||
|
default_slow_mode_seconds: string;
|
||||||
|
group_permissions: Array<{
|
||||||
|
permission_type: number;
|
||||||
|
group_name: string;
|
||||||
|
group_id: number;
|
||||||
|
}>;
|
||||||
|
email_in: string;
|
||||||
|
email_in_allow_strangers: boolean;
|
||||||
|
mailinglist_mirror: boolean;
|
||||||
|
all_topics_wiki: boolean;
|
||||||
|
can_delete: boolean;
|
||||||
|
allow_badges: boolean;
|
||||||
|
topic_featured_link_allowed: boolean;
|
||||||
|
search_priority: number;
|
||||||
|
uploaded_logo: string;
|
||||||
|
uploaded_logo_dark: string;
|
||||||
|
uploaded_background: string;
|
||||||
|
uploaded_background_dark: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Topic {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
fancy_title: string;
|
||||||
|
slug: string;
|
||||||
|
posts_count: number;
|
||||||
|
reply_count: number;
|
||||||
|
highest_post_number: number;
|
||||||
|
image_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_posted_at: string | null;
|
||||||
|
bumped: boolean;
|
||||||
|
bumped_at: string | null;
|
||||||
|
archetype: string;
|
||||||
|
unseen: boolean;
|
||||||
|
pinned: boolean;
|
||||||
|
unpinned: boolean | null;
|
||||||
|
visible: boolean;
|
||||||
|
closed: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
bookmarked: boolean | null;
|
||||||
|
liked: boolean | null;
|
||||||
|
tags: Array<string>;
|
||||||
|
tags_descriptions: Record<string, string>;
|
||||||
|
like_count: number;
|
||||||
|
views: number;
|
||||||
|
category_id: number;
|
||||||
|
featured_link: string | null;
|
||||||
|
has_accepted_answer: boolean;
|
||||||
|
posters: Array<{
|
||||||
|
extras: string | null;
|
||||||
|
description: string;
|
||||||
|
user_id: number;
|
||||||
|
primary_group_id: number | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TopicList {
|
||||||
|
topic_list: {
|
||||||
|
can_create_topic: boolean;
|
||||||
|
draft: string | null;
|
||||||
|
draft_key: string;
|
||||||
|
draft_sequence: number;
|
||||||
|
per_page: number;
|
||||||
|
topics: Array<Topic>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CategoryList, CategoryGet, Topic, TopicList };
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
|
import { confirm } from "@inquirer/prompts";
|
||||||
|
import { SingleBar, Presets } from "cli-progress";
|
||||||
|
import { getMimeType } from "../utils/mimeType.js";
|
||||||
|
|
||||||
|
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
|
||||||
|
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||||
|
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint === undefined) {
|
||||||
|
throw new Error("S3_ENDPOINT is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDirectory = join(import.meta.dirname, "..", "..", "data");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively gets all files in a directory.
|
||||||
|
* @param directory - The directory to scan.
|
||||||
|
* @param baseDirectory - The base directory for relative paths.
|
||||||
|
* @returns An array of file paths relative to baseDirectory.
|
||||||
|
*/
|
||||||
|
const getAllFiles = async(
|
||||||
|
directory: string,
|
||||||
|
baseDirectory: string,
|
||||||
|
): Promise<Array<string>> => {
|
||||||
|
const files: Array<string> = [];
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(directory, entry.name);
|
||||||
|
const relativePath = relative(baseDirectory, fullPath);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
const subFiles = await getAllFiles(fullPath, baseDirectory);
|
||||||
|
files.push(...subFiles);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard to check if a value is a record.
|
||||||
|
* @param value - The value to check.
|
||||||
|
* @returns Whether the value is a record.
|
||||||
|
*/
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a tree node into a string representation.
|
||||||
|
* @param node - The tree node to format.
|
||||||
|
* @param prefix - The prefix for the current level.
|
||||||
|
* @param _isLast - Whether this is the last entry (unused but kept for API consistency).
|
||||||
|
* @returns The formatted tree string.
|
||||||
|
*/
|
||||||
|
const formatTree = (
|
||||||
|
node: Record<string, unknown>,
|
||||||
|
prefix = "",
|
||||||
|
_isLast = true,
|
||||||
|
): string => {
|
||||||
|
const entries = Object.entries(node).sort(([ a ], [ b ]) => {
|
||||||
|
const aIsDirectory = typeof node[a] === "object" && node[a] !== null;
|
||||||
|
const bIsDirectory = typeof node[b] === "object" && node[b] !== null;
|
||||||
|
|
||||||
|
// Directories come first
|
||||||
|
if (aIsDirectory && !bIsDirectory) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (!aIsDirectory && bIsDirectory) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
|
||||||
|
for (let index = 0; index < entries.length; index = index + 1) {
|
||||||
|
const entry = entries[index];
|
||||||
|
if (entry === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [ name, value ] = entry;
|
||||||
|
const isLastEntry = index === entries.length - 1;
|
||||||
|
const connector = isLastEntry
|
||||||
|
? "└── "
|
||||||
|
: "├── ";
|
||||||
|
const nextPrefix = isLastEntry
|
||||||
|
? " "
|
||||||
|
: "│ ";
|
||||||
|
|
||||||
|
result = `${result}${prefix}${connector}${name}\n`;
|
||||||
|
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const subTree = formatTree(
|
||||||
|
value,
|
||||||
|
`${prefix}${nextPrefix}`,
|
||||||
|
isLastEntry,
|
||||||
|
);
|
||||||
|
result = `${result}${subTree}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a tree structure from file paths.
|
||||||
|
* @param files - Array of relative file paths.
|
||||||
|
* @returns A tree structure as a string.
|
||||||
|
*/
|
||||||
|
const buildFileTree = (files: Array<string>): string => {
|
||||||
|
const tree: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const parts = file.split("/");
|
||||||
|
let current = tree;
|
||||||
|
|
||||||
|
for (let index = 0; index < parts.length; index = index + 1) {
|
||||||
|
const part = parts[index];
|
||||||
|
if (part === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (index === parts.length - 1) {
|
||||||
|
// Last part is a file
|
||||||
|
current[part] = null;
|
||||||
|
} else {
|
||||||
|
// It's a directory
|
||||||
|
if (!(part in current) || typeof current[part] !== "object") {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
const currentValue = current[part];
|
||||||
|
if (isRecord(currentValue)) {
|
||||||
|
current = currentValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatTree(tree);
|
||||||
|
};
|
||||||
|
|
||||||
|
const files = await getAllFiles(dataDirectory, dataDirectory);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
console.log("No files found in the data directory.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${files.length.toString()} file(s) to upload:\n`);
|
||||||
|
console.log(buildFileTree(files));
|
||||||
|
console.log(`\nTotal: ${files.length.toString()} file(s)\n`);
|
||||||
|
|
||||||
|
const shouldProceed = await confirm({
|
||||||
|
default: false,
|
||||||
|
message: "Do you want to proceed with uploading all these files?",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldProceed) {
|
||||||
|
console.log("Upload cancelled.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
endpoint: endpoint,
|
||||||
|
region: "hel1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bar = new SingleBar({}, Presets.shades_classic);
|
||||||
|
bar.start(files.length, 0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const filePath = join(dataDirectory, file);
|
||||||
|
const fileContent = await readFile(filePath);
|
||||||
|
const contentType = getMimeType(file);
|
||||||
|
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Body: fileContent,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: "nhcarrigan",
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
ContentType: contentType,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Key: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3.send(command);
|
||||||
|
successCount = successCount + 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`\nError uploading ${file}:`, error);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.increment();
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.stop();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nUpload complete! ${successCount.toString()} succeeded, ${errorCount.toString()} failed.`,
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
CopyObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
type ListObjectsV2CommandOutput,
|
||||||
|
S3Client,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { confirm } from "@inquirer/prompts";
|
||||||
|
import { getMimeType } from "../utils/mimeType.js";
|
||||||
|
|
||||||
|
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
|
||||||
|
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint === undefined) {
|
||||||
|
throw new Error("S3_ENDPOINT is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3 = new S3Client({
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
endpoint: endpoint,
|
||||||
|
region: "hel1",
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucket = "nhcarrigan";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all objects in the S3 bucket recursively.
|
||||||
|
* @returns An array of object keys.
|
||||||
|
*/
|
||||||
|
const listAllObjects = async(): Promise<Array<string>> => {
|
||||||
|
const objects: Array<string> = [];
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const command = new ListObjectsV2Command({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucket,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
ContinuationToken: continuationToken ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/no-unnecessary-type-assertion -- AWS SDK type inference issue
|
||||||
|
const response = await s3.send(command) as ListObjectsV2CommandOutput;
|
||||||
|
|
||||||
|
if (response.Contents !== undefined) {
|
||||||
|
for (const object of response.Contents) {
|
||||||
|
if (object.Key !== undefined) {
|
||||||
|
objects.push(object.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = response.NextContinuationToken ?? null;
|
||||||
|
} while (continuationToken !== null);
|
||||||
|
|
||||||
|
return objects;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the content type of an object.
|
||||||
|
* @param key - The S3 object key to check.
|
||||||
|
* @returns The content type, or undefined if not found.
|
||||||
|
*/
|
||||||
|
const getObjectContentType = async(
|
||||||
|
key: string,
|
||||||
|
): Promise<string | undefined> => {
|
||||||
|
const command = new HeadObjectCommand({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucket,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Key: key,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await s3.send(command);
|
||||||
|
return response.ContentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the content type of an object.
|
||||||
|
* @param key - The S3 object key to update.
|
||||||
|
* @param contentType - The new content type to set.
|
||||||
|
*/
|
||||||
|
const updateObjectContentType = async(
|
||||||
|
key: string,
|
||||||
|
contentType: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const command = new CopyObjectCommand({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucket,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
ContentType: contentType,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
CopySource: `${bucket}/${key}`,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Key: key,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
MetadataDirective: "REPLACE",
|
||||||
|
});
|
||||||
|
|
||||||
|
await s3.send(command);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Listing all objects in S3 bucket...");
|
||||||
|
const allObjects = await listAllObjects();
|
||||||
|
console.log(`Found ${allObjects.length.toString()} object(s) to check.\n`);
|
||||||
|
|
||||||
|
let correctedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const objectKey of allObjects) {
|
||||||
|
// Skip directory markers (keys ending with /)
|
||||||
|
if (objectKey.endsWith("/")) {
|
||||||
|
console.log(`Skipping ${objectKey} (directory marker)`);
|
||||||
|
skippedCount = skippedCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-useless-assignment -- What?
|
||||||
|
let currentContentType: string | null = null;
|
||||||
|
try {
|
||||||
|
currentContentType = await getObjectContentType(objectKey) ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
console.error(
|
||||||
|
`Error getting content type for ${objectKey}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedContentType = getMimeType(objectKey);
|
||||||
|
|
||||||
|
// Skip if we don't know the expected type
|
||||||
|
if (expectedContentType === undefined) {
|
||||||
|
console.log(`Skipping ${objectKey} (unknown file type)`);
|
||||||
|
skippedCount = skippedCount + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content type needs correction
|
||||||
|
const needsCorrection = currentContentType === null
|
||||||
|
|| currentContentType === "application/octet-stream"
|
||||||
|
|| currentContentType !== expectedContentType;
|
||||||
|
|
||||||
|
if (needsCorrection) {
|
||||||
|
const message = `\nFile: ${objectKey}\nCurrent type: ${
|
||||||
|
currentContentType ?? "undefined"
|
||||||
|
}\nProposed type: ${expectedContentType}\n\nUpdate this file's content type?`;
|
||||||
|
|
||||||
|
const shouldUpdate = await confirm({
|
||||||
|
default: true,
|
||||||
|
message: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldUpdate) {
|
||||||
|
try {
|
||||||
|
await updateObjectContentType(objectKey, expectedContentType);
|
||||||
|
console.log(`✓ Updated ${objectKey}`);
|
||||||
|
correctedCount = correctedCount + 1;
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
console.error(
|
||||||
|
`Error updating ${objectKey}: ${errorMessage}`,
|
||||||
|
);
|
||||||
|
errorCount = errorCount + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`✗ Skipped ${objectKey}`);
|
||||||
|
skippedCount = skippedCount + 1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`✓ ${objectKey} is already correct: ${currentContentType ?? "undefined"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\nComplete! ${correctedCount.toString()} file(s) corrected, ${
|
||||||
|
skippedCount.toString()
|
||||||
|
} file(s) skipped, ${errorCount.toString()} error(s).`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
S3Client, ListObjectsV2Command, DeleteObjectsCommand,
|
||||||
|
type ListObjectsV2CommandOutput,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
|
import { input, confirm } from "@inquirer/prompts";
|
||||||
|
import { SingleBar, Presets } from "cli-progress";
|
||||||
|
|
||||||
|
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||||
|
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
|
||||||
|
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||||
|
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endpoint === undefined) {
|
||||||
|
throw new Error("S3_ENDPOINT is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bucket name
|
||||||
|
const bucketName = await input({
|
||||||
|
message: "Enter the S3 bucket name:",
|
||||||
|
validate: (value) => {
|
||||||
|
if (value.trim() === "") {
|
||||||
|
return "Bucket name cannot be empty";
|
||||||
|
}
|
||||||
|
// Basic S3 bucket name validation
|
||||||
|
if (!/^[\d.a-z-]+$/.test(value)) {
|
||||||
|
return `Bucket name can only contain lowercase letters, numbers, dots, and hyphens`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create S3 client
|
||||||
|
const s3 = new S3Client({
|
||||||
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
|
endpoint: endpoint,
|
||||||
|
region: "hel1",
|
||||||
|
});
|
||||||
|
|
||||||
|
// First, count total objects to delete
|
||||||
|
console.log("\nCounting objects in bucket...");
|
||||||
|
let totalObjects = 0;
|
||||||
|
let continuationToken: string | null = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const listCommand = new ListObjectsV2Command({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucketName,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
ContinuationToken: continuationToken ?? undefined,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
MaxKeys: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResponse: ListObjectsV2CommandOutput = await s3.send(listCommand);
|
||||||
|
totalObjects = totalObjects + (listResponse.Contents?.length ?? 0);
|
||||||
|
continuationToken = listResponse.NextContinuationToken ?? null;
|
||||||
|
} while (continuationToken !== null);
|
||||||
|
|
||||||
|
if (totalObjects === 0) {
|
||||||
|
console.log("✨ No files found in the bucket!");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nFound ${totalObjects.toString()} object(s) to delete.`);
|
||||||
|
|
||||||
|
// Safety confirmation
|
||||||
|
console.log(`\n⚠️ WARNING: This will DELETE ALL ${totalObjects.toString()} FILES in the bucket "${bucketName}"`);
|
||||||
|
console.log("This action cannot be undone!\n");
|
||||||
|
|
||||||
|
// First confirmation - type bucket name
|
||||||
|
await input({
|
||||||
|
message: `Type the bucket name "${bucketName}" to confirm deletion:`,
|
||||||
|
validate: (value) => {
|
||||||
|
if (value !== bucketName) {
|
||||||
|
return `Please type "${bucketName}" exactly to confirm`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second confirmation - yes/no
|
||||||
|
const finalConfirm = await confirm({
|
||||||
|
default: false,
|
||||||
|
message: "Are you ABSOLUTELY sure you want to delete all files?",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalConfirm) {
|
||||||
|
console.log("❌ Operation cancelled. No files were deleted.");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n🚀 Starting deletion process...\n");
|
||||||
|
|
||||||
|
// Initialize progress bar
|
||||||
|
const bar = new SingleBar({}, Presets.shades_classic);
|
||||||
|
bar.start(totalObjects, 0);
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
continuationToken = null;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// List objects in the bucket
|
||||||
|
const listCommand = new ListObjectsV2Command({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucketName,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
ContinuationToken: continuationToken ?? undefined,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
MaxKeys: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResponse: ListObjectsV2CommandOutput = await s3.send(listCommand);
|
||||||
|
|
||||||
|
if (!listResponse.Contents || listResponse.Contents.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare objects for deletion
|
||||||
|
const objectsToDelete = listResponse.Contents.
|
||||||
|
filter((object) => {
|
||||||
|
return object.Key !== undefined;
|
||||||
|
}).
|
||||||
|
map((object) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
return { Key: object.Key };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete objects in batch
|
||||||
|
const deleteCommand = new DeleteObjectsCommand({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Bucket: bucketName,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Delete: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Objects: objectsToDelete,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Quiet: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteResponse = await s3.send(deleteCommand);
|
||||||
|
|
||||||
|
const deletedCount = deleteResponse.Deleted?.length ?? 0;
|
||||||
|
successCount = successCount + deletedCount;
|
||||||
|
|
||||||
|
// Check for errors
|
||||||
|
if (deleteResponse.Errors && deleteResponse.Errors.length > 0) {
|
||||||
|
errorCount = errorCount + deleteResponse.Errors.length;
|
||||||
|
for (const error of deleteResponse.Errors) {
|
||||||
|
console.error(`\n⚠️ Error deleting ${error.Key ?? ""}: ${error.Message ?? ""}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.increment(deletedCount);
|
||||||
|
|
||||||
|
continuationToken = listResponse.NextContinuationToken ?? null;
|
||||||
|
} while (continuationToken !== null);
|
||||||
|
|
||||||
|
bar.stop();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`\n✅ Delete complete! ${successCount.toString()} succeeded, ${errorCount.toString()} failed.`,
|
||||||
|
);
|
||||||
@@ -4,27 +4,40 @@
|
|||||||
* @author Naomi Carrigan
|
* @author Naomi Carrigan
|
||||||
*/
|
*/
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||||
import { input } from "@inquirer/prompts";
|
import { input } from "@inquirer/prompts";
|
||||||
|
import { getMimeType } from "../utils/mimeType.js";
|
||||||
|
|
||||||
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
|
||||||
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
|
||||||
|
const endpoint = process.env.S3_ENDPOINT;
|
||||||
|
|
||||||
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
if (accessKeyId === undefined || secretAccessKey === undefined) {
|
||||||
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
throw new Error("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endpoint === undefined) {
|
||||||
|
throw new Error("S3_ENDPOINT is not set");
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = await input({
|
const fileName = await input({
|
||||||
message: "Enter the ABSOLUTE PATH of the file to upload, including leading slash:",
|
message:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Enter the name of the file to upload. Your file MUST be in the `data` directory in this repository. WITHOUT leading slash. Example: 'naomi.png' or 'img/naomi.png'",
|
||||||
});
|
});
|
||||||
if (fileName === "") {
|
if (fileName === "") {
|
||||||
throw new Error("File name is not set");
|
throw new Error("File name is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = await readFile(fileName);
|
const file = await readFile(
|
||||||
|
join(import.meta.dirname, "..", "..", "data", fileName),
|
||||||
|
);
|
||||||
|
|
||||||
const uploadPath = await input({
|
const uploadPath = await input({
|
||||||
message: "Enter the PATH to upload the file to, WITHOUT leading slash:",
|
message:
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"Enter the PATH to upload the file to, WITHOUT leading slash. Example: 'img/naomi.png' or 'naomi.png':",
|
||||||
});
|
});
|
||||||
if (uploadPath === "") {
|
if (uploadPath === "") {
|
||||||
throw new Error("Upload path is not set");
|
throw new Error("Upload path is not set");
|
||||||
@@ -32,17 +45,27 @@ if (uploadPath === "") {
|
|||||||
|
|
||||||
const s3 = new S3Client({
|
const s3 = new S3Client({
|
||||||
credentials: { accessKeyId, secretAccessKey },
|
credentials: { accessKeyId, secretAccessKey },
|
||||||
endpoint: "https://hel1.your-objectstorage.com",
|
endpoint: endpoint,
|
||||||
region: "hel1",
|
region: "hel1",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const contentType = getMimeType(uploadPath);
|
||||||
|
|
||||||
|
if (contentType === undefined) {
|
||||||
|
console.warn(
|
||||||
|
`Warning: Unknown file type for ${uploadPath}. Content type will not be set.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const command = new PutObjectCommand({
|
const command = new PutObjectCommand({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
Body: file,
|
Body: file,
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
Bucket: "nhcarrigan",
|
Bucket: "nhcarrigan",
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
Key: uploadPath,
|
ContentType: contentType,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||||
|
Key: uploadPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
await s3.send(command);
|
await s3.send(command);
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { backoffAndRetry } from "../backoffAndRetry.js";
|
||||||
|
|
||||||
|
describe("backoffAndRetry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.spyOn(global, "fetch");
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return JSON data on successful response", async() => {
|
||||||
|
const mockData = { success: true, data: "test" };
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockData);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await backoffAndRetry<typeof mockData>(
|
||||||
|
"https://example.com/api",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/api",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass options to fetch", async() => {
|
||||||
|
const mockData = { result: "success" };
|
||||||
|
const options: RequestInit = {
|
||||||
|
headers: { authorization: "Bearer token" },
|
||||||
|
method: "POST",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockData);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await backoffAndRetry("https://example.com/api", options);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/api",
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retry after 5 seconds on 429 response", async() => {
|
||||||
|
const mockData = { result: "success" };
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockData);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const resultPromise = backoffAndRetry<typeof mockData>(
|
||||||
|
"https://example.com/api",
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple 429 responses with backoff", async() => {
|
||||||
|
const mockData = { result: "finally" };
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockData);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const resultPromise = backoffAndRetry<typeof mockData>(
|
||||||
|
"https://example.com/api",
|
||||||
|
);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null on non-429 error response", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await backoffAndRetry("https://example.com/api");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null on fetch error", async() => {
|
||||||
|
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
const result = await backoffAndRetry("https://example.com/api");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null on JSON parse error", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.reject(new Error("Invalid JSON"));
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await backoffAndRetry("https://example.com/api");
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getMimeType } from "../mimeType.js";
|
||||||
|
|
||||||
|
describe("getMimeType", () => {
|
||||||
|
describe("image types", () => {
|
||||||
|
it("should return image/png for .png files", () => {
|
||||||
|
expect(getMimeType("image.png")).toBe("image/png");
|
||||||
|
expect(getMimeType("/path/to/image.png")).toBe("image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/jpeg for .jpg and .jpeg files", () => {
|
||||||
|
expect(getMimeType("photo.jpg")).toBe("image/jpeg");
|
||||||
|
expect(getMimeType("photo.jpeg")).toBe("image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/gif for .gif files", () => {
|
||||||
|
expect(getMimeType("animation.gif")).toBe("image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/webp for .webp files", () => {
|
||||||
|
expect(getMimeType("image.webp")).toBe("image/webp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/svg+xml for .svg files", () => {
|
||||||
|
expect(getMimeType("icon.svg")).toBe("image/svg+xml");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/bmp for .bmp files", () => {
|
||||||
|
expect(getMimeType("bitmap.bmp")).toBe("image/bmp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/tiff for .tif and .tiff files", () => {
|
||||||
|
expect(getMimeType("scan.tif")).toBe("image/tiff");
|
||||||
|
expect(getMimeType("scan.tiff")).toBe("image/tiff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return image/x-icon for .ico files", () => {
|
||||||
|
expect(getMimeType("favicon.ico")).toBe("image/x-icon");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("video types", () => {
|
||||||
|
it("should return video/mp4 for .mp4 files", () => {
|
||||||
|
expect(getMimeType("video.mp4")).toBe("video/mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return video/webm for .webm files", () => {
|
||||||
|
expect(getMimeType("video.webm")).toBe("video/webm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return video/x-msvideo for .avi files", () => {
|
||||||
|
expect(getMimeType("video.avi")).toBe("video/x-msvideo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return video/quicktime for .mov files", () => {
|
||||||
|
expect(getMimeType("video.mov")).toBe("video/quicktime");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return video/x-matroska for .mkv files", () => {
|
||||||
|
expect(getMimeType("video.mkv")).toBe("video/x-matroska");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("audio types", () => {
|
||||||
|
it("should return audio/mpeg for .mp3 files", () => {
|
||||||
|
expect(getMimeType("song.mp3")).toBe("audio/mpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return audio/wav for .wav files", () => {
|
||||||
|
expect(getMimeType("sound.wav")).toBe("audio/wav");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return audio/ogg for .ogg files", () => {
|
||||||
|
expect(getMimeType("audio.ogg")).toBe("audio/ogg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return audio/aac for .aac files", () => {
|
||||||
|
expect(getMimeType("audio.aac")).toBe("audio/aac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return audio/flac for .flac files", () => {
|
||||||
|
expect(getMimeType("music.flac")).toBe("audio/flac");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("document types", () => {
|
||||||
|
it("should return application/pdf for .pdf files", () => {
|
||||||
|
expect(getMimeType("document.pdf")).toBe("application/pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/json for .json files", () => {
|
||||||
|
expect(getMimeType("data.json")).toBe("application/json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/plain for .txt files", () => {
|
||||||
|
expect(getMimeType("readme.txt")).toBe("text/plain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/markdown for .md files", () => {
|
||||||
|
expect(getMimeType("README.md")).toBe("text/markdown");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/html for .html and .htm files", () => {
|
||||||
|
expect(getMimeType("page.html")).toBe("text/html");
|
||||||
|
expect(getMimeType("page.htm")).toBe("text/html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/css for .css files", () => {
|
||||||
|
expect(getMimeType("styles.css")).toBe("text/css");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/javascript for .js files", () => {
|
||||||
|
expect(getMimeType("script.js")).toBe("text/javascript");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return text/csv for .csv files", () => {
|
||||||
|
expect(getMimeType("data.csv")).toBe("text/csv");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/xml for .xml files", () => {
|
||||||
|
expect(getMimeType("data.xml")).toBe("application/xml");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Microsoft Office types", () => {
|
||||||
|
it("should return correct MIME type for .doc files", () => {
|
||||||
|
expect(getMimeType("document.doc")).toBe("application/msword");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .docx files", () => {
|
||||||
|
expect(getMimeType("document.docx")).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .xls files", () => {
|
||||||
|
expect(getMimeType("spreadsheet.xls")).toBe("application/vnd.ms-excel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .xlsx files", () => {
|
||||||
|
expect(getMimeType("spreadsheet.xlsx")).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .ppt files", () => {
|
||||||
|
expect(getMimeType("presentation.ppt")).toBe(
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return correct MIME type for .pptx files", () => {
|
||||||
|
expect(getMimeType("presentation.pptx")).toBe(
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("font types", () => {
|
||||||
|
it("should return font/ttf for .ttf files", () => {
|
||||||
|
expect(getMimeType("font.ttf")).toBe("font/ttf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return font/otf for .otf files", () => {
|
||||||
|
expect(getMimeType("font.otf")).toBe("font/otf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return font/woff for .woff files", () => {
|
||||||
|
expect(getMimeType("font.woff")).toBe("font/woff");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return font/woff2 for .woff2 files", () => {
|
||||||
|
expect(getMimeType("font.woff2")).toBe("font/woff2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/vnd.ms-fontobject for .eot files", () => {
|
||||||
|
expect(getMimeType("font.eot")).toBe("application/vnd.ms-fontobject");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("archive types", () => {
|
||||||
|
it("should return application/zip for .zip files", () => {
|
||||||
|
expect(getMimeType("archive.zip")).toBe("application/zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/gzip for .gz files", () => {
|
||||||
|
expect(getMimeType("archive.gz")).toBe("application/gzip");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/x-tar for .tar files", () => {
|
||||||
|
expect(getMimeType("archive.tar")).toBe("application/x-tar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/x-rar-compressed for .rar files", () => {
|
||||||
|
expect(getMimeType("archive.rar")).toBe("application/x-rar-compressed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return application/x-7z-compressed for .7z files", () => {
|
||||||
|
expect(getMimeType("archive.7z")).toBe("application/x-7z-compressed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("case insensitivity", () => {
|
||||||
|
it("should handle uppercase extensions", () => {
|
||||||
|
expect(getMimeType("IMAGE.PNG")).toBe("image/png");
|
||||||
|
expect(getMimeType("VIDEO.MP4")).toBe("video/mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed case extensions", () => {
|
||||||
|
expect(getMimeType("file.JpG")).toBe("image/jpeg");
|
||||||
|
expect(getMimeType("file.Mp3")).toBe("audio/mpeg");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("unknown types", () => {
|
||||||
|
it("should return undefined for unknown extensions", () => {
|
||||||
|
expect(getMimeType("file.xyz")).toBeUndefined();
|
||||||
|
expect(getMimeType("file.unknown")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for files without extensions", () => {
|
||||||
|
expect(getMimeType("filename")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("path handling", () => {
|
||||||
|
it("should handle full paths", () => {
|
||||||
|
expect(getMimeType("/home/user/documents/image.png")).toBe("image/png");
|
||||||
|
expect(getMimeType("./relative/path/video.mp4")).toBe("video/mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle filenames with multiple dots", () => {
|
||||||
|
expect(getMimeType("file.name.with.dots.png")).toBe("image/png");
|
||||||
|
expect(getMimeType("archive.tar.gz")).toBe("application/gzip");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { paginatedFetch } from "../paginatedFetch.js";
|
||||||
|
|
||||||
|
type TestItem = Record<string, unknown>;
|
||||||
|
|
||||||
|
describe("paginatedFetch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(global, "fetch");
|
||||||
|
vi.spyOn(console, "log").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should fetch a single page when results fit in one request", async() => {
|
||||||
|
const mockData = [
|
||||||
|
{ id: 1, name: "item1" },
|
||||||
|
{ id: 2, name: "item2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(mockData);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockData);
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/api?limit=10&page=1&offset=0",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple pages", async() => {
|
||||||
|
const page1 = [
|
||||||
|
{ id: 1 },
|
||||||
|
{ id: 2 },
|
||||||
|
];
|
||||||
|
const page2 = [
|
||||||
|
{ id: 3 },
|
||||||
|
{ id: 4 },
|
||||||
|
];
|
||||||
|
const page3: Array<TestItem> = [];
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(page1);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(page2);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve(page3);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([ ...page1, ...page2 ]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"https://example.com/api?limit=2&page=1&offset=0",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"https://example.com/api?limit=2&page=2&offset=2",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
"https://example.com/api?limit=2&page=3&offset=4",
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass options to fetch", async() => {
|
||||||
|
const options: RequestInit = {
|
||||||
|
headers: { authorization: "Bearer token" },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([{ id: 1 }]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
10,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw on first page error", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Failed to fetch https://example.com/api?limit=10&page=1&offset=0: 500 Internal Server Error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw if first page is not an array", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ data: "not an array" });
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
paginatedFetch<Array<TestItem>>("https://example.com/api", 10),
|
||||||
|
).rejects.toThrow("Expected array response but got object");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop pagination on subsequent page error", async() => {
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([{ id: 1 }]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ id: 1 }]);
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stop pagination if subsequent page is not an array", async() => {
|
||||||
|
vi.mocked(global.fetch).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([{ id: 1 }]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response).
|
||||||
|
mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve({ notAnArray: true });
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([{ id: 1 }]);
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty first page", async() => {
|
||||||
|
vi.mocked(global.fetch).mockResolvedValueOnce({
|
||||||
|
json: () => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
const result = await paginatedFetch<Array<TestItem>>(
|
||||||
|
"https://example.com/api",
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { serialiseJsonOrError } from "../serialiseJsonOrError.js";
|
||||||
|
|
||||||
|
describe("serialiseJsonOrError", () => {
|
||||||
|
describe("valid JSON parsing", () => {
|
||||||
|
it("should parse a simple object", () => {
|
||||||
|
const result = serialiseJsonOrError('{"key": "value"}');
|
||||||
|
expect(result).toEqual({ key: "value" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse an object with multiple properties", () => {
|
||||||
|
const result = serialiseJsonOrError(
|
||||||
|
'{"name": "test", "count": 42, "active": true}',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ name: "test", count: 42, active: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse nested objects", () => {
|
||||||
|
const result = serialiseJsonOrError(
|
||||||
|
'{"outer": {"inner": {"deep": "value"}}}',
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ outer: { inner: { deep: "value" } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse objects with arrays", () => {
|
||||||
|
const result = serialiseJsonOrError('{"items": [1, 2, 3]}');
|
||||||
|
expect(result).toEqual({ items: [ 1, 2, 3 ] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse objects with null values", () => {
|
||||||
|
const result = serialiseJsonOrError('{"value": null}');
|
||||||
|
expect(result).toEqual({ value: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse an empty object", () => {
|
||||||
|
const result = serialiseJsonOrError("{}");
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse objects with special characters in strings", () => {
|
||||||
|
const result = serialiseJsonOrError('{"text": "hello\\nworld"}');
|
||||||
|
expect(result).toEqual({ text: "hello\nworld" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse objects with unicode characters", () => {
|
||||||
|
const result = serialiseJsonOrError('{"emoji": "🎉", "text": "日本語"}');
|
||||||
|
expect(result).toEqual({ emoji: "🎉", text: "日本語" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse objects with numeric keys (as strings)", () => {
|
||||||
|
const result = serialiseJsonOrError('{"123": "numeric key"}');
|
||||||
|
expect(result).toEqual({ "123": "numeric key" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invalid JSON handling", () => {
|
||||||
|
it("should return null for invalid JSON syntax", () => {
|
||||||
|
expect(serialiseJsonOrError("{invalid}")).toBeNull();
|
||||||
|
expect(serialiseJsonOrError("not json")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for unclosed braces", () => {
|
||||||
|
expect(serialiseJsonOrError('{"key": "value"')).toBeNull();
|
||||||
|
expect(serialiseJsonOrError('{"key": {')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for trailing commas", () => {
|
||||||
|
expect(serialiseJsonOrError('{"key": "value",}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for single quotes", () => {
|
||||||
|
expect(serialiseJsonOrError("{'key': 'value'}")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for unquoted keys", () => {
|
||||||
|
expect(serialiseJsonOrError('{key: "value"}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for empty string", () => {
|
||||||
|
expect(serialiseJsonOrError("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null for whitespace only", () => {
|
||||||
|
expect(serialiseJsonOrError(" ")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should parse a plain array (returns as object)", () => {
|
||||||
|
const result = serialiseJsonOrError("[1, 2, 3]");
|
||||||
|
expect(result).toEqual([ 1, 2, 3 ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse primitive values", () => {
|
||||||
|
expect(serialiseJsonOrError("42")).toBe(42);
|
||||||
|
expect(serialiseJsonOrError('"string"')).toBe("string");
|
||||||
|
expect(serialiseJsonOrError("true")).toBe(true);
|
||||||
|
expect(serialiseJsonOrError("false")).toBe(false);
|
||||||
|
expect(serialiseJsonOrError("null")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle very large numbers", () => {
|
||||||
|
const result = serialiseJsonOrError('{"big": 9007199254740991}');
|
||||||
|
expect(result).toEqual({ big: 9007199254740991 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle scientific notation", () => {
|
||||||
|
const result = serialiseJsonOrError('{"sci": 1.23e10}');
|
||||||
|
expect(result).toEqual({ sci: 1.23e10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle deeply nested structures", () => {
|
||||||
|
const deepJson = '{"a":{"b":{"c":{"d":{"e":"deep"}}}}}';
|
||||||
|
const result = serialiseJsonOrError(deepJson);
|
||||||
|
expect(result).toEqual({ a: { b: { c: { d: { e: "deep" } } } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle JSON with whitespace", () => {
|
||||||
|
const result = serialiseJsonOrError(' { "key" : "value" } ');
|
||||||
|
expect(result).toEqual({ key: "value" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { sleep } from "../sleep.js";
|
||||||
|
|
||||||
|
describe("sleep", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should resolve after the specified time", async() => {
|
||||||
|
const sleepPromise = sleep(1000);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await expect(sleepPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not resolve before the specified time", async() => {
|
||||||
|
let resolved = false;
|
||||||
|
const sleepPromise = sleep(1000).then(() => {
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(999);
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
await sleepPromise;
|
||||||
|
|
||||||
|
expect(resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero milliseconds", async() => {
|
||||||
|
const sleepPromise = sleep(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(0);
|
||||||
|
|
||||||
|
await expect(sleepPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle small delays", async() => {
|
||||||
|
const sleepPromise = sleep(10);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
|
||||||
|
await expect(sleepPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle large delays", async() => {
|
||||||
|
const sleepPromise = sleep(60000);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60000);
|
||||||
|
|
||||||
|
await expect(sleepPromise).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("real timer tests", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should actually wait with real timers (short delay)", async() => {
|
||||||
|
const start = Date.now();
|
||||||
|
await sleep(50);
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||||
|
expect(elapsed).toBeLessThan(150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,23 +9,27 @@ import { sleep } from "./sleep.js";
|
|||||||
/**
|
/**
|
||||||
* Wraps the native fetch method in logic to back off
|
* Wraps the native fetch method in logic to back off
|
||||||
* and retry on 429 errors.
|
* and retry on 429 errors.
|
||||||
|
* @type {T} - The type of the response.
|
||||||
* @param url - The URL to fetch.
|
* @param url - The URL to fetch.
|
||||||
* @param options - The fetch options.
|
* @param options - The fetch options.
|
||||||
|
* @returns The response, or null on error.
|
||||||
*/
|
*/
|
||||||
export const backoffAndRetry
|
export const backoffAndRetry
|
||||||
= async(url: string, options: RequestInit = {}): Promise<void> => {
|
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, options);
|
const response = await fetch(url, options);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 429) {
|
if (response.status === 429) {
|
||||||
await sleep(5000);
|
await sleep(5000);
|
||||||
await backoffAndRetry(url, options);
|
return await backoffAndRetry(url, options);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
throw new Error(`Request failed with status ${response.status.toString()}`);
|
throw new Error(`Request failed with status ${response.status.toString()}`);
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- This is a workaround to avoid type errors.
|
||||||
|
return await response.json() as T;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
console.error(`Fetch error: ${JSON.stringify(error, null, 2)}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* @file Interactive script runner for ephemere project.
|
||||||
|
* @copyright 2025 Naomi Carrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { readdirSync, statSync } from "node:fs";
|
||||||
|
import { dirname, join, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { select } from "@inquirer/prompts";
|
||||||
|
|
||||||
|
const currentFilename = fileURLToPath(import.meta.url);
|
||||||
|
const currentDirectory = dirname(currentFilename);
|
||||||
|
|
||||||
|
interface ScriptOption {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTypeScriptCategories = (): Array<string> => {
|
||||||
|
const sourcePath = join(currentDirectory, "..");
|
||||||
|
const entries = readdirSync(sourcePath);
|
||||||
|
|
||||||
|
return entries.
|
||||||
|
filter((entry) => {
|
||||||
|
const fullPath = join(sourcePath, entry);
|
||||||
|
const entryIsDirectory = statSync(fullPath).isDirectory();
|
||||||
|
return entryIsDirectory && entry !== "utils" && entry !== "interfaces";
|
||||||
|
}).
|
||||||
|
sort((a, b) => {
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeScriptScripts = (category: string): Array<ScriptOption> => {
|
||||||
|
const categoryPath = join(currentDirectory, "..", category);
|
||||||
|
const scripts: Array<ScriptOption> = [];
|
||||||
|
|
||||||
|
const walkDirectory = (directory: string): void => {
|
||||||
|
const entries = readdirSync(directory);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(directory, entry);
|
||||||
|
const stat = statSync(fullPath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
walkDirectory(fullPath);
|
||||||
|
} else if (entry.endsWith(".ts") && entry !== "index.ts") {
|
||||||
|
const relativePath = relative(join(currentDirectory, ".."), fullPath);
|
||||||
|
scripts.push({
|
||||||
|
description: relativePath,
|
||||||
|
name: entry.replace(".ts", ""),
|
||||||
|
value: relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walkDirectory(categoryPath);
|
||||||
|
return scripts.sort((a, b) => {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPythonCategories = (): Array<string> => {
|
||||||
|
const pythonPath = join(currentDirectory, "../../../../python");
|
||||||
|
const entries = readdirSync(pythonPath);
|
||||||
|
|
||||||
|
const categories = entries.
|
||||||
|
filter((entry) => {
|
||||||
|
const fullPath = join(pythonPath, entry);
|
||||||
|
const entryIsDirectory = statSync(fullPath).isDirectory();
|
||||||
|
const isNotHidden = !entry.startsWith(".");
|
||||||
|
return entryIsDirectory && isNotHidden && entry !== "__pycache__";
|
||||||
|
}).
|
||||||
|
sort((a, b) => {
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also check for scripts in the root
|
||||||
|
const hasRootScripts = entries.some((entry) => {
|
||||||
|
return entry.endsWith(".py");
|
||||||
|
});
|
||||||
|
if (hasRootScripts) {
|
||||||
|
categories.unshift("(root)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return categories;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPythonScripts = (category: string): Array<ScriptOption> => {
|
||||||
|
const pythonPath = join(currentDirectory, "../../../../python");
|
||||||
|
const searchPath = category === "(root)"
|
||||||
|
? pythonPath
|
||||||
|
: join(pythonPath, category);
|
||||||
|
|
||||||
|
const scripts: Array<ScriptOption> = [];
|
||||||
|
const entries = readdirSync(searchPath);
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.endsWith(".py") && !entry.startsWith("__")) {
|
||||||
|
const relativePath = category === "(root)"
|
||||||
|
? entry
|
||||||
|
: join(category, entry);
|
||||||
|
scripts.push({
|
||||||
|
description: relativePath,
|
||||||
|
name: entry.replace(".py", ""),
|
||||||
|
value: relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scripts.sort((a, b) => {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLanguage = async(): Promise<string> => {
|
||||||
|
return await select({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
description: "Run a TypeScript script",
|
||||||
|
name: "TypeScript",
|
||||||
|
value: "typescript",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Run a Python script",
|
||||||
|
name: "Python",
|
||||||
|
value: "python",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: "Which language would you like to run?",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectCategory = async(categories: Array<string>): Promise<string> => {
|
||||||
|
return await select({
|
||||||
|
choices: categories.map((cat) => {
|
||||||
|
return {
|
||||||
|
name: cat === "(root)"
|
||||||
|
? "Root Directory"
|
||||||
|
: cat.charAt(0).toUpperCase() + cat.slice(1),
|
||||||
|
value: cat,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
message: "Which category?",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildCommand = (language: string, script: string): string => {
|
||||||
|
const environmentPath = join(currentDirectory, "../../../../../prod.env");
|
||||||
|
const typescriptDirectory = join(currentDirectory, "../../../");
|
||||||
|
const pythonDirectory = join(currentDirectory, "../../../../python");
|
||||||
|
|
||||||
|
return language === "typescript"
|
||||||
|
? `cd ${typescriptDirectory} && op run --env-file=${environmentPath} -- pnpm exec tsx src/${script}`
|
||||||
|
: `cd ${pythonDirectory} && op run --env-file=${environmentPath} -- uv run python ${script}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeScript = (script: string, command: string): void => {
|
||||||
|
console.log(`\n✨ Running: ${script}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(command, {
|
||||||
|
shell: "/bin/bash",
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
console.error("\n❌ Script execution failed!");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = async(): Promise<void> => {
|
||||||
|
console.log("🌸 Welcome to Ephemere Script Runner! 💖\n");
|
||||||
|
|
||||||
|
const language = await selectLanguage();
|
||||||
|
|
||||||
|
const categories = language === "typescript"
|
||||||
|
? getTypeScriptCategories()
|
||||||
|
: getPythonCategories();
|
||||||
|
|
||||||
|
if (categories.length === 0) {
|
||||||
|
console.error(`No categories found for ${language}!`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await selectCategory(categories);
|
||||||
|
|
||||||
|
const scripts = language === "typescript"
|
||||||
|
? getTypeScriptScripts(category)
|
||||||
|
: getPythonScripts(category);
|
||||||
|
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
console.error(`No scripts found in ${category}!`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = await select({
|
||||||
|
choices: scripts,
|
||||||
|
message: "Which script would you like to run?",
|
||||||
|
});
|
||||||
|
|
||||||
|
const command = buildCommand(language, script);
|
||||||
|
executeScript(script, command);
|
||||||
|
};
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { extname } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIME type mapping for file extensions.
|
||||||
|
*/
|
||||||
|
/* eslint-disable @typescript-eslint/naming-convention -- File extensions */
|
||||||
|
/* eslint-disable stylistic/key-spacing -- Alignment for readability */
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
".7z": "application/x-7z-compressed",
|
||||||
|
".aac": "audio/aac",
|
||||||
|
".avi": "video/x-msvideo",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".css": "text/css",
|
||||||
|
".csv": "text/csv",
|
||||||
|
".doc": "application/msword",
|
||||||
|
".docx":
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
".flac": "audio/flac",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".gz": "application/gzip",
|
||||||
|
".htm": "text/html",
|
||||||
|
".html": "text/html",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".json": "application/json",
|
||||||
|
".md": "text/markdown",
|
||||||
|
".mkv": "video/x-matroska",
|
||||||
|
".mov": "video/quicktime",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".mp4": "video/mp4",
|
||||||
|
".ogg": "audio/ogg",
|
||||||
|
".otf": "font/otf",
|
||||||
|
".pdf": "application/pdf",
|
||||||
|
".png": "image/png",
|
||||||
|
".ppt": "application/vnd.ms-powerpoint",
|
||||||
|
".pptx":
|
||||||
|
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
".rar": "application/x-rar-compressed",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".tar": "application/x-tar",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".txt": "text/plain",
|
||||||
|
".wav": "audio/wav",
|
||||||
|
".webm": "video/webm",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".xls": "application/vnd.ms-excel",
|
||||||
|
".xlsx":
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
".xml": "application/xml",
|
||||||
|
".zip": "application/zip",
|
||||||
|
};
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention -- File extensions */
|
||||||
|
/* eslint-enable stylistic/key-spacing -- Alignment for readability */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the MIME type for a file based on its extension.
|
||||||
|
* @param filePath - The file name or path.
|
||||||
|
* @returns The MIME type, or undefined if unknown.
|
||||||
|
*/
|
||||||
|
export const getMimeType = (filePath: string): string | undefined => {
|
||||||
|
const extension = extname(filePath).toLowerCase();
|
||||||
|
return mimeTypes[extension];
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* @copyright NHCarrigan
|
||||||
|
* @license Naomi's Public License
|
||||||
|
* @author Naomi Carrigan
|
||||||
|
*/
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: false,
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/__tests__/**/*.test.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "html"],
|
||||||
|
exclude: ["node_modules/", "**/__tests__/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user