generated from nhcarrigan/template
Compare commits
14 Commits
369992d665
...
feat/adb
| Author | SHA1 | Date | |
|---|---|---|---|
|
c829ec97c4
|
|||
|
dac875c413
|
|||
| f5e8deca59 | |||
| 6169eb4577 | |||
| 8826c1c1a5 | |||
| 5f84c5ae44 | |||
| 6b5fa40599 | |||
|
38e7f15d93
|
|||
|
6fe566b3f6
|
|||
|
6acef3b770
|
|||
|
b3a4793243
|
|||
|
30ea4ad79d
|
|||
| 80ebbcc651 | |||
| 4dcd768965 |
@@ -1,10 +1,10 @@
|
||||
# Crowdin
|
||||
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID"
|
||||
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url"
|
||||
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token"
|
||||
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
|
||||
CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
|
||||
CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
|
||||
|
||||
# Github
|
||||
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token"
|
||||
GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
|
||||
|
||||
# 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:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and Test
|
||||
|
||||
dependency-pin-check-typescript:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Source Files
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js v22
|
||||
uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v4
|
||||
- name: Copy TypeScript files to root
|
||||
run: |
|
||||
cp typescript/package.json .
|
||||
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:
|
||||
node-version: 22
|
||||
|
||||
- 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
|
||||
language: typescript
|
||||
dev-dependencies: true
|
||||
peer-dependencies: true
|
||||
optional-dependencies: true
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
dependency-pin-check-python:
|
||||
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
|
||||
run: pnpm run lint
|
||||
typescript:
|
||||
needs: dependency-pin-check-typescript
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Verify Build
|
||||
run: pnpm run build
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.15.0
|
||||
|
||||
- name: Run Tests
|
||||
run: pnpm run test
|
||||
- uses: actions/setup-node@v4
|
||||
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
|
||||
data
|
||||
!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
|
||||
|
||||
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
|
||||
|
||||
@@ -36,4 +148,4 @@ Copyright held by Naomi Carrigan.
|
||||
|
||||
## 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_PROJECT_ID="op://Environment Variables - Development/Scripts/Crowdin Project ID"
|
||||
CROWDIN_API_URL="op://Environment Variables - Development/Scripts/Crowdin API Url"
|
||||
CROWDIN_TOKEN="op://Environment Variables - Development/Scripts/Crowdin Token"
|
||||
CROWDIN_PROJECT_ID="op://Environment Variables - Development/Ephemere/Crowdin Project ID"
|
||||
CROWDIN_API_URL="op://Environment Variables - Development/Ephemere/Crowdin API Url"
|
||||
CROWDIN_TOKEN="op://Environment Variables - Development/Ephemere/Crowdin Token"
|
||||
|
||||
# Github
|
||||
GITHUB_TOKEN="op://Environment Variables - Development/Scripts/GitHub Token"
|
||||
GITHUB_TOKEN="op://Environment Variables - Development/Ephemere/GitHub Token"
|
||||
|
||||
# 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_SECRET="op://Private/Guild Counter/client secret"
|
||||
DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Amari/bot token"
|
||||
|
||||
# AWS
|
||||
AWS_ACCESS_KEY_ID="op://Private/Hetzner/S3 Access Key ID"
|
||||
AWS_SECRET_ACCESS_KEY="op://Private/Hetzner/S3 Secret Access Key"
|
||||
AWS_ACCESS_KEY_ID="op://Private/S3/S3 Access Key ID"
|
||||
AWS_SECRET_ACCESS_KEY="op://Private/S3/S3 Secret Access Key"
|
||||
S3_ENDPOINT="op://Private/S3/S3 Endpoint"
|
||||
|
||||
# Gitea
|
||||
GITEA_TOKEN="op://Private/Gitea/token"
|
||||
|
||||
# DefectDojo
|
||||
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",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"lint": "eslint src --max-warnings 0",
|
||||
"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": [],
|
||||
"author": "",
|
||||
@@ -27,5 +27,9 @@
|
||||
"open": "11.0.0",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
+332
@@ -44,9 +44,20 @@ importers:
|
||||
typescript:
|
||||
specifier: 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:
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -210,10 +221,31 @@ packages:
|
||||
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
|
||||
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':
|
||||
resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -589,9 +621,27 @@ packages:
|
||||
'@types/node':
|
||||
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':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@nhcarrigan/eslint-config@5.2.0':
|
||||
resolution: {integrity: sha512-YpTTqhviKMlRwKF+RC/GYiA5i2jTCmg8uftuiufldneNV5HMbGpTfBbV7tpa8++5mpYJc4+eZaf40QbDiz84dQ==}
|
||||
engines: {node: '>=22', pnpm: '>=9'}
|
||||
@@ -672,6 +722,10 @@ packages:
|
||||
'@octokit/types@14.1.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -1136,6 +1190,15 @@ packages:
|
||||
resolution: {integrity: sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==}
|
||||
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':
|
||||
resolution: {integrity: sha512-7IaENe4NNy33g0iuuy5bHY69JYYRjpv4lMx6H5Wp30W7ez2baLHwxsXF5TM4wa8JDYZt8ut99Ytoj7GiDO01hw==}
|
||||
peerDependencies:
|
||||
@@ -1200,10 +1263,18 @@ packages:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-regex@6.2.2:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@6.2.3:
|
||||
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
are-docs-informative@0.0.2:
|
||||
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1251,6 +1322,9 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.11:
|
||||
resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1434,12 +1508,18 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
electron-to-chromium@1.5.210:
|
||||
resolution: {integrity: sha512-20kSVv1tyNBN2VFsjCIJZfyvxqo7ylHPrJLME040f/030lzNMA7uQNpxtqJjWSNpccD8/2sqe53EAjrFPvQmjw==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
emoji-regex@9.2.2:
|
||||
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
|
||||
|
||||
error-ex@1.3.2:
|
||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
||||
|
||||
@@ -1679,6 +1759,10 @@ packages:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -1722,6 +1806,10 @@ packages:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
hasBin: true
|
||||
|
||||
globals@13.24.0:
|
||||
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -1779,6 +1867,9 @@ packages:
|
||||
hosted-git-info@2.8.9:
|
||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1938,10 +2029,32 @@ packages:
|
||||
isexe@2.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==}
|
||||
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:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2013,9 +2126,19 @@ packages:
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
magic-string@0.30.18:
|
||||
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:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2042,6 +2165,10 @@ packages:
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
minipass@7.1.2:
|
||||
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -2127,6 +2254,9 @@ packages:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
package-json-from-dist@1.0.1:
|
||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2150,6 +2280,10 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
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:
|
||||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2379,6 +2513,9 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
std-env@3.9.0:
|
||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
||||
|
||||
@@ -2390,6 +2527,10 @@ packages:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string-width@5.1.2:
|
||||
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
string.prototype.matchall@4.0.12:
|
||||
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2413,6 +2554,10 @@ packages:
|
||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2443,6 +2588,10 @@ packages:
|
||||
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -2658,6 +2807,14 @@ packages:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2672,6 +2829,11 @@ packages:
|
||||
|
||||
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':
|
||||
dependencies:
|
||||
'@aws-crypto/util': 5.2.0
|
||||
@@ -3157,8 +3319,23 @@ snapshots:
|
||||
js-tokens: 4.0.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@babel/helper-string-parser@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':
|
||||
dependencies:
|
||||
comment-parser: 1.4.1
|
||||
@@ -3451,8 +3628,31 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@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/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))':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-plugin-eslint-comments': 4.4.1(eslint@9.34.0)
|
||||
@@ -3560,6 +3760,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 25.1.0
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@pkgr/core@0.1.2': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.49.0':
|
||||
@@ -4163,6 +4366,25 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.41.0
|
||||
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))':
|
||||
dependencies:
|
||||
'@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@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
|
||||
are-docs-informative@0.0.2: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
@@ -4313,6 +4539,12 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
@@ -4488,10 +4720,14 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
electron-to-chromium@1.5.210: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
||||
error-ex@1.3.2:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
@@ -4900,6 +5136,11 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
optional: true
|
||||
|
||||
@@ -4955,6 +5196,15 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
dependencies:
|
||||
type-fest: 0.20.2
|
||||
@@ -5005,6 +5255,8 @@ snapshots:
|
||||
|
||||
hosted-git-info@2.8.9: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
@@ -5157,6 +5409,27 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -5166,6 +5439,14 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
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@9.0.1: {}
|
||||
@@ -5226,10 +5507,22 @@ snapshots:
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
magic-string@0.30.18:
|
||||
dependencies:
|
||||
'@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: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -5251,6 +5544,8 @@ snapshots:
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
minipass@7.1.2: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
@@ -5352,6 +5647,8 @@ snapshots:
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json-from-dist@1.0.1: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -5374,6 +5671,11 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
@@ -5627,6 +5929,8 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
std-env@3.9.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -5640,6 +5944,12 @@ snapshots:
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
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:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5688,6 +5998,10 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
|
||||
strip-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-indent@3.0.0:
|
||||
@@ -5713,6 +6027,12 @@ snapshots:
|
||||
'@pkgr/core': 0.1.2
|
||||
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: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
@@ -5960,6 +6280,18 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
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:
|
||||
dependencies:
|
||||
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,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.`,
|
||||
);
|
||||
@@ -7,14 +7,20 @@ import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { input } 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 fileName = await input({
|
||||
message:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
@@ -39,16 +45,26 @@ if (uploadPath === "") {
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint: "https://hel1.your-objectstorage.com",
|
||||
endpoint: endpoint,
|
||||
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({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- AWS SDK
|
||||
Body: file,
|
||||
// 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: uploadPath,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
* and retry on 429 errors.
|
||||
* @type {T} - The type of the response.
|
||||
* @param url - The URL to fetch.
|
||||
* @param options - The fetch options.
|
||||
* @returns The response, or null on error.
|
||||
*/
|
||||
export const backoffAndRetry
|
||||
= async(url: string, options: RequestInit = {}): Promise<void> => {
|
||||
= async<T>(url: string, options: RequestInit = {}): Promise<T | null> => {
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
await sleep(5000);
|
||||
await backoffAndRetry(url, options);
|
||||
return;
|
||||
return await backoffAndRetry(url, options);
|
||||
}
|
||||
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) {
|
||||
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