Compare commits
38 Commits
194de75904
...
lnwc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e5f27316e | ||
|
|
1a6ad08079 | ||
|
|
1a999ab00b | ||
|
|
72b453d6dd | ||
|
|
ea24102053 | ||
| fecafc15ee | |||
|
|
b5d30c6427 | ||
|
|
736d8fc7c1 | ||
|
|
8d4c8dfbad | ||
|
|
94ffa69d21 | ||
|
|
146ad754c0 | ||
|
|
cdc415b0c1 | ||
|
|
48868df4d9 | ||
|
|
ac94215f84 | ||
|
|
b47155cc36 | ||
|
|
29a9d521e7 | ||
|
|
2c518fce4a | ||
| f92851b415 | |||
| 466badd326 | |||
| 29b4a9d339 | |||
| b0b9a9e912 | |||
| 6a1834bbd2 | |||
| 63fa44ed2c | |||
| b84ebce8f1 | |||
| b87fb829ca | |||
| 8c1e055a05 | |||
| a3ea1e9bdb | |||
| 94e12041ec | |||
| 07df6d8f0a | |||
| 3ab3b04643 | |||
| 236ec2abbe | |||
| c7bd634ad6 | |||
| 5d6da930df | |||
| c47d1ede7e | |||
| f220694735 | |||
| 918586d6b1 | |||
| 51911f2c48 | |||
| a1d8c9d373 |
129
DEPLOYMENT.md
Normal file
129
DEPLOYMENT.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Deployment and Issue Management Instructions
|
||||||
|
|
||||||
|
## Making Changes and Committing
|
||||||
|
|
||||||
|
### 1. Make Code Changes
|
||||||
|
Edit the necessary files to implement your feature or fix.
|
||||||
|
|
||||||
|
### 2. Commit Your Changes
|
||||||
|
Always use descriptive commit messages with the Claude Code format:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add <files>
|
||||||
|
git commit -m "$(cat <<'EOF'
|
||||||
|
Brief title of the change
|
||||||
|
|
||||||
|
Detailed description of what was changed and why.
|
||||||
|
- Bullet points for key features
|
||||||
|
- More details as needed
|
||||||
|
- Reference issue numbers (e.g., Issue #1)
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Push to Remote
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commenting on Issues
|
||||||
|
|
||||||
|
After implementing a fix or feature that addresses a GitHub/Gitea issue, comment on the issue to document the work:
|
||||||
|
|
||||||
|
### Comment on Issue #1 Example
|
||||||
|
```bash
|
||||||
|
curl -X POST -u "the_bot:4152aOP!" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\":\"Implemented [feature description] in commit [commit-hash].\\n\\nFeatures include:\\n- Feature 1\\n- Feature 2\\n- Feature 3\"}" \
|
||||||
|
https://git.scorpi.us/api/v1/repos/chelsea/balanceboard/issues/1/comments
|
||||||
|
```
|
||||||
|
|
||||||
|
### General Template
|
||||||
|
```bash
|
||||||
|
curl -X POST -u "the_bot:4152aOP!" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"body\":\"Your comment here with \\n for newlines\"}" \
|
||||||
|
https://git.scorpi.us/api/v1/repos/chelsea/balanceboard/issues/ISSUE_NUMBER/comments
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Notes:**
|
||||||
|
- Use `the_bot` as the username with password `4152aOP!`
|
||||||
|
- Escape quotes in JSON with `\"`
|
||||||
|
- Use `\\n` for newlines in the body
|
||||||
|
- Remove apostrophes or escape them carefully to avoid shell parsing issues
|
||||||
|
- Replace `ISSUE_NUMBER` with the actual issue number
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
When you add new database fields:
|
||||||
|
|
||||||
|
1. **Update the Model** (in `models.py`)
|
||||||
|
2. **Create a Migration Script** (e.g., `migrate_password_reset.py`)
|
||||||
|
3. **Run the Migration** before deploying:
|
||||||
|
```bash
|
||||||
|
python3 migrate_password_reset.py
|
||||||
|
```
|
||||||
|
4. **Test Locally** to ensure the migration works
|
||||||
|
5. **Deploy** to production and run the migration there too
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Build and Push
|
||||||
|
```bash
|
||||||
|
# Build the image
|
||||||
|
docker build -t git.scorpi.us/chelsea/balanceboard:latest .
|
||||||
|
|
||||||
|
# Push to registry
|
||||||
|
docker push git.scorpi.us/chelsea/balanceboard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deploy on Server
|
||||||
|
```bash
|
||||||
|
# SSH to server
|
||||||
|
ssh user@reddit.scorpi.us
|
||||||
|
|
||||||
|
# Pull latest image
|
||||||
|
cd /path/to/balanceboard
|
||||||
|
docker-compose pull
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs -f balanceboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### Container Won't Start
|
||||||
|
- Check logs: `docker-compose logs balanceboard`
|
||||||
|
- Verify file permissions in `data/` directory
|
||||||
|
- Ensure all required files are in git (filtersets.json, themes/, static/, etc.)
|
||||||
|
|
||||||
|
### Database Migration Errors
|
||||||
|
- Back up database first
|
||||||
|
- Run migration manually: `python3 migrate_*.py`
|
||||||
|
- Check if columns already exist before re-running
|
||||||
|
|
||||||
|
### 404 Errors for Static Files
|
||||||
|
- Ensure files are committed to git
|
||||||
|
- Rebuild Docker image after adding files
|
||||||
|
- Check volume mounts in docker-compose.yml
|
||||||
|
|
||||||
|
## Checklist for Each Commit
|
||||||
|
|
||||||
|
- [ ] Make code changes
|
||||||
|
- [ ] Test locally
|
||||||
|
- [ ] Run database migrations if needed
|
||||||
|
- [ ] Commit with descriptive message
|
||||||
|
- [ ] Push to git remote
|
||||||
|
- [ ] Comment on related issues
|
||||||
|
- [ ] Build and push Docker image (if needed)
|
||||||
|
- [ ] Deploy to server (if needed)
|
||||||
|
- [ ] Verify deployment works
|
||||||
|
- [ ] Update this README if process changes
|
||||||
368
FILTER_PIPELINE.md
Normal file
368
FILTER_PIPELINE.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
# Filter Pipeline Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
BalanceBoard's Filter Pipeline is a plugin-based content filtering system that provides intelligent categorization, moderation, and ranking of posts using AI-powered analysis with aggressive caching for cost efficiency.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Three-Level Caching System
|
||||||
|
|
||||||
|
1. **Level 1: Memory Cache** (5-minute TTL)
|
||||||
|
- In-memory cache for fast repeated access
|
||||||
|
- Cleared on application restart
|
||||||
|
|
||||||
|
2. **Level 2: AI Analysis Cache** (Permanent, content-hash based)
|
||||||
|
- Stores AI results (categorization, moderation, quality scores)
|
||||||
|
- Keyed by SHA-256 hash of content
|
||||||
|
- Never expires - same content always returns cached results
|
||||||
|
- **Huge cost savings**: Never re-analyze the same content
|
||||||
|
|
||||||
|
3. **Level 3: Filterset Results Cache** (24-hour TTL)
|
||||||
|
- Stores final filter results per filterset
|
||||||
|
- Invalidated when filterset definition changes
|
||||||
|
- Enables instant filterset switching
|
||||||
|
|
||||||
|
### Pipeline Stages
|
||||||
|
|
||||||
|
Posts flow through 4 sequential stages:
|
||||||
|
|
||||||
|
```
|
||||||
|
Raw Post
|
||||||
|
↓
|
||||||
|
1. Categorizer (AI: topic detection, tags)
|
||||||
|
↓
|
||||||
|
2. Moderator (AI: safety, quality, sentiment)
|
||||||
|
↓
|
||||||
|
3. Filter (Rules: apply filterset conditions)
|
||||||
|
↓
|
||||||
|
4. Ranker (Score: quality + recency + source + engagement)
|
||||||
|
↓
|
||||||
|
Filtered & Scored Post
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Stage 1: Categorizer
|
||||||
|
- **Purpose**: Detect topics and assign categories
|
||||||
|
- **AI Model**: Llama 70B (cheap model)
|
||||||
|
- **Caching**: Permanent (by content hash)
|
||||||
|
- **Output**: Categories, category scores, tags
|
||||||
|
|
||||||
|
#### Stage 2: Moderator
|
||||||
|
- **Purpose**: Safety and quality analysis
|
||||||
|
- **AI Model**: Llama 70B (cheap model)
|
||||||
|
- **Caching**: Permanent (by content hash)
|
||||||
|
- **Metrics**:
|
||||||
|
- Violence score (0.0-1.0)
|
||||||
|
- Sexual content score (0.0-1.0)
|
||||||
|
- Hate speech score (0.0-1.0)
|
||||||
|
- Harassment score (0.0-1.0)
|
||||||
|
- Quality score (0.0-1.0)
|
||||||
|
- Sentiment (positive/neutral/negative)
|
||||||
|
|
||||||
|
#### Stage 3: Filter
|
||||||
|
- **Purpose**: Apply filterset rules
|
||||||
|
- **AI**: None (fast rule evaluation)
|
||||||
|
- **Rules Supported**:
|
||||||
|
- `equals`, `not_equals`
|
||||||
|
- `in`, `not_in`
|
||||||
|
- `min`, `max`
|
||||||
|
- `includes_any`, `excludes`
|
||||||
|
|
||||||
|
#### Stage 4: Ranker
|
||||||
|
- **Purpose**: Calculate relevance scores
|
||||||
|
- **Scoring Factors**:
|
||||||
|
- Quality (30%): From Moderator stage
|
||||||
|
- Recency (25%): Age-based decay
|
||||||
|
- Source Tier (25%): Platform reputation
|
||||||
|
- Engagement (20%): Upvotes + comments
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### filter_config.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai": {
|
||||||
|
"enabled": false,
|
||||||
|
"openrouter_key_file": "openrouter_key.txt",
|
||||||
|
"models": {
|
||||||
|
"cheap": "meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"smart": "meta-llama/llama-3.3-70b-instruct"
|
||||||
|
},
|
||||||
|
"parallel_workers": 10,
|
||||||
|
"timeout_seconds": 60
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"enabled": true,
|
||||||
|
"ai_cache_dir": "data/filter_cache",
|
||||||
|
"filterset_cache_ttl_hours": 24
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"default_stages": ["categorizer", "moderator", "filter", "ranker"],
|
||||||
|
"batch_size": 50,
|
||||||
|
"enable_parallel": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### filtersets.json
|
||||||
|
|
||||||
|
Each filterset defines filtering rules:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"safe_content": {
|
||||||
|
"description": "Filter for safe, family-friendly content",
|
||||||
|
"post_rules": {
|
||||||
|
"moderation.flags.is_safe": {"equals": true},
|
||||||
|
"moderation.content_safety.violence": {"max": 0.3},
|
||||||
|
"moderation.content_safety.sexual_content": {"max": 0.2},
|
||||||
|
"moderation.content_safety.hate_speech": {"max": 0.1}
|
||||||
|
},
|
||||||
|
"comment_rules": {
|
||||||
|
"moderation.flags.is_safe": {"equals": true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### User Perspective
|
||||||
|
|
||||||
|
1. Navigate to **Settings → Filters**
|
||||||
|
2. Select a filterset from the dropdown
|
||||||
|
3. Save preferences
|
||||||
|
4. Feed automatically applies your filterset
|
||||||
|
5. Posts sorted by relevance score (highest first)
|
||||||
|
|
||||||
|
### Developer Perspective
|
||||||
|
|
||||||
|
```python
|
||||||
|
from filter_pipeline import FilterEngine
|
||||||
|
|
||||||
|
# Get singleton instance
|
||||||
|
engine = FilterEngine.get_instance()
|
||||||
|
|
||||||
|
# Apply filterset to posts
|
||||||
|
filtered_posts = engine.apply_filterset(
|
||||||
|
posts=raw_posts,
|
||||||
|
filterset_name='safe_content',
|
||||||
|
use_cache=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Access filter metadata
|
||||||
|
for post in filtered_posts:
|
||||||
|
score = post['_filter_score'] # 0.0-1.0
|
||||||
|
categories = post['_filter_categories'] # ['technology', 'programming']
|
||||||
|
tags = post['_filter_tags'] # ['reddit', 'python']
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI Integration
|
||||||
|
|
||||||
|
### Enabling AI
|
||||||
|
|
||||||
|
1. **Get OpenRouter API Key**:
|
||||||
|
- Sign up at https://openrouter.ai
|
||||||
|
- Generate API key
|
||||||
|
|
||||||
|
2. **Configure**:
|
||||||
|
```bash
|
||||||
|
echo "your-api-key-here" > openrouter_key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Enable in config**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ai": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Restart application**
|
||||||
|
|
||||||
|
### Cost Efficiency
|
||||||
|
|
||||||
|
- **Model**: Llama 70B only (~$0.0003/1K tokens)
|
||||||
|
- **Caching**: Permanent AI result cache
|
||||||
|
- **Estimate**: ~$0.001 per post (first time), $0 (cached)
|
||||||
|
- **10,000 posts**: ~$10 first time, ~$0 cached
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
### Benchmarks
|
||||||
|
|
||||||
|
- **With Cache Hit**: < 10ms per post
|
||||||
|
- **With Cache Miss (AI)**: ~500ms per post
|
||||||
|
- **Parallel Processing**: 10 workers (configurable)
|
||||||
|
- **Typical Feed Load**: 100 posts in < 1 second (cached)
|
||||||
|
|
||||||
|
### Cache Hit Rates
|
||||||
|
|
||||||
|
After initial processing:
|
||||||
|
- **AI Cache**: ~95% hit rate (content rarely changes)
|
||||||
|
- **Filterset Cache**: ~80% hit rate (depends on TTL)
|
||||||
|
- **Memory Cache**: ~60% hit rate (5min TTL)
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Cache Statistics
|
||||||
|
|
||||||
|
```python
|
||||||
|
stats = filter_engine.get_cache_stats()
|
||||||
|
# {
|
||||||
|
# 'memory_cache_size': 150,
|
||||||
|
# 'ai_cache_size': 5000,
|
||||||
|
# 'filterset_cache_size': 8,
|
||||||
|
# 'ai_cache_dir': '/app/data/filter_cache',
|
||||||
|
# 'filterset_cache_dir': '/app/data/filter_cache/filtersets'
|
||||||
|
# }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
Filter pipeline logs to `app.log`:
|
||||||
|
|
||||||
|
```
|
||||||
|
INFO - FilterEngine initialized with 5 filtersets
|
||||||
|
DEBUG - Categorizer: Cache hit for a3f5c2e8...
|
||||||
|
DEBUG - Moderator: Analyzed b7d9e1f3... (quality: 0.75)
|
||||||
|
DEBUG - Filter: Post passed filterset 'safe_content'
|
||||||
|
DEBUG - Ranker: Post score=0.82 (q:0.75, r:0.90, s:0.70, e:0.85)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filtersets
|
||||||
|
|
||||||
|
### no_filter
|
||||||
|
- **Description**: No filtering - all content passes
|
||||||
|
- **Use Case**: Default, unfiltered feed
|
||||||
|
- **Rules**: None
|
||||||
|
- **AI**: Disabled
|
||||||
|
|
||||||
|
### safe_content
|
||||||
|
- **Description**: Family-friendly content only
|
||||||
|
- **Use Case**: Safe browsing
|
||||||
|
- **Rules**:
|
||||||
|
- Violence < 0.3
|
||||||
|
- Sexual content < 0.2
|
||||||
|
- Hate speech < 0.1
|
||||||
|
- **AI**: Required
|
||||||
|
|
||||||
|
### tech_only
|
||||||
|
- **Description**: Technology and programming content
|
||||||
|
- **Use Case**: Tech professionals
|
||||||
|
- **Rules**:
|
||||||
|
- Platform: hackernews, reddit, lobsters, stackoverflow
|
||||||
|
- Topics: technology, programming, software (confidence > 0.5)
|
||||||
|
- **AI**: Required
|
||||||
|
|
||||||
|
### high_quality
|
||||||
|
- **Description**: High quality posts only
|
||||||
|
- **Use Case**: Curated feed
|
||||||
|
- **Rules**:
|
||||||
|
- Score ≥ 10
|
||||||
|
- Quality ≥ 0.6
|
||||||
|
- Readability grade ≤ 14
|
||||||
|
- **AI**: Required
|
||||||
|
|
||||||
|
## Plugin System
|
||||||
|
|
||||||
|
### Creating Custom Plugins
|
||||||
|
|
||||||
|
```python
|
||||||
|
from filter_pipeline.plugins import BaseFilterPlugin
|
||||||
|
|
||||||
|
class MyCustomPlugin(BaseFilterPlugin):
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "MyCustomFilter"
|
||||||
|
|
||||||
|
def should_filter(self, post: dict, context: dict = None) -> bool:
|
||||||
|
# Return True to filter OUT (reject) post
|
||||||
|
title = post.get('title', '').lower()
|
||||||
|
return 'spam' in title
|
||||||
|
|
||||||
|
def score(self, post: dict, context: dict = None) -> float:
|
||||||
|
# Return score 0.0-1.0
|
||||||
|
return 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Built-in Plugins
|
||||||
|
|
||||||
|
- **KeywordFilterPlugin**: Blocklist/allowlist filtering
|
||||||
|
- **QualityFilterPlugin**: Length, caps, clickbait detection
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: AI Not Working
|
||||||
|
|
||||||
|
**Check**:
|
||||||
|
1. `filter_config.json`: `"enabled": true`
|
||||||
|
2. OpenRouter API key file exists
|
||||||
|
3. Logs for API errors
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```bash
|
||||||
|
# Test API key
|
||||||
|
curl -H "Authorization: Bearer $(cat openrouter_key.txt)" \
|
||||||
|
https://openrouter.ai/api/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Posts Not Filtered
|
||||||
|
|
||||||
|
**Check**:
|
||||||
|
1. User has filterset selected in settings
|
||||||
|
2. Filterset exists in filtersets.json
|
||||||
|
3. Posts match filter rules
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```python
|
||||||
|
# Check user settings
|
||||||
|
user_settings = json.loads(current_user.settings)
|
||||||
|
print(user_settings.get('filter_set')) # Should not be 'no_filter'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Slow Performance
|
||||||
|
|
||||||
|
**Check**:
|
||||||
|
1. Cache enabled in config
|
||||||
|
2. Cache hit rates
|
||||||
|
3. Parallel processing enabled
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cache": {"enabled": true},
|
||||||
|
"pipeline": {"enable_parallel": true, "parallel_workers": 10}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] Database persistence for FilterResults
|
||||||
|
- [ ] Filter statistics dashboard
|
||||||
|
- [ ] Custom user-defined filtersets
|
||||||
|
- [ ] A/B testing different filter configurations
|
||||||
|
- [ ] Real-time filter updates without restart
|
||||||
|
- [ ] Multi-language support
|
||||||
|
- [ ] Advanced ML models for categorization
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
When adding new filtersets:
|
||||||
|
|
||||||
|
1. Define in `filtersets.json`
|
||||||
|
2. Test with sample posts
|
||||||
|
3. Document rules and use case
|
||||||
|
4. Consider AI requirements
|
||||||
|
|
||||||
|
When adding new stages:
|
||||||
|
|
||||||
|
1. Extend `BaseStage`
|
||||||
|
2. Implement `process()` method
|
||||||
|
3. Use caching where appropriate
|
||||||
|
4. Add to `pipeline_config.json`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0 with commercial licensing option (see LICENSE file)
|
||||||
40
LICENSE
Normal file
40
LICENSE
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
BalanceBoard License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Chelsea. All rights reserved.
|
||||||
|
|
||||||
|
This software is dual-licensed:
|
||||||
|
|
||||||
|
1. OPEN SOURCE LICENSE (AGPL-3.0)
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
2. COMMERCIAL LICENSE
|
||||||
|
|
||||||
|
The copyright holder reserves the right to offer separate commercial licenses
|
||||||
|
to any party for commercial use cases where the AGPL-3.0 terms are not suitable.
|
||||||
|
|
||||||
|
Commercial licenses, once granted, are irrevocable except for breach of the
|
||||||
|
commercial license terms themselves.
|
||||||
|
|
||||||
|
For commercial licensing inquiries, contact: chelsea.lee.woodruff@gmail.com
|
||||||
|
|
||||||
|
CLARIFICATIONS:
|
||||||
|
|
||||||
|
- This dual-licensing only applies to commercial use rights
|
||||||
|
- Non-commercial use is governed solely by AGPL-3.0
|
||||||
|
- The copyright holder does not reserve the right to revoke AGPL-3.0 licenses
|
||||||
|
- Once granted under AGPL-3.0, your rights under that license cannot be revoked
|
||||||
|
- The copyright holder only reserves the right to offer additional commercial licenses
|
||||||
|
|
||||||
|
Full text of AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt
|
||||||
229
README.md
Normal file
229
README.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# BalanceBoard
|
||||||
|
|
||||||
|
A Reddit-style content aggregator that collects posts from multiple platforms (Reddit, Hacker News, RSS feeds) and presents them in a unified, customizable feed.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Multi-Platform Support**: Collect content from Reddit, Hacker News, and RSS feeds
|
||||||
|
- **Automated Polling**: Background service polls sources at configurable intervals
|
||||||
|
- **User Authentication**: Local accounts with bcrypt password hashing and Auth0 OAuth support
|
||||||
|
- **Anonymous Browsing**: Browse public feed without creating an account
|
||||||
|
- **Password Reset**: Secure token-based password reset mechanism
|
||||||
|
- **Customizable Feeds**: Filter and customize content based on your preferences
|
||||||
|
- **Admin Panel**: Manage polling sources, view logs, and configure the system
|
||||||
|
- **Modern UI**: Card-based interface with clean, responsive design
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- PostgreSQL database
|
||||||
|
- Docker (for containerized deployment)
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
```bash
|
||||||
|
git clone https://git.scorpi.us/chelsea/balanceboard.git
|
||||||
|
cd balanceboard
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Set up environment**
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environment variables**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your database credentials and settings
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Initialize the database**
|
||||||
|
```bash
|
||||||
|
python3 -c "from app import app, db; app.app_context().push(); db.create_all()"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run migrations (if needed)**
|
||||||
|
```bash
|
||||||
|
python3 migrate_password_reset.py
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Start the application**
|
||||||
|
```bash
|
||||||
|
python3 app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Access the application**
|
||||||
|
- Open browser to `http://localhost:5000`
|
||||||
|
- Create an account or browse anonymously
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
1. **Build the image**
|
||||||
|
```bash
|
||||||
|
docker build -t git.scorpi.us/chelsea/balanceboard:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Push to registry**
|
||||||
|
```bash
|
||||||
|
docker push git.scorpi.us/chelsea/balanceboard:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Deploy with docker-compose**
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment instructions.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Platform Sources
|
||||||
|
|
||||||
|
Configure available platforms and communities in `platform_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"reddit": {
|
||||||
|
"name": "Reddit",
|
||||||
|
"communities": [
|
||||||
|
{
|
||||||
|
"id": "programming",
|
||||||
|
"name": "r/programming",
|
||||||
|
"description": "Computer programming"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Polling Configuration
|
||||||
|
|
||||||
|
Admins can configure polling sources via the Admin Panel:
|
||||||
|
- **Platform**: reddit, hackernews, or rss
|
||||||
|
- **Source ID**: Subreddit name, or RSS feed URL
|
||||||
|
- **Poll Interval**: How often to check for new content (in minutes)
|
||||||
|
- **Max Posts**: Maximum posts to collect per poll
|
||||||
|
- **Fetch Comments**: Whether to collect comments
|
||||||
|
- **Priority**: low, medium, or high
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Key environment variables (see `.env.example`):
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `SECRET_KEY`: Flask secret key for sessions
|
||||||
|
- `AUTH0_DOMAIN`: Auth0 domain (if using OAuth)
|
||||||
|
- `AUTH0_CLIENT_ID`: Auth0 client ID
|
||||||
|
- `AUTH0_CLIENT_SECRET`: Auth0 client secret
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **Flask Web Server** (`app.py`): Main application server
|
||||||
|
- **Polling Service** (`polling_service.py`): Background scheduler for data collection
|
||||||
|
- **Data Collection** (`data_collection.py`, `data_collection_lib.py`): Platform-specific data fetchers
|
||||||
|
- **Database Models** (`models.py`): SQLAlchemy ORM models
|
||||||
|
- **User Service** (`user_service.py`): User authentication and management
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
- **users**: User accounts with authentication
|
||||||
|
- **poll_sources**: Configured polling sources
|
||||||
|
- **poll_logs**: History of polling activities
|
||||||
|
- **user_sessions**: Active user sessions
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. Polling service checks enabled sources at configured intervals
|
||||||
|
2. Data collection fetchers retrieve posts from platforms
|
||||||
|
3. Posts are normalized to a common schema and stored in `data/posts/`
|
||||||
|
4. Web interface displays posts from the feed
|
||||||
|
5. Users can filter, customize, and interact with content
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Routes
|
||||||
|
- `GET /`: Main feed (anonymous or authenticated)
|
||||||
|
- `GET /login`: Login page
|
||||||
|
- `POST /login`: Authenticate user
|
||||||
|
- `GET /register`: Registration page
|
||||||
|
- `POST /register`: Create new account
|
||||||
|
- `GET /password-reset-request`: Request password reset
|
||||||
|
- `POST /password-reset-request`: Send reset link
|
||||||
|
- `GET /password-reset/<token>`: Reset password form
|
||||||
|
- `POST /password-reset/<token>`: Update password
|
||||||
|
|
||||||
|
### Authenticated Routes
|
||||||
|
- `GET /settings`: User settings
|
||||||
|
- `GET /logout`: Log out
|
||||||
|
|
||||||
|
### Admin Routes
|
||||||
|
- `GET /admin`: Admin panel
|
||||||
|
- `GET /admin/polling`: Manage polling sources
|
||||||
|
- `POST /admin/polling/add`: Add new source
|
||||||
|
- `POST /admin/polling/update`: Update source settings
|
||||||
|
- `POST /admin/polling/poll`: Manually trigger poll
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
balanceboard/
|
||||||
|
├── app.py # Main Flask application
|
||||||
|
├── polling_service.py # Background polling service
|
||||||
|
├── data_collection.py # Data collection orchestration
|
||||||
|
├── data_collection_lib.py # Platform-specific fetchers
|
||||||
|
├── models.py # Database models
|
||||||
|
├── user_service.py # User management
|
||||||
|
├── database.py # Database setup
|
||||||
|
├── platform_config.json # Platform configurations
|
||||||
|
├── filtersets.json # Content filter definitions
|
||||||
|
├── templates/ # Jinja2 templates
|
||||||
|
├── static/ # Static assets (CSS, JS)
|
||||||
|
├── themes/ # UI themes
|
||||||
|
├── data/ # Data storage
|
||||||
|
│ ├── posts/ # Collected posts
|
||||||
|
│ ├── comments/ # Collected comments
|
||||||
|
│ └── moderation/ # Moderation data
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker image definition
|
||||||
|
├── docker-compose.yml # Docker composition
|
||||||
|
├── README.md # This file
|
||||||
|
└── DEPLOYMENT.md # Deployment instructions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Platform
|
||||||
|
|
||||||
|
1. Add platform config to `platform_config.json`
|
||||||
|
2. Implement fetcher in `data_collection_lib.py`:
|
||||||
|
- `fetchers.getPlatformData()`
|
||||||
|
- `converters.platform_to_schema()`
|
||||||
|
- `builders.build_platform_url()`
|
||||||
|
3. Update routing in `getData()` function
|
||||||
|
4. Test data collection
|
||||||
|
5. Add to available sources in admin panel
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Test thoroughly
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
See [DEPLOYMENT.md](DEPLOYMENT.md) for commit and issue management guidelines.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This is a personal project by Chelsea. All rights reserved.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and feature requests, please use the issue tracker at:
|
||||||
|
https://git.scorpi.us/chelsea/balanceboard/issues
|
||||||
579
app.py
579
app.py
@@ -42,6 +42,11 @@ app = Flask(__name__,
|
|||||||
template_folder='templates')
|
template_folder='templates')
|
||||||
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
|
||||||
|
app.config['ALLOW_ANONYMOUS_ACCESS'] = os.getenv('ALLOW_ANONYMOUS_ACCESS', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Application branding configuration
|
||||||
|
app.config['APP_NAME'] = os.getenv('APP_NAME', 'BalanceBoard')
|
||||||
|
app.config['LOGO_PATH'] = os.getenv('LOGO_PATH', 'logo.png')
|
||||||
|
|
||||||
# Auth0 Configuration
|
# Auth0 Configuration
|
||||||
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
app.config['AUTH0_DOMAIN'] = os.getenv('AUTH0_DOMAIN', '')
|
||||||
@@ -50,7 +55,7 @@ app.config['AUTH0_CLIENT_SECRET'] = os.getenv('AUTH0_CLIENT_SECRET', '')
|
|||||||
app.config['AUTH0_AUDIENCE'] = os.getenv('AUTH0_AUDIENCE', '')
|
app.config['AUTH0_AUDIENCE'] = os.getenv('AUTH0_AUDIENCE', '')
|
||||||
|
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
ALLOWED_FILTERSETS = {'no_filter', 'safe_content'}
|
# Note: ALLOWED_FILTERSETS will be dynamically loaded from filter_engine
|
||||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||||
UPLOAD_FOLDER = 'static/avatars'
|
UPLOAD_FOLDER = 'static/avatars'
|
||||||
MAX_FILENAME_LENGTH = 100
|
MAX_FILENAME_LENGTH = 100
|
||||||
@@ -81,6 +86,11 @@ from polling_service import polling_service
|
|||||||
polling_service.init_app(app)
|
polling_service.init_app(app)
|
||||||
polling_service.start()
|
polling_service.start()
|
||||||
|
|
||||||
|
# Initialize filter engine
|
||||||
|
from filter_pipeline import FilterEngine
|
||||||
|
filter_engine = FilterEngine.get_instance()
|
||||||
|
logger.info(f"FilterEngine initialized with {len(filter_engine.get_available_filtersets())} filtersets")
|
||||||
|
|
||||||
# Initialize OAuth for Auth0
|
# Initialize OAuth for Auth0
|
||||||
oauth = OAuth(app)
|
oauth = OAuth(app)
|
||||||
auth0 = oauth.register(
|
auth0 = oauth.register(
|
||||||
@@ -104,7 +114,9 @@ def _is_safe_filterset(filterset):
|
|||||||
"""Validate filterset name for security"""
|
"""Validate filterset name for security"""
|
||||||
if not filterset or not isinstance(filterset, str):
|
if not filterset or not isinstance(filterset, str):
|
||||||
return False
|
return False
|
||||||
return filterset in ALLOWED_FILTERSETS and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
|
# Check against available filtersets from filter_engine
|
||||||
|
allowed_filtersets = set(filter_engine.get_available_filtersets())
|
||||||
|
return filterset in allowed_filtersets and re.match(r'^[a-zA-Z0-9_-]+$', filterset)
|
||||||
|
|
||||||
def _is_safe_path(path):
|
def _is_safe_path(path):
|
||||||
"""Validate file path for security"""
|
"""Validate file path for security"""
|
||||||
@@ -207,10 +219,15 @@ def _validate_user_settings(settings_str):
|
|||||||
exp = settings['experience']
|
exp = settings['experience']
|
||||||
if isinstance(exp, dict):
|
if isinstance(exp, dict):
|
||||||
safe_exp = {}
|
safe_exp = {}
|
||||||
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in']
|
bool_fields = ['infinite_scroll', 'auto_refresh', 'push_notifications', 'dark_patterns_opt_in', 'time_filter_enabled']
|
||||||
for field in bool_fields:
|
for field in bool_fields:
|
||||||
if field in exp and isinstance(exp[field], bool):
|
if field in exp and isinstance(exp[field], bool):
|
||||||
safe_exp[field] = exp[field]
|
safe_exp[field] = exp[field]
|
||||||
|
|
||||||
|
# Handle time_filter_days as integer
|
||||||
|
if 'time_filter_days' in exp and isinstance(exp['time_filter_days'], int) and exp['time_filter_days'] > 0:
|
||||||
|
safe_exp['time_filter_days'] = exp['time_filter_days']
|
||||||
|
|
||||||
validated['experience'] = safe_exp
|
validated['experience'] = safe_exp
|
||||||
|
|
||||||
return validated
|
return validated
|
||||||
@@ -263,9 +280,32 @@ def check_first_user():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_quick_stats():
|
||||||
|
"""Calculate quick stats for dashboard"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
cached_posts, _ = _load_posts_cache()
|
||||||
|
|
||||||
|
# Calculate posts from today (last 24 hours)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
today_start = now - timedelta(hours=24)
|
||||||
|
today_timestamp = today_start.timestamp()
|
||||||
|
|
||||||
|
posts_today = sum(1 for post in cached_posts.values()
|
||||||
|
if post.get('timestamp', 0) >= today_timestamp)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'posts_today': posts_today,
|
||||||
|
'total_posts': len(cached_posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
"""Serve the main feed page"""
|
"""Serve the main feed page"""
|
||||||
|
# Calculate stats
|
||||||
|
quick_stats = calculate_quick_stats()
|
||||||
|
|
||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
# Load user settings
|
# Load user settings
|
||||||
try:
|
try:
|
||||||
@@ -274,7 +314,24 @@ def index():
|
|||||||
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
logger.warning(f"Invalid user settings JSON for user {current_user.id}: {e}")
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
|
|
||||||
return render_template('dashboard.html', user_settings=user_settings)
|
return render_template('dashboard.html', user_settings=user_settings, quick_stats=quick_stats)
|
||||||
|
else:
|
||||||
|
# Check if anonymous access is allowed
|
||||||
|
if app.config.get('ALLOW_ANONYMOUS_ACCESS', False):
|
||||||
|
# Anonymous mode - allow browsing with default settings
|
||||||
|
user_settings = {
|
||||||
|
'filter_set': 'no_filter',
|
||||||
|
'communities': [],
|
||||||
|
'experience': {
|
||||||
|
'infinite_scroll': False,
|
||||||
|
'auto_refresh': False,
|
||||||
|
'push_notifications': False,
|
||||||
|
'dark_patterns_opt_in': False,
|
||||||
|
'time_filter_enabled': False,
|
||||||
|
'time_filter_days': 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return render_template('dashboard.html', user_settings=user_settings, anonymous=True, quick_stats=quick_stats)
|
||||||
else:
|
else:
|
||||||
# Redirect non-authenticated users to login
|
# Redirect non-authenticated users to login
|
||||||
return redirect(url_for('login'))
|
return redirect(url_for('login'))
|
||||||
@@ -338,23 +395,112 @@ def api_posts():
|
|||||||
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
||||||
community = request.args.get('community', '')
|
community = request.args.get('community', '')
|
||||||
platform = request.args.get('platform', '')
|
platform = request.args.get('platform', '')
|
||||||
|
search_query = request.args.get('q', '').lower().strip()
|
||||||
|
filter_override = request.args.get('filter', '')
|
||||||
|
|
||||||
|
# Get user's filterset preference, community selections, and time filter
|
||||||
|
filterset_name = 'no_filter'
|
||||||
|
user_communities = []
|
||||||
|
time_filter_enabled = False
|
||||||
|
time_filter_days = 7
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
try:
|
||||||
|
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
||||||
|
filterset_name = user_settings.get('filter_set', 'no_filter')
|
||||||
|
user_communities = user_settings.get('communities', [])
|
||||||
|
|
||||||
|
experience_settings = user_settings.get('experience', {})
|
||||||
|
time_filter_enabled = experience_settings.get('time_filter_enabled', False)
|
||||||
|
time_filter_days = experience_settings.get('time_filter_days', 7)
|
||||||
|
except:
|
||||||
|
filterset_name = 'no_filter'
|
||||||
|
user_communities = []
|
||||||
|
time_filter_enabled = False
|
||||||
|
time_filter_days = 7
|
||||||
|
|
||||||
|
# Override filterset if specified in request (for sidebar filter switching)
|
||||||
|
if filter_override and _is_safe_filterset(filter_override):
|
||||||
|
filterset_name = filter_override
|
||||||
|
|
||||||
# Use cached data for better performance
|
# Use cached data for better performance
|
||||||
cached_posts, cached_comments = _load_posts_cache()
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
posts = []
|
# Calculate time filter cutoff if enabled
|
||||||
|
time_cutoff = None
|
||||||
|
if time_filter_enabled:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
|
||||||
|
time_cutoff = cutoff_date.timestamp()
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# START OF REFACTORED SECTION
|
||||||
|
# ====================================================================
|
||||||
|
|
||||||
|
def _post_should_be_included(post_data):
|
||||||
|
"""Check if a post passes all pre-filterset criteria."""
|
||||||
|
# Apply time filter
|
||||||
|
if time_filter_enabled and time_cutoff:
|
||||||
|
if post_data.get('timestamp', 0) < time_cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
# Process cached posts
|
|
||||||
for post_uuid, post_data in cached_posts.items():
|
|
||||||
# Apply community filter
|
# Apply community filter
|
||||||
if community and post_data.get('source', '').lower() != community.lower():
|
if community and post_data.get('source', '').lower() != community.lower():
|
||||||
continue
|
return False
|
||||||
|
|
||||||
# Apply platform filter
|
# Apply platform filter
|
||||||
if platform and post_data.get('platform', '').lower() != platform.lower():
|
if platform and post_data.get('platform', '').lower() != platform.lower():
|
||||||
continue
|
return False
|
||||||
|
|
||||||
# Get comment count from cache
|
# Apply user's community preferences
|
||||||
|
if user_communities:
|
||||||
|
post_source = post_data.get('source', '').lower()
|
||||||
|
post_platform = post_data.get('platform', '').lower()
|
||||||
|
if not any(
|
||||||
|
post_source == c or post_platform == c or c in post_source
|
||||||
|
for c in user_communities
|
||||||
|
):
|
||||||
|
# ====================================================================
|
||||||
|
# MODIFICATION: Add logging here
|
||||||
|
# ====================================================================
|
||||||
|
logger.error(
|
||||||
|
f"Post filtered out for user {current_user.id if current_user.is_authenticated else 'anonymous'}: "
|
||||||
|
f"Community mismatch. Platform='{post_platform}', Source='{post_source}', "
|
||||||
|
f"User Communities={user_communities}"
|
||||||
|
)
|
||||||
|
# ====================================================================
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search_query:
|
||||||
|
title = post_data.get('title', '').lower()
|
||||||
|
content = post_data.get('content', '').lower()
|
||||||
|
author = post_data.get('author', '').lower()
|
||||||
|
source = post_data.get('source', '').lower()
|
||||||
|
if not (search_query in title or
|
||||||
|
search_query in content or
|
||||||
|
search_query in author or
|
||||||
|
search_query in source):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Collect raw posts using a clean, declarative list comprehension
|
||||||
|
raw_posts = [
|
||||||
|
post_data for post_data in cached_posts.values()
|
||||||
|
if _post_should_be_included(post_data)
|
||||||
|
]
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# END OF REFACTORED SECTION
|
||||||
|
# ====================================================================
|
||||||
|
|
||||||
|
# Apply filterset using FilterEngine
|
||||||
|
filtered_posts = filter_engine.apply_filterset(raw_posts, filterset_name, use_cache=True)
|
||||||
|
|
||||||
|
# Build response posts with metadata
|
||||||
|
posts = []
|
||||||
|
for post_data in filtered_posts:
|
||||||
|
post_uuid = post_data.get('uuid')
|
||||||
comment_count = len(cached_comments.get(post_uuid, []))
|
comment_count = len(cached_comments.get(post_uuid, []))
|
||||||
|
|
||||||
# Get proper display name for source
|
# Get proper display name for source
|
||||||
@@ -364,7 +510,7 @@ def api_posts():
|
|||||||
platform_config
|
platform_config
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create post object with actual title
|
# Create post object with filter metadata
|
||||||
post = {
|
post = {
|
||||||
'id': post_uuid,
|
'id': post_uuid,
|
||||||
'title': post_data.get('title', 'Untitled'),
|
'title': post_data.get('title', 'Untitled'),
|
||||||
@@ -378,12 +524,16 @@ def api_posts():
|
|||||||
'source': post_data.get('source', ''),
|
'source': post_data.get('source', ''),
|
||||||
'source_display': source_display,
|
'source_display': source_display,
|
||||||
'tags': post_data.get('tags', []),
|
'tags': post_data.get('tags', []),
|
||||||
'external_url': post_data.get('url', '')
|
'external_url': post_data.get('url', ''),
|
||||||
|
# Add filter metadata
|
||||||
|
'filter_score': post_data.get('_filter_score', 0.5),
|
||||||
|
'filter_categories': post_data.get('_filter_categories', []),
|
||||||
|
'filter_tags': post_data.get('_filter_tags', [])
|
||||||
}
|
}
|
||||||
posts.append(post)
|
posts.append(post)
|
||||||
|
|
||||||
# Sort by timestamp (newest first)
|
# Sort by filter score (highest first), then timestamp
|
||||||
posts.sort(key=lambda x: x['timestamp'], reverse=True)
|
posts.sort(key=lambda x: (x['filter_score'], x['timestamp']), reverse=True)
|
||||||
|
|
||||||
# Calculate pagination
|
# Calculate pagination
|
||||||
total_posts = len(posts)
|
total_posts = len(posts)
|
||||||
@@ -518,6 +668,237 @@ def api_content_timestamp():
|
|||||||
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
return jsonify({'error': 'Failed to get content timestamp'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmark', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def api_bookmark():
|
||||||
|
"""Toggle bookmark status for a post"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'post_uuid' not in data:
|
||||||
|
return jsonify({'error': 'Missing post_uuid'}), 400
|
||||||
|
|
||||||
|
post_uuid = data['post_uuid']
|
||||||
|
if not post_uuid:
|
||||||
|
return jsonify({'error': 'Invalid post_uuid'}), 400
|
||||||
|
|
||||||
|
# Check if bookmark already exists
|
||||||
|
existing_bookmark = Bookmark.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing_bookmark:
|
||||||
|
# Remove bookmark
|
||||||
|
db.session.delete(existing_bookmark)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'bookmarked': False, 'message': 'Bookmark removed'})
|
||||||
|
else:
|
||||||
|
# Add bookmark - get post data for caching
|
||||||
|
cached_posts, _ = _load_posts_cache()
|
||||||
|
post_data = cached_posts.get(post_uuid, {})
|
||||||
|
|
||||||
|
bookmark = Bookmark(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid,
|
||||||
|
title=post_data.get('title', ''),
|
||||||
|
platform=post_data.get('platform', ''),
|
||||||
|
source=post_data.get('source', '')
|
||||||
|
)
|
||||||
|
db.session.add(bookmark)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({'bookmarked': True, 'message': 'Bookmark added'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error toggling bookmark: {e}")
|
||||||
|
return jsonify({'error': 'Failed to toggle bookmark'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmarks')
|
||||||
|
@login_required
|
||||||
|
def api_bookmarks():
|
||||||
|
"""Get user's bookmarks"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
per_page = int(request.args.get('per_page', DEFAULT_PAGE_SIZE))
|
||||||
|
|
||||||
|
# Get user's bookmarks with pagination
|
||||||
|
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(Bookmark.created_at.desc())
|
||||||
|
total_bookmarks = bookmarks_query.count()
|
||||||
|
bookmarks = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
# Load current posts cache to get updated data
|
||||||
|
cached_posts, cached_comments = _load_posts_cache()
|
||||||
|
|
||||||
|
# Build response
|
||||||
|
bookmark_posts = []
|
||||||
|
for bookmark in bookmarks:
|
||||||
|
# Try to get current post data, fallback to cached data
|
||||||
|
post_data = cached_posts.get(bookmark.post_uuid)
|
||||||
|
if post_data:
|
||||||
|
# Post still exists in current data
|
||||||
|
comment_count = len(cached_comments.get(bookmark.post_uuid, []))
|
||||||
|
post = {
|
||||||
|
'id': bookmark.post_uuid,
|
||||||
|
'title': post_data.get('title', bookmark.title or 'Untitled'),
|
||||||
|
'author': post_data.get('author', 'Unknown'),
|
||||||
|
'platform': post_data.get('platform', bookmark.platform or 'unknown'),
|
||||||
|
'score': post_data.get('score', 0),
|
||||||
|
'timestamp': post_data.get('timestamp', 0),
|
||||||
|
'url': f'/post/{bookmark.post_uuid}',
|
||||||
|
'comments_count': comment_count,
|
||||||
|
'content_preview': (post_data.get('content', '') or '')[:200] + '...' if post_data.get('content') else '',
|
||||||
|
'source': post_data.get('source', bookmark.source or ''),
|
||||||
|
'bookmarked_at': bookmark.created_at.isoformat(),
|
||||||
|
'external_url': post_data.get('url', '')
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Post no longer in current data, use cached bookmark data
|
||||||
|
post = {
|
||||||
|
'id': bookmark.post_uuid,
|
||||||
|
'title': bookmark.title or 'Untitled',
|
||||||
|
'author': 'Unknown',
|
||||||
|
'platform': bookmark.platform or 'unknown',
|
||||||
|
'score': 0,
|
||||||
|
'timestamp': 0,
|
||||||
|
'url': f'/post/{bookmark.post_uuid}',
|
||||||
|
'comments_count': 0,
|
||||||
|
'content_preview': 'Content no longer available',
|
||||||
|
'source': bookmark.source or '',
|
||||||
|
'bookmarked_at': bookmark.created_at.isoformat(),
|
||||||
|
'external_url': '',
|
||||||
|
'archived': True # Mark as archived
|
||||||
|
}
|
||||||
|
bookmark_posts.append(post)
|
||||||
|
|
||||||
|
total_pages = (total_bookmarks + per_page - 1) // per_page
|
||||||
|
has_next = page < total_pages
|
||||||
|
has_prev = page > 1
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'posts': bookmark_posts,
|
||||||
|
'pagination': {
|
||||||
|
'current_page': page,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'total_posts': total_bookmarks,
|
||||||
|
'per_page': per_page,
|
||||||
|
'has_next': has_next,
|
||||||
|
'has_prev': has_prev
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting bookmarks: {e}")
|
||||||
|
return jsonify({'error': 'Failed to get bookmarks'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/bookmark-status/<post_uuid>')
|
||||||
|
@login_required
|
||||||
|
def api_bookmark_status(post_uuid):
|
||||||
|
"""Check if a post is bookmarked by current user"""
|
||||||
|
try:
|
||||||
|
from models import Bookmark
|
||||||
|
|
||||||
|
bookmark = Bookmark.query.filter_by(
|
||||||
|
user_id=current_user.id,
|
||||||
|
post_uuid=post_uuid
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return jsonify({'bookmarked': bookmark is not None})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking bookmark status: {e}")
|
||||||
|
return jsonify({'error': 'Failed to check bookmark status'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/filters')
|
||||||
|
def api_filters():
|
||||||
|
"""API endpoint to get available filters"""
|
||||||
|
try:
|
||||||
|
filters = []
|
||||||
|
|
||||||
|
# Get current user's filter preference
|
||||||
|
current_filter = 'no_filter'
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
try:
|
||||||
|
user_settings = json.loads(current_user.settings) if current_user.settings else {}
|
||||||
|
current_filter = user_settings.get('filter_set', 'no_filter')
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get available filtersets from filter engine
|
||||||
|
for filterset_name in filter_engine.get_available_filtersets():
|
||||||
|
filterset_config = filter_engine.config.get_filterset(filterset_name)
|
||||||
|
if filterset_config:
|
||||||
|
# Map filter names to icons and display names
|
||||||
|
icon_map = {
|
||||||
|
'no_filter': '🌐',
|
||||||
|
'safe_content': '✅',
|
||||||
|
'tech_only': '💻',
|
||||||
|
'high_quality': '⭐',
|
||||||
|
'custom_example': '🎯'
|
||||||
|
}
|
||||||
|
|
||||||
|
name_map = {
|
||||||
|
'no_filter': 'All Content',
|
||||||
|
'safe_content': 'Safe Content',
|
||||||
|
'tech_only': 'Tech Only',
|
||||||
|
'high_quality': 'High Quality',
|
||||||
|
'custom_example': 'Custom Example'
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.append({
|
||||||
|
'id': filterset_name,
|
||||||
|
'name': name_map.get(filterset_name, filterset_name.replace('_', ' ').title()),
|
||||||
|
'description': filterset_config.get('description', ''),
|
||||||
|
'icon': icon_map.get(filterset_name, '🔧'),
|
||||||
|
'active': filterset_name == current_filter
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'filters': filters})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting filters: {e}")
|
||||||
|
return jsonify({'error': 'Failed to get filters'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/bookmarks')
|
||||||
|
@login_required
|
||||||
|
def bookmarks():
|
||||||
|
"""Bookmarks page"""
|
||||||
|
return render_template('bookmarks.html', user=current_user)
|
||||||
|
|
||||||
|
|
||||||
|
def build_comment_tree(comments):
|
||||||
|
"""Build a hierarchical comment tree from flat comment list"""
|
||||||
|
# Create lookup dict by UUID
|
||||||
|
comment_dict = {c['uuid']: {**c, 'replies': []} for c in comments}
|
||||||
|
|
||||||
|
# Build tree structure
|
||||||
|
root_comments = []
|
||||||
|
for comment in comments:
|
||||||
|
parent_uuid = comment.get('parent_comment_uuid')
|
||||||
|
if parent_uuid and parent_uuid in comment_dict:
|
||||||
|
# Add as reply to parent
|
||||||
|
comment_dict[parent_uuid]['replies'].append(comment_dict[comment['uuid']])
|
||||||
|
else:
|
||||||
|
# Top-level comment
|
||||||
|
root_comments.append(comment_dict[comment['uuid']])
|
||||||
|
|
||||||
|
# Sort at each level by timestamp
|
||||||
|
def sort_tree(comments_list):
|
||||||
|
comments_list.sort(key=lambda x: x.get('timestamp', 0))
|
||||||
|
for comment in comments_list:
|
||||||
|
if comment.get('replies'):
|
||||||
|
sort_tree(comment['replies'])
|
||||||
|
|
||||||
|
sort_tree(root_comments)
|
||||||
|
return root_comments
|
||||||
|
|
||||||
|
|
||||||
@app.route('/post/<post_id>')
|
@app.route('/post/<post_id>')
|
||||||
def post_detail(post_id):
|
def post_detail(post_id):
|
||||||
"""Serve individual post detail page with modern theme"""
|
"""Serve individual post detail page with modern theme"""
|
||||||
@@ -541,10 +922,11 @@ def post_detail(post_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get comments from cache
|
# Get comments from cache
|
||||||
comments = cached_comments.get(post_id, [])
|
comments_flat = cached_comments.get(post_id, [])
|
||||||
|
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
|
||||||
|
|
||||||
# Sort comments by timestamp
|
# Build comment tree
|
||||||
comments.sort(key=lambda x: x.get('timestamp', 0))
|
comments = build_comment_tree(comments_flat)
|
||||||
|
|
||||||
# Load user settings if authenticated
|
# Load user settings if authenticated
|
||||||
user_settings = {}
|
user_settings = {}
|
||||||
@@ -573,8 +955,16 @@ def serve_theme(filename):
|
|||||||
|
|
||||||
@app.route('/logo.png')
|
@app.route('/logo.png')
|
||||||
def serve_logo():
|
def serve_logo():
|
||||||
"""Serve logo"""
|
"""Serve configurable logo"""
|
||||||
return send_from_directory('.', 'logo.png')
|
logo_path = app.config['LOGO_PATH']
|
||||||
|
# If it's just a filename, serve from current directory
|
||||||
|
if '/' not in logo_path:
|
||||||
|
return send_from_directory('.', logo_path)
|
||||||
|
else:
|
||||||
|
# If it's a full path, split directory and filename
|
||||||
|
directory = os.path.dirname(logo_path)
|
||||||
|
filename = os.path.basename(logo_path)
|
||||||
|
return send_from_directory(directory, filename)
|
||||||
|
|
||||||
@app.route('/static/<path:filename>')
|
@app.route('/static/<path:filename>')
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
@@ -596,6 +986,9 @@ def login():
|
|||||||
if current_user.is_authenticated:
|
if current_user.is_authenticated:
|
||||||
return redirect(url_for('index'))
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# Check if Auth0 is configured
|
||||||
|
auth0_configured = bool(app.config.get('AUTH0_DOMAIN') and app.config.get('AUTH0_CLIENT_ID'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
username = request.form.get('username')
|
username = request.form.get('username')
|
||||||
password = request.form.get('password')
|
password = request.form.get('password')
|
||||||
@@ -603,7 +996,7 @@ def login():
|
|||||||
|
|
||||||
if not user_service:
|
if not user_service:
|
||||||
flash('User service not available', 'error')
|
flash('User service not available', 'error')
|
||||||
return render_template('login.html')
|
return render_template('login.html', auth0_configured=auth0_configured)
|
||||||
|
|
||||||
user = user_service.authenticate(username, password)
|
user = user_service.authenticate(username, password)
|
||||||
|
|
||||||
@@ -617,15 +1010,97 @@ def login():
|
|||||||
else:
|
else:
|
||||||
flash('Invalid username or password', 'error')
|
flash('Invalid username or password', 'error')
|
||||||
|
|
||||||
return render_template('login.html')
|
return render_template('login.html', auth0_configured=auth0_configured)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/password-reset-request', methods=['GET', 'POST'])
|
||||||
|
def password_reset_request():
|
||||||
|
"""Request a password reset"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
email = request.form.get('email', '').strip().lower()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
flash('Please enter your email address', 'error')
|
||||||
|
return render_template('password_reset_request.html')
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
user = User.query.filter_by(email=email).first()
|
||||||
|
|
||||||
|
# Always show success message for security (don't reveal if email exists)
|
||||||
|
flash('If an account exists with that email, a password reset link has been sent.', 'success')
|
||||||
|
|
||||||
|
if user and user.password_hash: # Only send reset if user has a password (not OAuth only)
|
||||||
|
# Generate reset token
|
||||||
|
token = user.generate_reset_token()
|
||||||
|
|
||||||
|
# Build reset URL
|
||||||
|
reset_url = url_for('password_reset', token=token, _external=True)
|
||||||
|
|
||||||
|
# Log the reset URL (in production, this would be emailed)
|
||||||
|
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
|
||||||
|
|
||||||
|
# For now, also flash it for development (remove in production)
|
||||||
|
flash(f'Reset link (development only): {reset_url}', 'info')
|
||||||
|
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
return render_template('password_reset_request.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/password-reset/<token>', methods=['GET', 'POST'])
|
||||||
|
def password_reset(token):
|
||||||
|
"""Reset password with token"""
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('index'))
|
||||||
|
|
||||||
|
# Find user by token
|
||||||
|
user = User.query.filter_by(reset_token=token).first()
|
||||||
|
|
||||||
|
if not user or not user.verify_reset_token(token):
|
||||||
|
flash('Invalid or expired reset token', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
password = request.form.get('password', '')
|
||||||
|
confirm_password = request.form.get('confirm_password', '')
|
||||||
|
|
||||||
|
if not password or len(password) < 6:
|
||||||
|
flash('Password must be at least 6 characters', 'error')
|
||||||
|
return render_template('password_reset.html')
|
||||||
|
|
||||||
|
if password != confirm_password:
|
||||||
|
flash('Passwords do not match', 'error')
|
||||||
|
return render_template('password_reset.html')
|
||||||
|
|
||||||
|
# Set new password
|
||||||
|
user.set_password(password)
|
||||||
|
user.clear_reset_token()
|
||||||
|
|
||||||
|
flash('Your password has been reset successfully. You can now log in.', 'success')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
return render_template('password_reset.html')
|
||||||
|
|
||||||
|
|
||||||
# Auth0 Routes
|
# Auth0 Routes
|
||||||
@app.route('/auth0/login')
|
@app.route('/auth0/login')
|
||||||
def auth0_login():
|
def auth0_login():
|
||||||
"""Redirect to Auth0 for authentication"""
|
"""Redirect to Auth0 for authentication"""
|
||||||
|
# Check if Auth0 is configured
|
||||||
|
if not app.config.get('AUTH0_DOMAIN') or not app.config.get('AUTH0_CLIENT_ID'):
|
||||||
|
flash('Auth0 authentication is not configured. Please use email/password login or contact the administrator.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
try:
|
||||||
redirect_uri = url_for('auth0_callback', _external=True)
|
redirect_uri = url_for('auth0_callback', _external=True)
|
||||||
return auth0.authorize_redirect(redirect_uri)
|
return auth0.authorize_redirect(redirect_uri)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Auth0 login error: {e}")
|
||||||
|
flash('Auth0 authentication failed. Please use email/password login.', 'error')
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/auth0/callback')
|
@app.route('/auth0/callback')
|
||||||
@@ -933,15 +1408,30 @@ def settings_communities():
|
|||||||
except:
|
except:
|
||||||
selected_communities = []
|
selected_communities = []
|
||||||
|
|
||||||
# Available communities
|
# Get available communities from platform config and collection targets
|
||||||
available_communities = [
|
available_communities = []
|
||||||
{'id': 'programming', 'name': 'Programming', 'platform': 'reddit'},
|
|
||||||
{'id': 'python', 'name': 'Python', 'platform': 'reddit'},
|
# Load platform configuration
|
||||||
{'id': 'technology', 'name': 'Technology', 'platform': 'reddit'},
|
platform_config = load_platform_config()
|
||||||
{'id': 'hackernews', 'name': 'Hacker News', 'platform': 'hackernews'},
|
|
||||||
{'id': 'lobsters', 'name': 'Lobsters', 'platform': 'lobsters'},
|
# Get enabled communities from collection_targets (what's actually being crawled)
|
||||||
{'id': 'stackoverflow', 'name': 'Stack Overflow', 'platform': 'stackoverflow'},
|
enabled_communities = set()
|
||||||
]
|
for target in platform_config.get('collection_targets', []):
|
||||||
|
enabled_communities.add((target['platform'], target['community']))
|
||||||
|
|
||||||
|
# Build community list from platform config for communities that are enabled
|
||||||
|
for platform_name, platform_info in platform_config.get('platforms', {}).items():
|
||||||
|
for community_info in platform_info.get('communities', []):
|
||||||
|
# Only include communities that are in collection_targets
|
||||||
|
if (platform_name, community_info['id']) in enabled_communities:
|
||||||
|
available_communities.append({
|
||||||
|
'id': community_info['id'],
|
||||||
|
'name': community_info['name'],
|
||||||
|
'display_name': community_info.get('display_name', community_info['name']),
|
||||||
|
'platform': platform_name,
|
||||||
|
'icon': community_info.get('icon', platform_info.get('icon', '📄')),
|
||||||
|
'description': community_info.get('description', '')
|
||||||
|
})
|
||||||
|
|
||||||
return render_template('settings_communities.html',
|
return render_template('settings_communities.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -986,13 +1476,10 @@ def settings_filters():
|
|||||||
|
|
||||||
current_filter = user_settings.get('filter_set', 'no_filter')
|
current_filter = user_settings.get('filter_set', 'no_filter')
|
||||||
|
|
||||||
# Load available filter sets
|
# Load available filter sets from FilterEngine as a dictionary
|
||||||
filter_sets = {}
|
|
||||||
try:
|
|
||||||
with open('filtersets.json', 'r') as f:
|
|
||||||
filter_sets = json.load(f)
|
|
||||||
except:
|
|
||||||
filter_sets = {}
|
filter_sets = {}
|
||||||
|
for filterset_name in filter_engine.get_available_filtersets():
|
||||||
|
filter_sets[filterset_name] = filter_engine.config.get_filterset(filterset_name)
|
||||||
|
|
||||||
return render_template('settings_filters.html',
|
return render_template('settings_filters.html',
|
||||||
user=current_user,
|
user=current_user,
|
||||||
@@ -1016,7 +1503,9 @@ def settings_experience():
|
|||||||
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
'infinite_scroll': request.form.get('infinite_scroll') == 'on',
|
||||||
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
'auto_refresh': request.form.get('auto_refresh') == 'on',
|
||||||
'push_notifications': request.form.get('push_notifications') == 'on',
|
'push_notifications': request.form.get('push_notifications') == 'on',
|
||||||
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on'
|
'dark_patterns_opt_in': request.form.get('dark_patterns_opt_in') == 'on',
|
||||||
|
'time_filter_enabled': request.form.get('time_filter_enabled') == 'on',
|
||||||
|
'time_filter_days': int(request.form.get('time_filter_days', 7))
|
||||||
}
|
}
|
||||||
|
|
||||||
# Save settings
|
# Save settings
|
||||||
@@ -1036,7 +1525,9 @@ def settings_experience():
|
|||||||
'infinite_scroll': False,
|
'infinite_scroll': False,
|
||||||
'auto_refresh': False,
|
'auto_refresh': False,
|
||||||
'push_notifications': False,
|
'push_notifications': False,
|
||||||
'dark_patterns_opt_in': False
|
'dark_patterns_opt_in': False,
|
||||||
|
'time_filter_enabled': False,
|
||||||
|
'time_filter_days': 7
|
||||||
})
|
})
|
||||||
|
|
||||||
return render_template('settings_experience.html',
|
return render_template('settings_experience.html',
|
||||||
@@ -1526,6 +2017,18 @@ def admin_polling_logs(source_id):
|
|||||||
logs=logs)
|
logs=logs)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# TEMPLATE CONTEXT PROCESSORS
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_app_config():
|
||||||
|
"""Inject app configuration into all templates"""
|
||||||
|
return {
|
||||||
|
'APP_NAME': app.config['APP_NAME']
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ERROR HANDLERS
|
# ERROR HANDLERS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -211,6 +211,12 @@ def collect_platform(platform: str, community: str, start_date: str, end_date: s
|
|||||||
if post_id in index:
|
if post_id in index:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# ====================================================================
|
||||||
|
# FIX: Correct the post's source field BEFORE saving
|
||||||
|
# ====================================================================
|
||||||
|
post['source'] = community if community else platform
|
||||||
|
# ====================================================================
|
||||||
|
|
||||||
# Save post
|
# Save post
|
||||||
post_uuid = save_post(post, platform, index, dirs)
|
post_uuid = save_post(post, platform, index, dirs)
|
||||||
added_count += 1
|
added_count += 1
|
||||||
|
|||||||
@@ -292,8 +292,10 @@ class data_methods():
|
|||||||
'meta': {'is_self': post.get('is_self', False)}
|
'meta': {'is_self': post.get('is_self', False)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# In data_methods.converters.hackernews_to_schema()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def hackernews_to_schema(raw):
|
def hackernews_to_schema(raw, community='front_page'): # Add community parameter
|
||||||
if not raw or raw.get('type') != 'story':
|
if not raw or raw.get('type') != 'story':
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
@@ -306,7 +308,11 @@ class data_methods():
|
|||||||
'replies': raw.get('descendants', 0),
|
'replies': raw.get('descendants', 0),
|
||||||
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
'url': raw.get('url', f"https://news.ycombinator.com/item?id={raw.get('id')}"),
|
||||||
'content': raw.get('text', ''),
|
'content': raw.get('text', ''),
|
||||||
'source': 'hackernews',
|
# ====================================================================
|
||||||
|
# FIX: Use the community parameter for the source
|
||||||
|
# ====================================================================
|
||||||
|
'source': community,
|
||||||
|
# ====================================================================
|
||||||
'tags': ['hackernews'],
|
'tags': ['hackernews'],
|
||||||
'meta': {}
|
'meta': {}
|
||||||
}
|
}
|
||||||
@@ -681,7 +687,7 @@ class data_methods():
|
|||||||
stories.append(data_methods.utils.http_get_json(story_url))
|
stories.append(data_methods.utils.http_get_json(story_url))
|
||||||
|
|
||||||
# Convert and filter
|
# Convert and filter
|
||||||
posts = [data_methods.converters.hackernews_to_schema(s) for s in stories]
|
posts = [data_methods.converters.hackernews_to_schema(s, community) for s in stories]
|
||||||
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
return data_methods.utils.filter_by_date_range(posts, start_date, end_date)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ services:
|
|||||||
FLASK_ENV: production
|
FLASK_ENV: production
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-this-secret-key-in-production}
|
||||||
|
ALLOW_ANONYMOUS_ACCESS: ${ALLOW_ANONYMOUS_ACCESS:-true}
|
||||||
|
|
||||||
# Auth0 configuration (optional)
|
# Auth0 configuration (optional)
|
||||||
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}
|
AUTH0_DOMAIN: ${AUTH0_DOMAIN:-}
|
||||||
@@ -47,8 +48,9 @@ services:
|
|||||||
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
AUTH0_CLIENT_SECRET: ${AUTH0_CLIENT_SECRET:-}
|
||||||
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
AUTH0_AUDIENCE: ${AUTH0_AUDIENCE:-}
|
||||||
volumes:
|
volumes:
|
||||||
# Persistent data storage
|
# Application-managed data (using a named volume)
|
||||||
- ./data:/app/data
|
- app_data:/app/data
|
||||||
|
# User-editable content (using bind mounts)
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
- ./backups:/app/backups
|
- ./backups:/app/backups
|
||||||
- ./active_html:/app/active_html
|
- ./active_html:/app/active_html
|
||||||
@@ -71,3 +73,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
app_data: # <-- New named volume declared here
|
||||||
27
filter_config.json
Normal file
27
filter_config.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"ai": {
|
||||||
|
"enabled": false,
|
||||||
|
"openrouter_key_file": "openrouter_key.txt",
|
||||||
|
"models": {
|
||||||
|
"cheap": "meta-llama/llama-3.3-70b-instruct",
|
||||||
|
"smart": "meta-llama/llama-3.3-70b-instruct"
|
||||||
|
},
|
||||||
|
"parallel_workers": 10,
|
||||||
|
"timeout_seconds": 60,
|
||||||
|
"note": "Using only Llama 70B for cost efficiency"
|
||||||
|
},
|
||||||
|
"cache": {
|
||||||
|
"enabled": true,
|
||||||
|
"ai_cache_dir": "data/filter_cache",
|
||||||
|
"filterset_cache_ttl_hours": 24
|
||||||
|
},
|
||||||
|
"pipeline": {
|
||||||
|
"default_stages": ["categorizer", "moderator", "filter", "ranker"],
|
||||||
|
"batch_size": 50,
|
||||||
|
"enable_parallel": true
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"filtered_dir": "data/filtered",
|
||||||
|
"save_rejected": false
|
||||||
|
}
|
||||||
|
}
|
||||||
10
filter_pipeline/__init__.py
Normal file
10
filter_pipeline/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Filter Pipeline Package
|
||||||
|
Content filtering, categorization, and ranking system for BalanceBoard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .engine import FilterEngine
|
||||||
|
from .models import FilterResult, ProcessingStatus
|
||||||
|
|
||||||
|
__all__ = ['FilterEngine', 'FilterResult', 'ProcessingStatus']
|
||||||
|
__version__ = '1.0.0'
|
||||||
326
filter_pipeline/ai_client.py
Normal file
326
filter_pipeline/ai_client.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
AI Client
|
||||||
|
OpenRouter API client for content analysis (Llama 70B only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterClient:
|
||||||
|
"""
|
||||||
|
OpenRouter API client for AI-powered content analysis.
|
||||||
|
Uses only Llama 70B for cost efficiency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, model: str = 'meta-llama/llama-3.3-70b-instruct'):
|
||||||
|
"""
|
||||||
|
Initialize OpenRouter client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: OpenRouter API key
|
||||||
|
model: Model to use (default: Llama 70B)
|
||||||
|
"""
|
||||||
|
self.api_key = api_key
|
||||||
|
self.model = model
|
||||||
|
self.base_url = 'https://openrouter.ai/api/v1/chat/completions'
|
||||||
|
self.timeout = 60
|
||||||
|
self.max_retries = 3
|
||||||
|
self.retry_delay = 2 # seconds
|
||||||
|
|
||||||
|
def call_model(
|
||||||
|
self,
|
||||||
|
prompt: str,
|
||||||
|
max_tokens: int = 500,
|
||||||
|
temperature: float = 0.7,
|
||||||
|
system_prompt: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Call AI model with prompt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prompt: User prompt
|
||||||
|
max_tokens: Maximum tokens in response
|
||||||
|
temperature: Sampling temperature (0.0-1.0)
|
||||||
|
system_prompt: Optional system prompt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Model response text
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception if API call fails after retries
|
||||||
|
"""
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
if system_prompt:
|
||||||
|
messages.append({'role': 'system', 'content': system_prompt})
|
||||||
|
|
||||||
|
messages.append({'role': 'user', 'content': prompt})
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'model': self.model,
|
||||||
|
'messages': messages,
|
||||||
|
'max_tokens': max_tokens,
|
||||||
|
'temperature': temperature
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
'Authorization': f'Bearer {self.api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://github.com/balanceboard',
|
||||||
|
'X-Title': 'BalanceBoard Filter Pipeline'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Retry loop
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
self.base_url,
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract response text
|
||||||
|
result = data['choices'][0]['message']['content'].strip()
|
||||||
|
|
||||||
|
logger.debug(f"AI call successful (attempt {attempt + 1})")
|
||||||
|
return result
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(f"AI call failed (attempt {attempt + 1}/{self.max_retries}): {e}")
|
||||||
|
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(self.retry_delay * (attempt + 1)) # Exponential backoff
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All retries failed
|
||||||
|
error_msg = f"AI call failed after {self.max_retries} attempts: {last_error}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise Exception(error_msg)
|
||||||
|
|
||||||
|
def categorize(self, title: str, content: str, categories: list) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Categorize content into predefined categories.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Post title
|
||||||
|
content: Post content/description
|
||||||
|
categories: List of valid category names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'category' and 'confidence' keys
|
||||||
|
"""
|
||||||
|
category_list = ', '.join(categories)
|
||||||
|
|
||||||
|
prompt = f"""Classify this content into ONE of these categories: {category_list}
|
||||||
|
|
||||||
|
Title: {title}
|
||||||
|
Content: {content[:500]}
|
||||||
|
|
||||||
|
Respond in this EXACT format:
|
||||||
|
CATEGORY: [category name]
|
||||||
|
CONFIDENCE: [0.0-1.0]"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.call_model(prompt, max_tokens=20, temperature=0.3)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
lines = response.strip().split('\n')
|
||||||
|
category = None
|
||||||
|
confidence = 0.5
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith('CATEGORY:'):
|
||||||
|
category = line.split(':', 1)[1].strip().lower()
|
||||||
|
elif line.startswith('CONFIDENCE:'):
|
||||||
|
try:
|
||||||
|
confidence = float(line.split(':', 1)[1].strip())
|
||||||
|
except:
|
||||||
|
confidence = 0.5
|
||||||
|
|
||||||
|
# Validate category
|
||||||
|
if category not in [c.lower() for c in categories]:
|
||||||
|
category = categories[0].lower() # Default to first category
|
||||||
|
|
||||||
|
return {
|
||||||
|
'category': category,
|
||||||
|
'confidence': confidence
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Categorization failed: {e}")
|
||||||
|
return {
|
||||||
|
'category': categories[0].lower(),
|
||||||
|
'confidence': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
def moderate(self, title: str, content: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Perform content moderation (safety analysis).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Post title
|
||||||
|
content: Post content/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with moderation flags and scores
|
||||||
|
"""
|
||||||
|
prompt = f"""Analyze this content for safety issues.
|
||||||
|
|
||||||
|
Title: {title}
|
||||||
|
Content: {content[:500]}
|
||||||
|
|
||||||
|
Respond in this EXACT format:
|
||||||
|
VIOLENCE: [0.0-1.0]
|
||||||
|
SEXUAL: [0.0-1.0]
|
||||||
|
HATE_SPEECH: [0.0-1.0]
|
||||||
|
HARASSMENT: [0.0-1.0]
|
||||||
|
IS_SAFE: [YES/NO]"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.call_model(prompt, max_tokens=50, temperature=0.3)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
moderation = {
|
||||||
|
'violence': 0.0,
|
||||||
|
'sexual_content': 0.0,
|
||||||
|
'hate_speech': 0.0,
|
||||||
|
'harassment': 0.0,
|
||||||
|
'is_safe': True
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = response.strip().split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split(':', 1)
|
||||||
|
key = key.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if key == 'violence':
|
||||||
|
moderation['violence'] = float(value)
|
||||||
|
elif key == 'sexual':
|
||||||
|
moderation['sexual_content'] = float(value)
|
||||||
|
elif key == 'hate_speech':
|
||||||
|
moderation['hate_speech'] = float(value)
|
||||||
|
elif key == 'harassment':
|
||||||
|
moderation['harassment'] = float(value)
|
||||||
|
elif key == 'is_safe':
|
||||||
|
moderation['is_safe'] = value.upper() == 'YES'
|
||||||
|
|
||||||
|
return moderation
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Moderation failed: {e}")
|
||||||
|
return {
|
||||||
|
'violence': 0.0,
|
||||||
|
'sexual_content': 0.0,
|
||||||
|
'hate_speech': 0.0,
|
||||||
|
'harassment': 0.0,
|
||||||
|
'is_safe': True
|
||||||
|
}
|
||||||
|
|
||||||
|
def score_quality(self, title: str, content: str) -> float:
|
||||||
|
"""
|
||||||
|
Score content quality (0.0-1.0).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Post title
|
||||||
|
content: Post content/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quality score (0.0-1.0)
|
||||||
|
"""
|
||||||
|
prompt = f"""Rate this content's quality on a scale of 0.0 to 1.0.
|
||||||
|
|
||||||
|
Consider:
|
||||||
|
- Clarity and informativeness
|
||||||
|
- Proper grammar and formatting
|
||||||
|
- Lack of clickbait or sensationalism
|
||||||
|
- Factual tone
|
||||||
|
|
||||||
|
Title: {title}
|
||||||
|
Content: {content[:500]}
|
||||||
|
|
||||||
|
Respond with ONLY a number between 0.0 and 1.0 (e.g., 0.7)"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.call_model(prompt, max_tokens=10, temperature=0.3)
|
||||||
|
|
||||||
|
# Extract number
|
||||||
|
score = float(response.strip())
|
||||||
|
score = max(0.0, min(1.0, score)) # Clamp to 0-1
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Quality scoring failed: {e}")
|
||||||
|
return 0.5 # Default neutral score
|
||||||
|
|
||||||
|
def analyze_sentiment(self, title: str, content: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze sentiment of content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Post title
|
||||||
|
content: Post content/description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'sentiment' (positive/neutral/negative) and 'score'
|
||||||
|
"""
|
||||||
|
prompt = f"""Analyze the sentiment of this content.
|
||||||
|
|
||||||
|
Title: {title}
|
||||||
|
Content: {content[:500]}
|
||||||
|
|
||||||
|
Respond in this EXACT format:
|
||||||
|
SENTIMENT: [positive/neutral/negative]
|
||||||
|
SCORE: [-1.0 to 1.0]"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.call_model(prompt, max_tokens=20, temperature=0.3)
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
sentiment = 'neutral'
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
lines = response.strip().split('\n')
|
||||||
|
for line in lines:
|
||||||
|
if ':' not in line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
key, value = line.split(':', 1)
|
||||||
|
key = key.strip().lower()
|
||||||
|
value = value.strip()
|
||||||
|
|
||||||
|
if key == 'sentiment':
|
||||||
|
sentiment = value.lower()
|
||||||
|
elif key == 'score':
|
||||||
|
try:
|
||||||
|
score = float(value)
|
||||||
|
score = max(-1.0, min(1.0, score))
|
||||||
|
except:
|
||||||
|
score = 0.0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'sentiment': sentiment,
|
||||||
|
'score': score
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Sentiment analysis failed: {e}")
|
||||||
|
return {
|
||||||
|
'sentiment': 'neutral',
|
||||||
|
'score': 0.0
|
||||||
|
}
|
||||||
259
filter_pipeline/cache.py
Normal file
259
filter_pipeline/cache.py
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
Multi-Level Caching System
|
||||||
|
Implements 3-tier caching for filter pipeline efficiency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from .models import AIAnalysisResult, FilterResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterCache:
|
||||||
|
"""
|
||||||
|
Three-level caching system:
|
||||||
|
Level 1: In-memory cache (fastest, TTL-based)
|
||||||
|
Level 2: AI analysis cache (persistent, content-hash based)
|
||||||
|
Level 3: Filterset result cache (persistent, filterset version based)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cache_dir: str = 'data/filter_cache'):
|
||||||
|
self.cache_dir = Path(cache_dir)
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Level 1: In-memory cache
|
||||||
|
self.memory_cache: Dict[str, tuple[Any, datetime]] = {}
|
||||||
|
self.memory_ttl = timedelta(minutes=5)
|
||||||
|
|
||||||
|
# Level 2: AI analysis cache directory
|
||||||
|
self.ai_cache_dir = self.cache_dir / 'ai_analysis'
|
||||||
|
self.ai_cache_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# Level 3: Filterset result cache directory
|
||||||
|
self.filterset_cache_dir = self.cache_dir / 'filtersets'
|
||||||
|
self.filterset_cache_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# ===== Level 1: Memory Cache =====
|
||||||
|
|
||||||
|
def get_memory(self, key: str) -> Optional[Any]:
|
||||||
|
"""Get from memory cache if not expired"""
|
||||||
|
if key in self.memory_cache:
|
||||||
|
value, timestamp = self.memory_cache[key]
|
||||||
|
if datetime.now() - timestamp < self.memory_ttl:
|
||||||
|
return value
|
||||||
|
else:
|
||||||
|
# Expired, remove
|
||||||
|
del self.memory_cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_memory(self, key: str, value: Any):
|
||||||
|
"""Store in memory cache"""
|
||||||
|
self.memory_cache[key] = (value, datetime.now())
|
||||||
|
|
||||||
|
def clear_memory(self):
|
||||||
|
"""Clear all memory cache"""
|
||||||
|
self.memory_cache.clear()
|
||||||
|
|
||||||
|
# ===== Level 2: AI Analysis Cache (Persistent) =====
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_content_hash(title: str, content: str) -> str:
|
||||||
|
"""Compute SHA-256 hash of content for caching"""
|
||||||
|
text = f"{title}\n{content}".encode('utf-8')
|
||||||
|
return hashlib.sha256(text).hexdigest()
|
||||||
|
|
||||||
|
def get_ai_analysis(self, content_hash: str) -> Optional[AIAnalysisResult]:
|
||||||
|
"""
|
||||||
|
Get AI analysis result from cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_hash: SHA-256 hash of content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AIAnalysisResult if cached, None otherwise
|
||||||
|
"""
|
||||||
|
# Check memory first
|
||||||
|
mem_key = f"ai_{content_hash}"
|
||||||
|
cached = self.get_memory(mem_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# Check disk
|
||||||
|
cache_file = self.ai_cache_dir / f"{content_hash}.json"
|
||||||
|
if cache_file.exists():
|
||||||
|
try:
|
||||||
|
with open(cache_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
result = AIAnalysisResult.from_dict(data)
|
||||||
|
|
||||||
|
# Store in memory for faster access
|
||||||
|
self.set_memory(mem_key, result)
|
||||||
|
|
||||||
|
logger.debug(f"AI analysis cache hit for {content_hash[:8]}...")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading AI cache {content_hash}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_ai_analysis(self, content_hash: str, result: AIAnalysisResult):
|
||||||
|
"""
|
||||||
|
Store AI analysis result in cache (persistent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_hash: SHA-256 hash of content
|
||||||
|
result: AIAnalysisResult to cache
|
||||||
|
"""
|
||||||
|
# Store in memory
|
||||||
|
mem_key = f"ai_{content_hash}"
|
||||||
|
self.set_memory(mem_key, result)
|
||||||
|
|
||||||
|
# Store on disk (persistent)
|
||||||
|
cache_file = self.ai_cache_dir / f"{content_hash}.json"
|
||||||
|
try:
|
||||||
|
with open(cache_file, 'w') as f:
|
||||||
|
json.dump(result.to_dict(), f, indent=2)
|
||||||
|
logger.debug(f"Cached AI analysis for {content_hash[:8]}...")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving AI cache {content_hash}: {e}")
|
||||||
|
|
||||||
|
# ===== Level 3: Filterset Result Cache =====
|
||||||
|
|
||||||
|
def _get_filterset_version(self, filterset_name: str, filtersets_config: Dict) -> str:
|
||||||
|
"""Get version hash of filterset definition for cache invalidation"""
|
||||||
|
filterset_def = filtersets_config.get(filterset_name, {})
|
||||||
|
# Include version field if present, otherwise hash the entire definition
|
||||||
|
if 'version' in filterset_def:
|
||||||
|
return str(filterset_def['version'])
|
||||||
|
|
||||||
|
# Compute hash of filterset definition
|
||||||
|
definition_json = json.dumps(filterset_def, sort_keys=True)
|
||||||
|
return hashlib.md5(definition_json.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
def get_filterset_results(
|
||||||
|
self,
|
||||||
|
filterset_name: str,
|
||||||
|
filterset_version: str,
|
||||||
|
max_age_hours: int = 24
|
||||||
|
) -> Optional[Dict[str, FilterResult]]:
|
||||||
|
"""
|
||||||
|
Get cached filterset results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filterset_name: Name of filterset
|
||||||
|
filterset_version: Version hash of filterset definition
|
||||||
|
max_age_hours: Maximum age of cache in hours
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping post_uuid to FilterResult, or None if cache invalid
|
||||||
|
"""
|
||||||
|
cache_file = self.filterset_cache_dir / f"{filterset_name}_{filterset_version}.json"
|
||||||
|
|
||||||
|
if not cache_file.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check age
|
||||||
|
try:
|
||||||
|
file_age = datetime.now() - datetime.fromtimestamp(cache_file.stat().st_mtime)
|
||||||
|
if file_age > timedelta(hours=max_age_hours):
|
||||||
|
logger.debug(f"Filterset cache expired for {filterset_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Load cache
|
||||||
|
with open(cache_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Deserialize FilterResults
|
||||||
|
results = {
|
||||||
|
uuid: FilterResult.from_dict(result_data)
|
||||||
|
for uuid, result_data in data.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Filterset cache hit for {filterset_name} ({len(results)} results)")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading filterset cache {filterset_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_filterset_results(
|
||||||
|
self,
|
||||||
|
filterset_name: str,
|
||||||
|
filterset_version: str,
|
||||||
|
results: Dict[str, FilterResult]
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Store filterset results in cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filterset_name: Name of filterset
|
||||||
|
filterset_version: Version hash of filterset definition
|
||||||
|
results: Dict mapping post_uuid to FilterResult
|
||||||
|
"""
|
||||||
|
cache_file = self.filterset_cache_dir / f"{filterset_name}_{filterset_version}.json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Serialize FilterResults
|
||||||
|
data = {
|
||||||
|
uuid: result.to_dict()
|
||||||
|
for uuid, result in results.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(cache_file, 'w') as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
logger.info(f"Cached {len(results)} filterset results for {filterset_name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving filterset cache {filterset_name}: {e}")
|
||||||
|
|
||||||
|
def invalidate_filterset(self, filterset_name: str):
|
||||||
|
"""
|
||||||
|
Invalidate all caches for a filterset (when definition changes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filterset_name: Name of filterset to invalidate
|
||||||
|
"""
|
||||||
|
pattern = f"{filterset_name}_*.json"
|
||||||
|
for cache_file in self.filterset_cache_dir.glob(pattern):
|
||||||
|
try:
|
||||||
|
cache_file.unlink()
|
||||||
|
logger.info(f"Invalidated filterset cache: {cache_file.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error invalidating cache {cache_file}: {e}")
|
||||||
|
|
||||||
|
# ===== Utility Methods =====
|
||||||
|
|
||||||
|
def get_cache_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get cache statistics"""
|
||||||
|
ai_cache_count = len(list(self.ai_cache_dir.glob('*.json')))
|
||||||
|
filterset_cache_count = len(list(self.filterset_cache_dir.glob('*.json')))
|
||||||
|
memory_cache_count = len(self.memory_cache)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'memory_cache_size': memory_cache_count,
|
||||||
|
'ai_cache_size': ai_cache_count,
|
||||||
|
'filterset_cache_size': filterset_cache_count,
|
||||||
|
'ai_cache_dir': str(self.ai_cache_dir),
|
||||||
|
'filterset_cache_dir': str(self.filterset_cache_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
def clear_all(self):
|
||||||
|
"""Clear all caches (use with caution!)"""
|
||||||
|
self.clear_memory()
|
||||||
|
|
||||||
|
# Clear AI cache
|
||||||
|
for cache_file in self.ai_cache_dir.glob('*.json'):
|
||||||
|
cache_file.unlink()
|
||||||
|
|
||||||
|
# Clear filterset cache
|
||||||
|
for cache_file in self.filterset_cache_dir.glob('*.json'):
|
||||||
|
cache_file.unlink()
|
||||||
|
|
||||||
|
logger.warning("All filter caches cleared")
|
||||||
206
filter_pipeline/config.py
Normal file
206
filter_pipeline/config.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""
|
||||||
|
Configuration Loader
|
||||||
|
Loads and validates filter pipeline configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterConfig:
|
||||||
|
"""Configuration for filter pipeline"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_file: str = 'filter_config.json',
|
||||||
|
filtersets_file: str = 'filtersets.json'
|
||||||
|
):
|
||||||
|
self.config_file = Path(config_file)
|
||||||
|
self.filtersets_file = Path(filtersets_file)
|
||||||
|
|
||||||
|
# Load configurations
|
||||||
|
self.config = self._load_config()
|
||||||
|
self.filtersets = self._load_filtersets()
|
||||||
|
|
||||||
|
def _load_config(self) -> Dict:
|
||||||
|
"""Load filter_config.json"""
|
||||||
|
if not self.config_file.exists():
|
||||||
|
logger.warning(f"{self.config_file} not found, using defaults")
|
||||||
|
return self._get_default_config()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.config_file, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
logger.info(f"Loaded filter config from {self.config_file}")
|
||||||
|
return config
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading {self.config_file}: {e}")
|
||||||
|
return self._get_default_config()
|
||||||
|
|
||||||
|
def _load_filtersets(self) -> Dict:
|
||||||
|
"""Load filtersets.json"""
|
||||||
|
if not self.filtersets_file.exists():
|
||||||
|
logger.error(f"{self.filtersets_file} not found!")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.filtersets_file, 'r') as f:
|
||||||
|
filtersets = json.load(f)
|
||||||
|
logger.info(f"Loaded {len(filtersets)} filtersets from {self.filtersets_file}")
|
||||||
|
return filtersets
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading {self.filtersets_file}: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_default_config() -> Dict:
|
||||||
|
"""Get default configuration"""
|
||||||
|
return {
|
||||||
|
'ai': {
|
||||||
|
'enabled': False, # Disabled by default until API key is configured
|
||||||
|
'openrouter_key_file': 'openrouter_key.txt',
|
||||||
|
'models': {
|
||||||
|
'cheap': 'meta-llama/llama-3.3-70b-instruct',
|
||||||
|
'smart': 'anthropic/claude-3.5-sonnet'
|
||||||
|
},
|
||||||
|
'parallel_workers': 10,
|
||||||
|
'timeout_seconds': 60
|
||||||
|
},
|
||||||
|
'cache': {
|
||||||
|
'enabled': True,
|
||||||
|
'ai_cache_dir': 'data/filter_cache',
|
||||||
|
'filterset_cache_ttl_hours': 24
|
||||||
|
},
|
||||||
|
'pipeline': {
|
||||||
|
'default_stages': ['categorizer', 'moderator', 'filter', 'ranker'],
|
||||||
|
'batch_size': 50,
|
||||||
|
'enable_parallel': True
|
||||||
|
},
|
||||||
|
'output': {
|
||||||
|
'filtered_dir': 'data/filtered',
|
||||||
|
'save_rejected': False # Don't save posts that fail filters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===== AI Configuration =====
|
||||||
|
|
||||||
|
def is_ai_enabled(self) -> bool:
|
||||||
|
"""Check if AI processing is enabled"""
|
||||||
|
return self.config.get('ai', {}).get('enabled', False)
|
||||||
|
|
||||||
|
def get_openrouter_key(self) -> Optional[str]:
|
||||||
|
"""Get OpenRouter API key"""
|
||||||
|
# Try environment variable first
|
||||||
|
key = os.getenv('OPENROUTER_API_KEY')
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
|
||||||
|
# Try key file
|
||||||
|
key_file = self.config.get('ai', {}).get('openrouter_key_file')
|
||||||
|
if key_file and Path(key_file).exists():
|
||||||
|
try:
|
||||||
|
with open(key_file, 'r') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading API key from {key_file}: {e}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_ai_model(self, model_type: str = 'cheap') -> str:
|
||||||
|
"""Get AI model name for a given type (cheap/smart)"""
|
||||||
|
models = self.config.get('ai', {}).get('models', {})
|
||||||
|
return models.get(model_type, 'meta-llama/llama-3.3-70b-instruct')
|
||||||
|
|
||||||
|
def get_parallel_workers(self) -> int:
|
||||||
|
"""Get number of parallel workers for AI processing"""
|
||||||
|
return self.config.get('ai', {}).get('parallel_workers', 10)
|
||||||
|
|
||||||
|
# ===== Cache Configuration =====
|
||||||
|
|
||||||
|
def is_cache_enabled(self) -> bool:
|
||||||
|
"""Check if caching is enabled"""
|
||||||
|
return self.config.get('cache', {}).get('enabled', True)
|
||||||
|
|
||||||
|
def get_cache_dir(self) -> str:
|
||||||
|
"""Get cache directory path"""
|
||||||
|
return self.config.get('cache', {}).get('ai_cache_dir', 'data/filter_cache')
|
||||||
|
|
||||||
|
def get_cache_ttl_hours(self) -> int:
|
||||||
|
"""Get filterset cache TTL in hours"""
|
||||||
|
return self.config.get('cache', {}).get('filterset_cache_ttl_hours', 24)
|
||||||
|
|
||||||
|
# ===== Pipeline Configuration =====
|
||||||
|
|
||||||
|
def get_default_stages(self) -> List[str]:
|
||||||
|
"""Get default pipeline stages"""
|
||||||
|
return self.config.get('pipeline', {}).get('default_stages', [
|
||||||
|
'categorizer', 'moderator', 'filter', 'ranker'
|
||||||
|
])
|
||||||
|
|
||||||
|
def get_batch_size(self) -> int:
|
||||||
|
"""Get batch processing size"""
|
||||||
|
return self.config.get('pipeline', {}).get('batch_size', 50)
|
||||||
|
|
||||||
|
def is_parallel_enabled(self) -> bool:
|
||||||
|
"""Check if parallel processing is enabled"""
|
||||||
|
return self.config.get('pipeline', {}).get('enable_parallel', True)
|
||||||
|
|
||||||
|
# ===== Filterset Methods =====
|
||||||
|
|
||||||
|
def get_filterset(self, name: str) -> Optional[Dict]:
|
||||||
|
"""Get filterset configuration by name"""
|
||||||
|
return self.filtersets.get(name)
|
||||||
|
|
||||||
|
def get_filterset_names(self) -> List[str]:
|
||||||
|
"""Get list of available filterset names"""
|
||||||
|
return list(self.filtersets.keys())
|
||||||
|
|
||||||
|
def get_filterset_version(self, name: str) -> Optional[str]:
|
||||||
|
"""Get version of filterset (for cache invalidation)"""
|
||||||
|
filterset = self.get_filterset(name)
|
||||||
|
if not filterset:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use explicit version if present
|
||||||
|
if 'version' in filterset:
|
||||||
|
return str(filterset['version'])
|
||||||
|
|
||||||
|
# Otherwise compute hash of definition
|
||||||
|
import hashlib
|
||||||
|
definition_json = json.dumps(filterset, sort_keys=True)
|
||||||
|
return hashlib.md5(definition_json.encode()).hexdigest()[:8]
|
||||||
|
|
||||||
|
# ===== Output Configuration =====
|
||||||
|
|
||||||
|
def get_filtered_dir(self) -> str:
|
||||||
|
"""Get directory for filtered posts"""
|
||||||
|
return self.config.get('output', {}).get('filtered_dir', 'data/filtered')
|
||||||
|
|
||||||
|
def should_save_rejected(self) -> bool:
|
||||||
|
"""Check if rejected posts should be saved"""
|
||||||
|
return self.config.get('output', {}).get('save_rejected', False)
|
||||||
|
|
||||||
|
# ===== Utility Methods =====
|
||||||
|
|
||||||
|
def reload(self):
|
||||||
|
"""Reload configurations from disk"""
|
||||||
|
self.config = self._load_config()
|
||||||
|
self.filtersets = self._load_filtersets()
|
||||||
|
logger.info("Configuration reloaded")
|
||||||
|
|
||||||
|
def get_config_summary(self) -> Dict[str, Any]:
|
||||||
|
"""Get summary of configuration"""
|
||||||
|
return {
|
||||||
|
'ai_enabled': self.is_ai_enabled(),
|
||||||
|
'cache_enabled': self.is_cache_enabled(),
|
||||||
|
'parallel_enabled': self.is_parallel_enabled(),
|
||||||
|
'num_filtersets': len(self.filtersets),
|
||||||
|
'filterset_names': self.get_filterset_names(),
|
||||||
|
'default_stages': self.get_default_stages(),
|
||||||
|
'batch_size': self.get_batch_size()
|
||||||
|
}
|
||||||
376
filter_pipeline/engine.py
Normal file
376
filter_pipeline/engine.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""
|
||||||
|
Filter Engine
|
||||||
|
Main orchestrator for content filtering pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
from .config import FilterConfig
|
||||||
|
from .cache import FilterCache
|
||||||
|
from .models import FilterResult, ProcessingStatus, AIAnalysisResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterEngine:
|
||||||
|
"""
|
||||||
|
Main filter pipeline orchestrator.
|
||||||
|
|
||||||
|
Coordinates multi-stage content filtering with intelligent caching.
|
||||||
|
Compatible with user preferences and filterset selections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_instance = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config_file: str = 'filter_config.json',
|
||||||
|
filtersets_file: str = 'filtersets.json'
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize filter engine.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config_file: Path to filter_config.json
|
||||||
|
filtersets_file: Path to filtersets.json
|
||||||
|
"""
|
||||||
|
self.config = FilterConfig(config_file, filtersets_file)
|
||||||
|
self.cache = FilterCache(self.config.get_cache_dir())
|
||||||
|
|
||||||
|
# Lazy-loaded stages (will be imported when AI is enabled)
|
||||||
|
self._stages = None
|
||||||
|
|
||||||
|
logger.info("FilterEngine initialized")
|
||||||
|
logger.info(f"Configuration: {self.config.get_config_summary()}")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> 'FilterEngine':
|
||||||
|
"""Get singleton instance of FilterEngine"""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def _init_stages(self):
|
||||||
|
"""Initialize pipeline stages (lazy loading)"""
|
||||||
|
if self._stages is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .stages.categorizer import CategorizerStage
|
||||||
|
from .stages.moderator import ModeratorStage
|
||||||
|
from .stages.filter import FilterStage
|
||||||
|
from .stages.ranker import RankerStage
|
||||||
|
|
||||||
|
# Initialize stages based on configuration
|
||||||
|
self._stages = {
|
||||||
|
'categorizer': CategorizerStage(self.config, self.cache),
|
||||||
|
'moderator': ModeratorStage(self.config, self.cache),
|
||||||
|
'filter': FilterStage(self.config, self.cache),
|
||||||
|
'ranker': RankerStage(self.config, self.cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Initialized {len(self._stages)} pipeline stages")
|
||||||
|
|
||||||
|
def apply_filterset(
|
||||||
|
self,
|
||||||
|
posts: List[Dict[str, Any]],
|
||||||
|
filterset_name: str = 'no_filter',
|
||||||
|
use_cache: bool = True
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Apply filterset to posts (compatible with user preferences).
|
||||||
|
|
||||||
|
This is the main public API used by app.py when loading user feeds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
posts: List of post dictionaries
|
||||||
|
filterset_name: Name of filterset from user settings (e.g., 'safe_content')
|
||||||
|
use_cache: Whether to use cached results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of posts that passed the filter, with score and metadata added
|
||||||
|
"""
|
||||||
|
if not posts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Validate filterset exists
|
||||||
|
filterset = self.config.get_filterset(filterset_name)
|
||||||
|
if not filterset:
|
||||||
|
logger.warning(f"Filterset '{filterset_name}' not found, using 'no_filter'")
|
||||||
|
filterset_name = 'no_filter'
|
||||||
|
|
||||||
|
logger.info(f"Applying filterset '{filterset_name}' to {len(posts)} posts")
|
||||||
|
|
||||||
|
# Check if we have cached filterset results
|
||||||
|
if use_cache and self.config.is_cache_enabled():
|
||||||
|
filterset_version = self.config.get_filterset_version(filterset_name)
|
||||||
|
cached_results = self.cache.get_filterset_results(
|
||||||
|
filterset_name,
|
||||||
|
filterset_version,
|
||||||
|
self.config.get_cache_ttl_hours()
|
||||||
|
)
|
||||||
|
|
||||||
|
if cached_results:
|
||||||
|
# Filter posts using cached results
|
||||||
|
filtered_posts = []
|
||||||
|
for post in posts:
|
||||||
|
post_uuid = post.get('uuid')
|
||||||
|
if post_uuid in cached_results:
|
||||||
|
result = cached_results[post_uuid]
|
||||||
|
if result.passed:
|
||||||
|
# Add filter metadata to post
|
||||||
|
post['_filter_score'] = result.score
|
||||||
|
post['_filter_categories'] = result.categories
|
||||||
|
post['_filter_tags'] = result.tags
|
||||||
|
filtered_posts.append(post)
|
||||||
|
|
||||||
|
logger.info(f"Cache hit: {len(filtered_posts)}/{len(posts)} posts passed filter")
|
||||||
|
return filtered_posts
|
||||||
|
|
||||||
|
# Cache miss or disabled - process posts through pipeline
|
||||||
|
results = self.process_batch(posts, filterset_name)
|
||||||
|
|
||||||
|
# Save to filterset cache
|
||||||
|
if self.config.is_cache_enabled():
|
||||||
|
filterset_version = self.config.get_filterset_version(filterset_name)
|
||||||
|
results_dict = {r.post_uuid: r for r in results}
|
||||||
|
self.cache.set_filterset_results(filterset_name, filterset_version, results_dict)
|
||||||
|
|
||||||
|
# Build filtered post list
|
||||||
|
filtered_posts = []
|
||||||
|
results_by_uuid = {r.post_uuid: r for r in results}
|
||||||
|
|
||||||
|
for post in posts:
|
||||||
|
post_uuid = post.get('uuid')
|
||||||
|
result = results_by_uuid.get(post_uuid)
|
||||||
|
|
||||||
|
if result and result.passed:
|
||||||
|
# Add filter metadata to post
|
||||||
|
post['_filter_score'] = result.score
|
||||||
|
post['_filter_categories'] = result.categories
|
||||||
|
post['_filter_tags'] = result.tags
|
||||||
|
filtered_posts.append(post)
|
||||||
|
|
||||||
|
logger.info(f"Processed: {len(filtered_posts)}/{len(posts)} posts passed filter")
|
||||||
|
return filtered_posts
|
||||||
|
|
||||||
|
def process_batch(
|
||||||
|
self,
|
||||||
|
posts: List[Dict[str, Any]],
|
||||||
|
filterset_name: str = 'no_filter'
|
||||||
|
) -> List[FilterResult]:
|
||||||
|
"""
|
||||||
|
Process batch of posts through pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
posts: List of post dictionaries
|
||||||
|
filterset_name: Name of filterset to apply
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of FilterResults for each post
|
||||||
|
"""
|
||||||
|
if not posts:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Special case: no_filter passes everything with default scores
|
||||||
|
if filterset_name == 'no_filter':
|
||||||
|
return self._process_no_filter(posts)
|
||||||
|
|
||||||
|
# Initialize stages if needed
|
||||||
|
if self.config.is_ai_enabled():
|
||||||
|
self._init_stages()
|
||||||
|
|
||||||
|
# If AI is disabled but filterset requires it, fall back to no_filter
|
||||||
|
if not self.config.is_ai_enabled() and filterset_name != 'no_filter':
|
||||||
|
logger.warning(f"AI disabled but '{filterset_name}' requires AI - falling back to 'no_filter'")
|
||||||
|
return self._process_no_filter(posts)
|
||||||
|
|
||||||
|
# Get pipeline stages for this filterset
|
||||||
|
stage_names = self._get_stages_for_filterset(filterset_name)
|
||||||
|
|
||||||
|
# Process posts (parallel or sequential based on config)
|
||||||
|
if self.config.is_parallel_enabled():
|
||||||
|
results = self._process_batch_parallel(posts, filterset_name, stage_names)
|
||||||
|
else:
|
||||||
|
results = self._process_batch_sequential(posts, filterset_name, stage_names)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _process_no_filter(self, posts: List[Dict[str, Any]]) -> List[FilterResult]:
|
||||||
|
"""Process posts with no_filter (all pass with default scores)"""
|
||||||
|
results = []
|
||||||
|
for post in posts:
|
||||||
|
result = FilterResult(
|
||||||
|
post_uuid=post.get('uuid', ''),
|
||||||
|
passed=True,
|
||||||
|
score=0.5, # Neutral score
|
||||||
|
categories=[],
|
||||||
|
tags=[],
|
||||||
|
filterset_name='no_filter',
|
||||||
|
processed_at=datetime.now(),
|
||||||
|
status=ProcessingStatus.COMPLETED
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _get_stages_for_filterset(self, filterset_name: str) -> List[str]:
|
||||||
|
"""Get pipeline stages to run for a filterset"""
|
||||||
|
filterset = self.config.get_filterset(filterset_name)
|
||||||
|
|
||||||
|
# Check if filterset specifies custom stages
|
||||||
|
if filterset and 'pipeline_stages' in filterset:
|
||||||
|
return filterset['pipeline_stages']
|
||||||
|
|
||||||
|
# Use default stages
|
||||||
|
return self.config.get_default_stages()
|
||||||
|
|
||||||
|
def _process_batch_parallel(
|
||||||
|
self,
|
||||||
|
posts: List[Dict[str, Any]],
|
||||||
|
filterset_name: str,
|
||||||
|
stage_names: List[str]
|
||||||
|
) -> List[FilterResult]:
|
||||||
|
"""Process posts in parallel"""
|
||||||
|
results = [None] * len(posts)
|
||||||
|
workers = self.config.get_parallel_workers()
|
||||||
|
|
||||||
|
def process_single_post(idx_post):
|
||||||
|
idx, post = idx_post
|
||||||
|
try:
|
||||||
|
result = self._process_single_post(post, filterset_name, stage_names)
|
||||||
|
return idx, result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing post {idx}: {e}")
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
# Return failed result
|
||||||
|
return idx, FilterResult(
|
||||||
|
post_uuid=post.get('uuid', ''),
|
||||||
|
passed=False,
|
||||||
|
score=0.0,
|
||||||
|
filterset_name=filterset_name,
|
||||||
|
processed_at=datetime.now(),
|
||||||
|
status=ProcessingStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||||
|
futures = {executor.submit(process_single_post, (i, post)): i
|
||||||
|
for i, post in enumerate(posts)}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
idx, result = future.result()
|
||||||
|
results[idx] = result
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _process_batch_sequential(
|
||||||
|
self,
|
||||||
|
posts: List[Dict[str, Any]],
|
||||||
|
filterset_name: str,
|
||||||
|
stage_names: List[str]
|
||||||
|
) -> List[FilterResult]:
|
||||||
|
"""Process posts sequentially"""
|
||||||
|
results = []
|
||||||
|
for post in posts:
|
||||||
|
try:
|
||||||
|
result = self._process_single_post(post, filterset_name, stage_names)
|
||||||
|
results.append(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing post: {e}")
|
||||||
|
results.append(FilterResult(
|
||||||
|
post_uuid=post.get('uuid', ''),
|
||||||
|
passed=False,
|
||||||
|
score=0.0,
|
||||||
|
filterset_name=filterset_name,
|
||||||
|
processed_at=datetime.now(),
|
||||||
|
status=ProcessingStatus.FAILED,
|
||||||
|
error=str(e)
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _process_single_post(
|
||||||
|
self,
|
||||||
|
post: Dict[str, Any],
|
||||||
|
filterset_name: str,
|
||||||
|
stage_names: List[str]
|
||||||
|
) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Process single post through pipeline stages.
|
||||||
|
|
||||||
|
Stages are run sequentially: Categorizer → Moderator → Filter → Ranker
|
||||||
|
"""
|
||||||
|
# Initialize result
|
||||||
|
result = FilterResult(
|
||||||
|
post_uuid=post.get('uuid', ''),
|
||||||
|
passed=True, # Start as passed, stages can reject
|
||||||
|
score=0.5, # Default score
|
||||||
|
filterset_name=filterset_name,
|
||||||
|
processed_at=datetime.now(),
|
||||||
|
status=ProcessingStatus.PROCESSING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run each stage
|
||||||
|
for stage_name in stage_names:
|
||||||
|
if stage_name not in self._stages:
|
||||||
|
logger.warning(f"Stage '{stage_name}' not found, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
stage = self._stages[stage_name]
|
||||||
|
|
||||||
|
if not stage.is_enabled():
|
||||||
|
logger.debug(f"Stage '{stage_name}' disabled, skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Process through stage
|
||||||
|
try:
|
||||||
|
result = stage.process(post, result)
|
||||||
|
|
||||||
|
# If post was rejected by this stage, stop processing
|
||||||
|
if not result.passed:
|
||||||
|
logger.debug(f"Post {post.get('uuid', '')} rejected by {stage_name}")
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in stage '{stage_name}': {e}")
|
||||||
|
result.status = ProcessingStatus.FAILED
|
||||||
|
result.error = f"{stage_name}: {str(e)}"
|
||||||
|
result.passed = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Mark as completed if not failed
|
||||||
|
if result.status != ProcessingStatus.FAILED:
|
||||||
|
result.status = ProcessingStatus.COMPLETED
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ===== Utility Methods =====
|
||||||
|
|
||||||
|
def get_available_filtersets(self) -> List[str]:
|
||||||
|
"""Get list of available filterset names (for user settings UI)"""
|
||||||
|
return self.config.get_filterset_names()
|
||||||
|
|
||||||
|
def get_filterset_description(self, name: str) -> Optional[str]:
|
||||||
|
"""Get description of a filterset (for user settings UI)"""
|
||||||
|
filterset = self.config.get_filterset(name)
|
||||||
|
return filterset.get('description') if filterset else None
|
||||||
|
|
||||||
|
def invalidate_filterset_cache(self, filterset_name: str):
|
||||||
|
"""Invalidate cache for a filterset (when definition changes)"""
|
||||||
|
self.cache.invalidate_filterset(filterset_name)
|
||||||
|
logger.info(f"Invalidated cache for filterset '{filterset_name}'")
|
||||||
|
|
||||||
|
def get_cache_stats(self) -> Dict[str, Any]:
|
||||||
|
"""Get cache statistics"""
|
||||||
|
return self.cache.get_cache_stats()
|
||||||
|
|
||||||
|
def reload_config(self):
|
||||||
|
"""Reload configuration from disk"""
|
||||||
|
self.config.reload()
|
||||||
|
self._stages = None # Force re-initialization of stages
|
||||||
|
logger.info("Configuration reloaded")
|
||||||
121
filter_pipeline/models.py
Normal file
121
filter_pipeline/models.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
Filter Pipeline Models
|
||||||
|
Data models for filter results and processing status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessingStatus(Enum):
|
||||||
|
"""Status of content processing"""
|
||||||
|
PENDING = 'pending'
|
||||||
|
PROCESSING = 'processing'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
FAILED = 'failed'
|
||||||
|
CACHED = 'cached'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FilterResult:
|
||||||
|
"""
|
||||||
|
Result of filtering pipeline for a single post.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
post_uuid: Unique identifier for the post
|
||||||
|
passed: Whether post passed the filter
|
||||||
|
score: Relevance/quality score (0.0-1.0)
|
||||||
|
categories: Detected categories/topics
|
||||||
|
tags: Additional tags applied
|
||||||
|
moderation_data: Safety and quality analysis results
|
||||||
|
filterset_name: Name of filterset applied
|
||||||
|
cache_key: Content hash for caching
|
||||||
|
processed_at: Timestamp of processing
|
||||||
|
status: Processing status
|
||||||
|
error: Error message if failed
|
||||||
|
"""
|
||||||
|
post_uuid: str
|
||||||
|
passed: bool
|
||||||
|
score: float
|
||||||
|
categories: List[str] = field(default_factory=list)
|
||||||
|
tags: List[str] = field(default_factory=list)
|
||||||
|
moderation_data: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
filterset_name: str = 'no_filter'
|
||||||
|
cache_key: Optional[str] = None
|
||||||
|
processed_at: Optional[datetime] = None
|
||||||
|
status: ProcessingStatus = ProcessingStatus.PENDING
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
# Detailed scoring breakdown
|
||||||
|
score_breakdown: Dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
'post_uuid': self.post_uuid,
|
||||||
|
'passed': self.passed,
|
||||||
|
'score': self.score,
|
||||||
|
'categories': self.categories,
|
||||||
|
'tags': self.tags,
|
||||||
|
'moderation_data': self.moderation_data,
|
||||||
|
'filterset_name': self.filterset_name,
|
||||||
|
'cache_key': self.cache_key,
|
||||||
|
'processed_at': self.processed_at.isoformat() if self.processed_at else None,
|
||||||
|
'status': self.status.value if isinstance(self.status, ProcessingStatus) else self.status,
|
||||||
|
'error': self.error,
|
||||||
|
'score_breakdown': self.score_breakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict) -> 'FilterResult':
|
||||||
|
"""Create from dictionary"""
|
||||||
|
# Handle datetime deserialization
|
||||||
|
if data.get('processed_at') and isinstance(data['processed_at'], str):
|
||||||
|
data['processed_at'] = datetime.fromisoformat(data['processed_at'])
|
||||||
|
|
||||||
|
# Handle enum deserialization
|
||||||
|
if data.get('status') and isinstance(data['status'], str):
|
||||||
|
data['status'] = ProcessingStatus(data['status'])
|
||||||
|
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AIAnalysisResult:
|
||||||
|
"""
|
||||||
|
Result of AI analysis (categorization, moderation, etc).
|
||||||
|
Cached separately from FilterResult for reuse across filtersets.
|
||||||
|
"""
|
||||||
|
content_hash: str
|
||||||
|
categories: List[str] = field(default_factory=list)
|
||||||
|
category_scores: Dict[str, float] = field(default_factory=dict)
|
||||||
|
moderation: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
quality_score: float = 0.5
|
||||||
|
sentiment: Optional[str] = None
|
||||||
|
sentiment_score: float = 0.0
|
||||||
|
analyzed_at: Optional[datetime] = None
|
||||||
|
model_used: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Convert to dictionary for JSON serialization"""
|
||||||
|
return {
|
||||||
|
'content_hash': self.content_hash,
|
||||||
|
'categories': self.categories,
|
||||||
|
'category_scores': self.category_scores,
|
||||||
|
'moderation': self.moderation,
|
||||||
|
'quality_score': self.quality_score,
|
||||||
|
'sentiment': self.sentiment,
|
||||||
|
'sentiment_score': self.sentiment_score,
|
||||||
|
'analyzed_at': self.analyzed_at.isoformat() if self.analyzed_at else None,
|
||||||
|
'model_used': self.model_used
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Dict) -> 'AIAnalysisResult':
|
||||||
|
"""Create from dictionary"""
|
||||||
|
if data.get('analyzed_at') and isinstance(data['analyzed_at'], str):
|
||||||
|
data['analyzed_at'] = datetime.fromisoformat(data['analyzed_at'])
|
||||||
|
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
10
filter_pipeline/plugins/__init__.py
Normal file
10
filter_pipeline/plugins/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
Filter Plugins
|
||||||
|
Pluggable filters for content filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base import BaseFilterPlugin
|
||||||
|
from .keyword import KeywordFilterPlugin
|
||||||
|
from .quality import QualityFilterPlugin
|
||||||
|
|
||||||
|
__all__ = ['BaseFilterPlugin', 'KeywordFilterPlugin', 'QualityFilterPlugin']
|
||||||
66
filter_pipeline/plugins/base.py
Normal file
66
filter_pipeline/plugins/base.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Base Filter Plugin
|
||||||
|
Abstract base class for all filter plugins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class BaseFilterPlugin(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for filter plugins.
|
||||||
|
|
||||||
|
Plugins can be used within stages to implement specific filtering logic.
|
||||||
|
Examples: keyword filtering, AI-based filtering, quality scoring, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Initialize plugin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Plugin configuration dictionary
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.enabled = config.get('enabled', True)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Determine if post should be filtered OUT.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data dictionary
|
||||||
|
context: Optional context from previous stages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if post should be filtered OUT (rejected), False to keep it
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculate relevance/quality score for post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data dictionary
|
||||||
|
context: Optional context from previous stages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score from 0.0 (lowest) to 1.0 (highest)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Get plugin name for logging"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Check if plugin is enabled"""
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.get_name()} enabled={self.enabled}>"
|
||||||
95
filter_pipeline/plugins/keyword.py
Normal file
95
filter_pipeline/plugins/keyword.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Keyword Filter Plugin
|
||||||
|
Simple keyword-based filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
|
from .base import BaseFilterPlugin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KeywordFilterPlugin(BaseFilterPlugin):
|
||||||
|
"""
|
||||||
|
Filter posts based on keyword matching.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Blocklist: Reject posts containing blocked keywords
|
||||||
|
- Allowlist: Only allow posts containing allowed keywords
|
||||||
|
- Case-insensitive matching
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
super().__init__(config)
|
||||||
|
|
||||||
|
self.blocklist = [k.lower() for k in config.get('blocklist', [])]
|
||||||
|
self.allowlist = [k.lower() for k in config.get('allowlist', [])]
|
||||||
|
self.check_title = config.get('check_title', True)
|
||||||
|
self.check_content = config.get('check_content', True)
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "KeywordFilter"
|
||||||
|
|
||||||
|
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Check if post should be filtered out based on keywords.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if post contains blocked keywords or missing allowed keywords
|
||||||
|
"""
|
||||||
|
text = self._get_text(post)
|
||||||
|
|
||||||
|
# Check blocklist
|
||||||
|
if self.blocklist:
|
||||||
|
for keyword in self.blocklist:
|
||||||
|
if keyword in text:
|
||||||
|
logger.debug(f"KeywordFilter: Blocked keyword '{keyword}' found")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check allowlist (if specified, at least one keyword must be present)
|
||||||
|
if self.allowlist:
|
||||||
|
found = any(keyword in text for keyword in self.allowlist)
|
||||||
|
if not found:
|
||||||
|
logger.debug("KeywordFilter: No allowed keywords found")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
|
||||||
|
"""
|
||||||
|
Score based on keyword presence.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
1.0 if allowlist keywords present, 0.5 neutral, 0.0 if blocklist keywords present
|
||||||
|
"""
|
||||||
|
text = self._get_text(post)
|
||||||
|
|
||||||
|
# Check blocklist
|
||||||
|
if self.blocklist:
|
||||||
|
for keyword in self.blocklist:
|
||||||
|
if keyword in text:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Check allowlist
|
||||||
|
if self.allowlist:
|
||||||
|
matches = sum(1 for keyword in self.allowlist if keyword in text)
|
||||||
|
if matches > 0:
|
||||||
|
return min(1.0, 0.5 + (matches * 0.1))
|
||||||
|
|
||||||
|
return 0.5 # Neutral
|
||||||
|
|
||||||
|
def _get_text(self, post: Dict[str, Any]) -> str:
|
||||||
|
"""Get searchable text from post"""
|
||||||
|
text_parts = []
|
||||||
|
|
||||||
|
if self.check_title:
|
||||||
|
title = post.get('title', '')
|
||||||
|
text_parts.append(title)
|
||||||
|
|
||||||
|
if self.check_content:
|
||||||
|
content = post.get('content', '')
|
||||||
|
text_parts.append(content)
|
||||||
|
|
||||||
|
return ' '.join(text_parts).lower()
|
||||||
128
filter_pipeline/plugins/quality.py
Normal file
128
filter_pipeline/plugins/quality.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Quality Filter Plugin
|
||||||
|
Filter based on quality metrics (readability, length, etc).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
from .base import BaseFilterPlugin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class QualityFilterPlugin(BaseFilterPlugin):
|
||||||
|
"""
|
||||||
|
Filter posts based on quality metrics.
|
||||||
|
|
||||||
|
Metrics:
|
||||||
|
- Title length (too short or too long)
|
||||||
|
- Content length
|
||||||
|
- Excessive caps (SHOUTING)
|
||||||
|
- Excessive punctuation (!!!)
|
||||||
|
- Clickbait patterns
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any]):
|
||||||
|
super().__init__(config)
|
||||||
|
|
||||||
|
self.min_title_length = config.get('min_title_length', 10)
|
||||||
|
self.max_title_length = config.get('max_title_length', 300)
|
||||||
|
self.min_content_length = config.get('min_content_length', 0)
|
||||||
|
self.max_caps_ratio = config.get('max_caps_ratio', 0.5)
|
||||||
|
self.max_exclamation_marks = config.get('max_exclamation_marks', 3)
|
||||||
|
|
||||||
|
# Clickbait patterns
|
||||||
|
self.clickbait_patterns = [
|
||||||
|
r'you won\'t believe',
|
||||||
|
r'shocking',
|
||||||
|
r'doctors hate',
|
||||||
|
r'this one trick',
|
||||||
|
r'number \d+ will',
|
||||||
|
r'what happened next'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "QualityFilter"
|
||||||
|
|
||||||
|
def should_filter(self, post: Dict[str, Any], context: Optional[Dict] = None) -> bool:
|
||||||
|
"""
|
||||||
|
Check if post should be filtered based on quality.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if post fails quality checks
|
||||||
|
"""
|
||||||
|
title = post.get('title', '')
|
||||||
|
content = post.get('content', '')
|
||||||
|
|
||||||
|
# Check title length
|
||||||
|
if len(title) < self.min_title_length:
|
||||||
|
logger.debug(f"QualityFilter: Title too short ({len(title)} chars)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
if len(title) > self.max_title_length:
|
||||||
|
logger.debug(f"QualityFilter: Title too long ({len(title)} chars)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check content length (if specified)
|
||||||
|
if self.min_content_length > 0 and len(content) < self.min_content_length:
|
||||||
|
logger.debug(f"QualityFilter: Content too short ({len(content)} chars)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check excessive caps
|
||||||
|
if len(title) > 0:
|
||||||
|
caps_ratio = sum(1 for c in title if c.isupper()) / len(title)
|
||||||
|
if caps_ratio > self.max_caps_ratio and len(title) > 10:
|
||||||
|
logger.debug(f"QualityFilter: Excessive caps ({caps_ratio:.1%})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check excessive exclamation marks
|
||||||
|
exclamations = title.count('!')
|
||||||
|
if exclamations > self.max_exclamation_marks:
|
||||||
|
logger.debug(f"QualityFilter: Excessive exclamations ({exclamations})")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check clickbait patterns
|
||||||
|
title_lower = title.lower()
|
||||||
|
for pattern in self.clickbait_patterns:
|
||||||
|
if re.search(pattern, title_lower):
|
||||||
|
logger.debug(f"QualityFilter: Clickbait pattern detected: {pattern}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def score(self, post: Dict[str, Any], context: Optional[Dict] = None) -> float:
|
||||||
|
"""
|
||||||
|
Score post quality.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Quality score 0.0-1.0
|
||||||
|
"""
|
||||||
|
title = post.get('title', '')
|
||||||
|
content = post.get('content', '')
|
||||||
|
|
||||||
|
score = 1.0
|
||||||
|
|
||||||
|
# Penalize for short title
|
||||||
|
if len(title) < 20:
|
||||||
|
score -= 0.1
|
||||||
|
|
||||||
|
# Penalize for excessive caps
|
||||||
|
if len(title) > 0:
|
||||||
|
caps_ratio = sum(1 for c in title if c.isupper()) / len(title)
|
||||||
|
if caps_ratio > 0.3:
|
||||||
|
score -= (caps_ratio - 0.3) * 0.5
|
||||||
|
|
||||||
|
# Penalize for exclamation marks
|
||||||
|
exclamations = title.count('!')
|
||||||
|
if exclamations > 0:
|
||||||
|
score -= exclamations * 0.05
|
||||||
|
|
||||||
|
# Bonus for longer content
|
||||||
|
if len(content) > 500:
|
||||||
|
score += 0.1
|
||||||
|
elif len(content) > 200:
|
||||||
|
score += 0.05
|
||||||
|
|
||||||
|
return max(0.0, min(1.0, score))
|
||||||
12
filter_pipeline/stages/__init__.py
Normal file
12
filter_pipeline/stages/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
Pipeline Stages
|
||||||
|
Sequential processing stages for content filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base_stage import BaseStage
|
||||||
|
from .categorizer import CategorizerStage
|
||||||
|
from .moderator import ModeratorStage
|
||||||
|
from .filter import FilterStage
|
||||||
|
from .ranker import RankerStage
|
||||||
|
|
||||||
|
__all__ = ['BaseStage', 'CategorizerStage', 'ModeratorStage', 'FilterStage', 'RankerStage']
|
||||||
62
filter_pipeline/stages/base_stage.py
Normal file
62
filter_pipeline/stages/base_stage.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Base Stage
|
||||||
|
Abstract base class for all pipeline stages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from ..models import FilterResult
|
||||||
|
|
||||||
|
|
||||||
|
class BaseStage(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base class for pipeline stages.
|
||||||
|
|
||||||
|
Each stage processes posts sequentially and can modify FilterResults.
|
||||||
|
Stages are executed in order: Categorizer → Moderator → Filter → Ranker
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any], cache: Any):
|
||||||
|
"""
|
||||||
|
Initialize stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration dictionary for this stage
|
||||||
|
cache: FilterCache instance
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.cache = cache
|
||||||
|
self.enabled = config.get('enabled', True)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def process(
|
||||||
|
self,
|
||||||
|
post: Dict[str, Any],
|
||||||
|
result: FilterResult
|
||||||
|
) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Process a single post and update its FilterResult.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data dictionary
|
||||||
|
result: Current FilterResult for this post
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated FilterResult
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception if processing fails
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Get stage name for logging"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
"""Check if stage is enabled"""
|
||||||
|
return self.enabled
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<{self.get_name()} enabled={self.enabled}>"
|
||||||
167
filter_pipeline/stages/categorizer.py
Normal file
167
filter_pipeline/stages/categorizer.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
Categorizer Stage
|
||||||
|
Detect topics and categories using AI (cached by content hash).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .base_stage import BaseStage
|
||||||
|
from ..models import FilterResult, AIAnalysisResult
|
||||||
|
from ..cache import FilterCache
|
||||||
|
from ..ai_client import OpenRouterClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CategorizerStage(BaseStage):
|
||||||
|
"""
|
||||||
|
Stage 1: Categorize content and extract tags.
|
||||||
|
|
||||||
|
Uses AI to detect topics/categories with content-hash based caching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config, cache: FilterCache):
|
||||||
|
super().__init__(config, cache)
|
||||||
|
|
||||||
|
# Initialize AI client if enabled
|
||||||
|
self.ai_client = None
|
||||||
|
if config.is_ai_enabled():
|
||||||
|
api_key = config.get_openrouter_key()
|
||||||
|
if api_key:
|
||||||
|
model = config.get_ai_model('cheap') # Use cheap model
|
||||||
|
self.ai_client = OpenRouterClient(api_key, model)
|
||||||
|
logger.info("Categorizer: AI client initialized")
|
||||||
|
else:
|
||||||
|
logger.warning("Categorizer: AI enabled but no API key found")
|
||||||
|
|
||||||
|
# Default categories
|
||||||
|
self.default_categories = [
|
||||||
|
'technology', 'programming', 'science', 'news',
|
||||||
|
'politics', 'business', 'entertainment', 'sports', 'other'
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "Categorizer"
|
||||||
|
|
||||||
|
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Categorize post and add tags.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data
|
||||||
|
result: Current FilterResult
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated FilterResult with categories and tags
|
||||||
|
"""
|
||||||
|
title = post.get('title', '')
|
||||||
|
content = post.get('content', '')
|
||||||
|
|
||||||
|
# Compute content hash for caching
|
||||||
|
content_hash = self.cache.compute_content_hash(title, content)
|
||||||
|
result.cache_key = content_hash
|
||||||
|
|
||||||
|
# Try to get cached AI analysis
|
||||||
|
cached_analysis = self.cache.get_ai_analysis(content_hash)
|
||||||
|
|
||||||
|
if cached_analysis:
|
||||||
|
# Use cached categorization
|
||||||
|
result.categories = cached_analysis.categories
|
||||||
|
result.tags.extend(self._extract_platform_tags(post))
|
||||||
|
|
||||||
|
logger.debug(f"Categorizer: Cache hit for {content_hash[:8]}...")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# No cache, need to categorize
|
||||||
|
if self.ai_client:
|
||||||
|
categories, category_scores = self._categorize_with_ai(title, content)
|
||||||
|
else:
|
||||||
|
# Fallback: Use platform/source as category
|
||||||
|
categories, category_scores = self._categorize_fallback(post)
|
||||||
|
|
||||||
|
# Store in AI analysis result for caching
|
||||||
|
ai_analysis = AIAnalysisResult(
|
||||||
|
content_hash=content_hash,
|
||||||
|
categories=categories,
|
||||||
|
category_scores=category_scores,
|
||||||
|
analyzed_at=datetime.now(),
|
||||||
|
model_used=self.ai_client.model if self.ai_client else 'fallback'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache AI analysis
|
||||||
|
self.cache.set_ai_analysis(content_hash, ai_analysis)
|
||||||
|
|
||||||
|
# Update result
|
||||||
|
result.categories = categories
|
||||||
|
result.tags.extend(self._extract_platform_tags(post))
|
||||||
|
|
||||||
|
logger.debug(f"Categorizer: Analyzed {content_hash[:8]}... -> {categories}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _categorize_with_ai(self, title: str, content: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Categorize using AI.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(categories list, category_scores dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.ai_client.categorize(title, content, self.default_categories)
|
||||||
|
|
||||||
|
category = response.get('category', 'other')
|
||||||
|
confidence = response.get('confidence', 0.5)
|
||||||
|
|
||||||
|
categories = [category]
|
||||||
|
category_scores = {category: confidence}
|
||||||
|
|
||||||
|
return categories, category_scores
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI categorization failed: {e}")
|
||||||
|
return ['other'], {'other': 0.0}
|
||||||
|
|
||||||
|
def _categorize_fallback(self, post: Dict[str, Any]) -> tuple:
|
||||||
|
"""
|
||||||
|
Fallback categorization using platform/source.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(categories list, category_scores dict)
|
||||||
|
"""
|
||||||
|
# Use source as category
|
||||||
|
source = post.get('source', '').lower()
|
||||||
|
|
||||||
|
# Map common sources to categories
|
||||||
|
category_map = {
|
||||||
|
'programming': 'programming',
|
||||||
|
'python': 'programming',
|
||||||
|
'javascript': 'programming',
|
||||||
|
'technology': 'technology',
|
||||||
|
'science': 'science',
|
||||||
|
'politics': 'politics',
|
||||||
|
'worldnews': 'news',
|
||||||
|
'news': 'news'
|
||||||
|
}
|
||||||
|
|
||||||
|
category = category_map.get(source, 'other')
|
||||||
|
return [category], {category: 0.5}
|
||||||
|
|
||||||
|
def _extract_platform_tags(self, post: Dict[str, Any]) -> list:
|
||||||
|
"""Extract tags from platform, source, etc."""
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
platform = post.get('platform', '')
|
||||||
|
if platform:
|
||||||
|
tags.append(platform)
|
||||||
|
|
||||||
|
source = post.get('source', '')
|
||||||
|
if source:
|
||||||
|
tags.append(source)
|
||||||
|
|
||||||
|
# Extract existing tags
|
||||||
|
existing_tags = post.get('tags', [])
|
||||||
|
if existing_tags:
|
||||||
|
tags.extend(existing_tags)
|
||||||
|
|
||||||
|
return list(set(tags)) # Remove duplicates
|
||||||
171
filter_pipeline/stages/filter.py
Normal file
171
filter_pipeline/stages/filter.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Filter Stage
|
||||||
|
Apply filterset rules to posts (no AI needed - fast rule evaluation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
from .base_stage import BaseStage
|
||||||
|
from ..models import FilterResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterStage(BaseStage):
|
||||||
|
"""
|
||||||
|
Stage 3: Apply filterset rules.
|
||||||
|
|
||||||
|
Evaluates filter conditions from filtersets.json without AI.
|
||||||
|
Fast rule-based filtering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "Filter"
|
||||||
|
|
||||||
|
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Apply filterset rules to post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data
|
||||||
|
result: Current FilterResult
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated FilterResult (may be rejected)
|
||||||
|
"""
|
||||||
|
# Get filterset configuration
|
||||||
|
filterset = self.config.get_filterset(result.filterset_name)
|
||||||
|
|
||||||
|
if not filterset:
|
||||||
|
logger.warning(f"Filterset '{result.filterset_name}' not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Apply post rules
|
||||||
|
post_rules = filterset.get('post_rules', {})
|
||||||
|
if not self._evaluate_rules(post, result, post_rules):
|
||||||
|
result.passed = False
|
||||||
|
logger.debug(f"Filter: Post {post.get('uuid', '')} rejected by filterset rules")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Post passed all rules
|
||||||
|
logger.debug(f"Filter: Post {post.get('uuid', '')} passed filterset '{result.filterset_name}'")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _evaluate_rules(
|
||||||
|
self,
|
||||||
|
post: Dict[str, Any],
|
||||||
|
result: FilterResult,
|
||||||
|
rules: Dict[str, Any]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Evaluate all rules for a post.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if post passes all rules, False otherwise
|
||||||
|
"""
|
||||||
|
for field, condition in rules.items():
|
||||||
|
if not self._evaluate_condition(post, result, field, condition):
|
||||||
|
logger.debug(f"Filter: Failed condition '{field}': {condition}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _evaluate_condition(
|
||||||
|
self,
|
||||||
|
post: Dict[str, Any],
|
||||||
|
result: FilterResult,
|
||||||
|
field: str,
|
||||||
|
condition: Any
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Evaluate a single condition.
|
||||||
|
|
||||||
|
Supported conditions:
|
||||||
|
- {"equals": value}
|
||||||
|
- {"not_equals": value}
|
||||||
|
- {"in": [values]}
|
||||||
|
- {"not_in": [values]}
|
||||||
|
- {"min": value}
|
||||||
|
- {"max": value}
|
||||||
|
- {"includes_any": [values]}
|
||||||
|
- {"excludes": [values]}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data
|
||||||
|
result: FilterResult with moderation data
|
||||||
|
field: Field path (e.g., "score", "moderation.flags.is_safe")
|
||||||
|
condition: Condition dict
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if condition passes
|
||||||
|
"""
|
||||||
|
# Get field value
|
||||||
|
value = self._get_field_value(post, result, field)
|
||||||
|
|
||||||
|
# Evaluate condition
|
||||||
|
if isinstance(condition, dict):
|
||||||
|
for op, expected in condition.items():
|
||||||
|
if op == 'equals':
|
||||||
|
if value != expected:
|
||||||
|
return False
|
||||||
|
elif op == 'not_equals':
|
||||||
|
if value == expected:
|
||||||
|
return False
|
||||||
|
elif op == 'in':
|
||||||
|
if value not in expected:
|
||||||
|
return False
|
||||||
|
elif op == 'not_in':
|
||||||
|
if value in expected:
|
||||||
|
return False
|
||||||
|
elif op == 'min':
|
||||||
|
if value < expected:
|
||||||
|
return False
|
||||||
|
elif op == 'max':
|
||||||
|
if value > expected:
|
||||||
|
return False
|
||||||
|
elif op == 'includes_any':
|
||||||
|
# Check if any expected value is in the field (for lists)
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return False
|
||||||
|
if not any(item in value for item in expected):
|
||||||
|
return False
|
||||||
|
elif op == 'excludes':
|
||||||
|
# Check that none of the excluded values are present
|
||||||
|
if isinstance(value, list):
|
||||||
|
if any(item in expected for item in value):
|
||||||
|
return False
|
||||||
|
elif value in expected:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown condition operator: {op}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_field_value(self, post: Dict[str, Any], result: FilterResult, field: str):
|
||||||
|
"""
|
||||||
|
Get field value from post or result.
|
||||||
|
|
||||||
|
Supports nested fields like "moderation.flags.is_safe"
|
||||||
|
"""
|
||||||
|
parts = field.split('.')
|
||||||
|
|
||||||
|
# Check if field is in moderation data
|
||||||
|
if parts[0] == 'moderation' and result.moderation_data:
|
||||||
|
value = result.moderation_data
|
||||||
|
for part in parts[1:]:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Check post data
|
||||||
|
value = post
|
||||||
|
for part in parts:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
value = value.get(part)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return value
|
||||||
153
filter_pipeline/stages/moderator.py
Normal file
153
filter_pipeline/stages/moderator.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""
|
||||||
|
Moderator Stage
|
||||||
|
Safety and quality analysis using AI (cached by content hash).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .base_stage import BaseStage
|
||||||
|
from ..models import FilterResult, AIAnalysisResult
|
||||||
|
from ..cache import FilterCache
|
||||||
|
from ..ai_client import OpenRouterClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ModeratorStage(BaseStage):
|
||||||
|
"""
|
||||||
|
Stage 2: Content moderation and quality analysis.
|
||||||
|
|
||||||
|
Uses AI to analyze safety, quality, and sentiment with content-hash based caching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config, cache: FilterCache):
|
||||||
|
super().__init__(config, cache)
|
||||||
|
|
||||||
|
# Initialize AI client if enabled
|
||||||
|
self.ai_client = None
|
||||||
|
if config.is_ai_enabled():
|
||||||
|
api_key = config.get_openrouter_key()
|
||||||
|
if api_key:
|
||||||
|
model = config.get_ai_model('cheap') # Use cheap model
|
||||||
|
self.ai_client = OpenRouterClient(api_key, model)
|
||||||
|
logger.info("Moderator: AI client initialized")
|
||||||
|
else:
|
||||||
|
logger.warning("Moderator: AI enabled but no API key found")
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "Moderator"
|
||||||
|
|
||||||
|
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Moderate post for safety and quality.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data
|
||||||
|
result: Current FilterResult
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated FilterResult with moderation data
|
||||||
|
"""
|
||||||
|
title = post.get('title', '')
|
||||||
|
content = post.get('content', '')
|
||||||
|
|
||||||
|
# Use existing cache key from Categorizer
|
||||||
|
content_hash = result.cache_key or self.cache.compute_content_hash(title, content)
|
||||||
|
|
||||||
|
# Try to get cached AI analysis
|
||||||
|
cached_analysis = self.cache.get_ai_analysis(content_hash)
|
||||||
|
|
||||||
|
if cached_analysis and cached_analysis.moderation:
|
||||||
|
# Use cached moderation data
|
||||||
|
result.moderation_data = cached_analysis.moderation
|
||||||
|
result.score_breakdown['quality'] = cached_analysis.quality_score
|
||||||
|
|
||||||
|
logger.debug(f"Moderator: Cache hit for {content_hash[:8]}...")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# No cache, need to moderate
|
||||||
|
if self.ai_client:
|
||||||
|
moderation, quality_score, sentiment = self._moderate_with_ai(title, content)
|
||||||
|
else:
|
||||||
|
# Fallback: Safe defaults
|
||||||
|
moderation, quality_score, sentiment = self._moderate_fallback(post)
|
||||||
|
|
||||||
|
# Update or create AI analysis result
|
||||||
|
if cached_analysis:
|
||||||
|
# Update existing analysis with moderation data
|
||||||
|
cached_analysis.moderation = moderation
|
||||||
|
cached_analysis.quality_score = quality_score
|
||||||
|
cached_analysis.sentiment = sentiment.get('sentiment')
|
||||||
|
cached_analysis.sentiment_score = sentiment.get('score', 0.0)
|
||||||
|
ai_analysis = cached_analysis
|
||||||
|
else:
|
||||||
|
# Create new analysis
|
||||||
|
ai_analysis = AIAnalysisResult(
|
||||||
|
content_hash=content_hash,
|
||||||
|
moderation=moderation,
|
||||||
|
quality_score=quality_score,
|
||||||
|
sentiment=sentiment.get('sentiment'),
|
||||||
|
sentiment_score=sentiment.get('score', 0.0),
|
||||||
|
analyzed_at=datetime.now(),
|
||||||
|
model_used=self.ai_client.model if self.ai_client else 'fallback'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache AI analysis
|
||||||
|
self.cache.set_ai_analysis(content_hash, ai_analysis)
|
||||||
|
|
||||||
|
# Update result
|
||||||
|
result.moderation_data = moderation
|
||||||
|
result.score_breakdown['quality'] = quality_score
|
||||||
|
result.score_breakdown['sentiment'] = sentiment.get('score', 0.0)
|
||||||
|
|
||||||
|
logger.debug(f"Moderator: Analyzed {content_hash[:8]}... (quality: {quality_score:.2f})")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _moderate_with_ai(self, title: str, content: str) -> tuple:
|
||||||
|
"""
|
||||||
|
Moderate using AI.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(moderation dict, quality_score float, sentiment dict)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Run moderation
|
||||||
|
moderation = self.ai_client.moderate(title, content)
|
||||||
|
|
||||||
|
# Run quality scoring
|
||||||
|
quality_score = self.ai_client.score_quality(title, content)
|
||||||
|
|
||||||
|
# Run sentiment analysis
|
||||||
|
sentiment = self.ai_client.analyze_sentiment(title, content)
|
||||||
|
|
||||||
|
return moderation, quality_score, sentiment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI moderation failed: {e}")
|
||||||
|
return self._moderate_fallback({})
|
||||||
|
|
||||||
|
def _moderate_fallback(self, post: Dict[str, Any]) -> tuple:
|
||||||
|
"""
|
||||||
|
Fallback moderation with safe defaults.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(moderation dict, quality_score float, sentiment dict)
|
||||||
|
"""
|
||||||
|
moderation = {
|
||||||
|
'violence': 0.0,
|
||||||
|
'sexual_content': 0.0,
|
||||||
|
'hate_speech': 0.0,
|
||||||
|
'harassment': 0.0,
|
||||||
|
'is_safe': True
|
||||||
|
}
|
||||||
|
|
||||||
|
quality_score = 0.5 # Neutral quality
|
||||||
|
|
||||||
|
sentiment = {
|
||||||
|
'sentiment': 'neutral',
|
||||||
|
'score': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
return moderation, quality_score, sentiment
|
||||||
201
filter_pipeline/stages/ranker.py
Normal file
201
filter_pipeline/stages/ranker.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Ranker Stage
|
||||||
|
Score and rank posts based on quality, recency, and source.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .base_stage import BaseStage
|
||||||
|
from ..models import FilterResult
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RankerStage(BaseStage):
|
||||||
|
"""
|
||||||
|
Stage 4: Score and rank posts.
|
||||||
|
|
||||||
|
Combines multiple factors:
|
||||||
|
- Quality score (from Moderator)
|
||||||
|
- Recency (how recent the post is)
|
||||||
|
- Source tier (platform/source reputation)
|
||||||
|
- User engagement (score, replies)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config, cache):
|
||||||
|
super().__init__(config, cache)
|
||||||
|
|
||||||
|
# Scoring weights
|
||||||
|
self.weights = {
|
||||||
|
'quality': 0.3,
|
||||||
|
'recency': 0.25,
|
||||||
|
'source_tier': 0.25,
|
||||||
|
'engagement': 0.20
|
||||||
|
}
|
||||||
|
|
||||||
|
# Source tiers (higher = better)
|
||||||
|
self.source_tiers = {
|
||||||
|
'tier1': ['hackernews', 'arxiv', 'nature', 'science'],
|
||||||
|
'tier2': ['reddit', 'stackoverflow', 'github'],
|
||||||
|
'tier3': ['twitter', 'medium', 'dev.to']
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "Ranker"
|
||||||
|
|
||||||
|
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
|
||||||
|
"""
|
||||||
|
Calculate final score for post.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
post: Post data
|
||||||
|
result: Current FilterResult
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated FilterResult with final score
|
||||||
|
"""
|
||||||
|
# Calculate component scores
|
||||||
|
quality_score = self._get_quality_score(result)
|
||||||
|
recency_score = self._calculate_recency_score(post)
|
||||||
|
source_score = self._calculate_source_score(post)
|
||||||
|
engagement_score = self._calculate_engagement_score(post)
|
||||||
|
|
||||||
|
# Store breakdown
|
||||||
|
result.score_breakdown.update({
|
||||||
|
'quality': quality_score,
|
||||||
|
'recency': recency_score,
|
||||||
|
'source_tier': source_score,
|
||||||
|
'engagement': engagement_score
|
||||||
|
})
|
||||||
|
|
||||||
|
# Calculate weighted final score
|
||||||
|
final_score = (
|
||||||
|
quality_score * self.weights['quality'] +
|
||||||
|
recency_score * self.weights['recency'] +
|
||||||
|
source_score * self.weights['source_tier'] +
|
||||||
|
engagement_score * self.weights['engagement']
|
||||||
|
)
|
||||||
|
|
||||||
|
result.score = final_score
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Ranker: Post {post.get('uuid', '')[:8]}... score={final_score:.3f} "
|
||||||
|
f"(q:{quality_score:.2f}, r:{recency_score:.2f}, s:{source_score:.2f}, e:{engagement_score:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _get_quality_score(self, result: FilterResult) -> float:
|
||||||
|
"""Get quality score from Moderator stage"""
|
||||||
|
return result.score_breakdown.get('quality', 0.5)
|
||||||
|
|
||||||
|
def _calculate_recency_score(self, post: Dict[str, Any]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate recency score based on post age.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score 0.0-1.0 (1.0 = very recent, 0.0 = very old)
|
||||||
|
"""
|
||||||
|
timestamp = post.get('timestamp')
|
||||||
|
if not timestamp:
|
||||||
|
return 0.5 # Neutral if no timestamp
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert to datetime
|
||||||
|
if isinstance(timestamp, int):
|
||||||
|
post_time = datetime.fromtimestamp(timestamp)
|
||||||
|
else:
|
||||||
|
post_time = datetime.fromisoformat(str(timestamp))
|
||||||
|
|
||||||
|
# Calculate age in hours
|
||||||
|
age_seconds = (datetime.now() - post_time).total_seconds()
|
||||||
|
age_hours = age_seconds / 3600
|
||||||
|
|
||||||
|
# Scoring curve
|
||||||
|
if age_hours < 1:
|
||||||
|
return 1.0
|
||||||
|
elif age_hours < 6:
|
||||||
|
return 0.9
|
||||||
|
elif age_hours < 12:
|
||||||
|
return 0.75
|
||||||
|
elif age_hours < 24:
|
||||||
|
return 0.6
|
||||||
|
elif age_hours < 48:
|
||||||
|
return 0.4
|
||||||
|
elif age_hours < 168: # 1 week
|
||||||
|
return 0.25
|
||||||
|
else:
|
||||||
|
return 0.1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error calculating recency: {e}")
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
def _calculate_source_score(self, post: Dict[str, Any]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate source tier score.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score 0.0-1.0 based on source reputation
|
||||||
|
"""
|
||||||
|
platform = post.get('platform', '').lower()
|
||||||
|
source = post.get('source', '').lower()
|
||||||
|
|
||||||
|
# Check tier 1
|
||||||
|
if any(t in platform or t in source for t in self.source_tiers['tier1']):
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
# Check tier 2
|
||||||
|
if any(t in platform or t in source for t in self.source_tiers['tier2']):
|
||||||
|
return 0.7
|
||||||
|
|
||||||
|
# Check tier 3
|
||||||
|
if any(t in platform or t in source for t in self.source_tiers['tier3']):
|
||||||
|
return 0.5
|
||||||
|
|
||||||
|
# Unknown source
|
||||||
|
return 0.3
|
||||||
|
|
||||||
|
def _calculate_engagement_score(self, post: Dict[str, Any]) -> float:
|
||||||
|
"""
|
||||||
|
Calculate engagement score based on upvotes/score and comments.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score 0.0-1.0 based on engagement metrics
|
||||||
|
"""
|
||||||
|
score = post.get('score', 0)
|
||||||
|
replies = post.get('replies', 0)
|
||||||
|
|
||||||
|
# Normalize scores (logarithmic scale)
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Score component (0-1)
|
||||||
|
if score <= 0:
|
||||||
|
score_component = 0.0
|
||||||
|
elif score < 10:
|
||||||
|
score_component = score / 10
|
||||||
|
elif score < 100:
|
||||||
|
score_component = 0.1 + (math.log10(score) - 1) * 0.3 # 0.1-0.4
|
||||||
|
elif score < 1000:
|
||||||
|
score_component = 0.4 + (math.log10(score) - 2) * 0.3 # 0.4-0.7
|
||||||
|
else:
|
||||||
|
score_component = min(1.0, 0.7 + (math.log10(score) - 3) * 0.1) # 0.7-1.0
|
||||||
|
|
||||||
|
# Replies component (0-1)
|
||||||
|
if replies <= 0:
|
||||||
|
replies_component = 0.0
|
||||||
|
elif replies < 5:
|
||||||
|
replies_component = replies / 5
|
||||||
|
elif replies < 20:
|
||||||
|
replies_component = 0.2 + (replies - 5) / 15 * 0.3 # 0.2-0.5
|
||||||
|
elif replies < 100:
|
||||||
|
replies_component = 0.5 + (math.log10(replies) - math.log10(20)) / (2 - math.log10(20)) * 0.3 # 0.5-0.8
|
||||||
|
else:
|
||||||
|
replies_component = min(1.0, 0.8 + (math.log10(replies) - 2) * 0.1) # 0.8-1.0
|
||||||
|
|
||||||
|
# Weighted combination (score matters more than replies)
|
||||||
|
engagement_score = score_component * 0.7 + replies_component * 0.3
|
||||||
|
|
||||||
|
return engagement_score
|
||||||
40
migrate_bookmarks.py
Normal file
40
migrate_bookmarks.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration script to create the bookmarks table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from database import init_db
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
# Add the current directory to Python path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
"""Create minimal Flask app for migration"""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = 'migration-secret'
|
||||||
|
return app
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the migration"""
|
||||||
|
print("Creating bookmarks table...")
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
# Initialize database
|
||||||
|
db = init_db(app)
|
||||||
|
|
||||||
|
# Import models to register them
|
||||||
|
from models import User, Session, PollSource, PollLog, Bookmark
|
||||||
|
|
||||||
|
# Create all tables (will only create missing ones)
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
print("✓ Bookmarks table created successfully!")
|
||||||
|
print("Migration completed.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
54
migrate_password_reset.py
Normal file
54
migrate_password_reset.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration to add password reset fields to users table.
|
||||||
|
Run this once to add the new columns for password reset functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from app import app, db
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add password reset columns to users table"""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
# Check if columns already exist
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
if 'reset_token' in columns and 'reset_token_expiry' in columns:
|
||||||
|
print("✓ Password reset columns already exist")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add the new columns using raw SQL
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
if 'reset_token' not in columns:
|
||||||
|
print("Adding reset_token column...")
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN reset_token VARCHAR(100) UNIQUE"
|
||||||
|
))
|
||||||
|
conn.execute(db.text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_users_reset_token ON users(reset_token)"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if 'reset_token_expiry' not in columns:
|
||||||
|
print("Adding reset_token_expiry column...")
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE users ADD COLUMN reset_token_expiry TIMESTAMP"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("✓ Password reset columns added successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Running password reset migration...")
|
||||||
|
success = migrate()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
66
migrate_poll_source_fields.py
Normal file
66
migrate_poll_source_fields.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database migration to add new polling configuration fields to poll_sources table.
|
||||||
|
Run this once to add the new columns: max_posts, fetch_comments, priority
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from app import app, db
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add polling configuration columns to poll_sources table"""
|
||||||
|
with app.app_context():
|
||||||
|
try:
|
||||||
|
# Check if columns already exist
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
inspector = inspect(db.engine)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('poll_sources')]
|
||||||
|
|
||||||
|
if 'max_posts' in columns and 'fetch_comments' in columns and 'priority' in columns:
|
||||||
|
print("✓ Polling configuration columns already exist")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add the new columns using raw SQL
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
if 'max_posts' not in columns:
|
||||||
|
print("Adding max_posts column...")
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE poll_sources ADD COLUMN max_posts INTEGER NOT NULL DEFAULT 100"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if 'fetch_comments' not in columns:
|
||||||
|
print("Adding fetch_comments column...")
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE poll_sources ADD COLUMN fetch_comments BOOLEAN NOT NULL DEFAULT TRUE"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if 'priority' not in columns:
|
||||||
|
print("Adding priority column...")
|
||||||
|
conn.execute(db.text(
|
||||||
|
"ALTER TABLE poll_sources ADD COLUMN priority VARCHAR(20) NOT NULL DEFAULT 'medium'"
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print("✓ Polling configuration columns added successfully")
|
||||||
|
print("\nUpdating existing poll sources with default values...")
|
||||||
|
|
||||||
|
# Update existing rows to have default values
|
||||||
|
with db.engine.connect() as conn:
|
||||||
|
result = conn.execute(db.text("UPDATE poll_sources SET fetch_comments = TRUE WHERE fetch_comments IS NULL"))
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ Updated {result.rowcount} rows with default fetch_comments=TRUE")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("Running poll source fields migration...")
|
||||||
|
success = migrate()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
59
models.py
59
models.py
@@ -41,6 +41,10 @@ class User(UserMixin, db.Model):
|
|||||||
# User settings (JSON stored as text)
|
# User settings (JSON stored as text)
|
||||||
settings = db.Column(db.Text, default='{}')
|
settings = db.Column(db.Text, default='{}')
|
||||||
|
|
||||||
|
# Password reset
|
||||||
|
reset_token = db.Column(db.String(100), nullable=True, unique=True, index=True)
|
||||||
|
reset_token_expiry = db.Column(db.DateTime, nullable=True)
|
||||||
|
|
||||||
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
def __init__(self, username, email, password=None, is_admin=False, auth0_id=None):
|
||||||
"""
|
"""
|
||||||
Initialize a new user.
|
Initialize a new user.
|
||||||
@@ -102,6 +106,32 @@ class User(UserMixin, db.Model):
|
|||||||
self.last_login = datetime.utcnow()
|
self.last_login = datetime.utcnow()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
def generate_reset_token(self):
|
||||||
|
"""Generate a password reset token that expires in 1 hour"""
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
self.reset_token = secrets.token_urlsafe(32)
|
||||||
|
self.reset_token_expiry = datetime.utcnow() + timedelta(hours=1)
|
||||||
|
db.session.commit()
|
||||||
|
return self.reset_token
|
||||||
|
|
||||||
|
def verify_reset_token(self, token):
|
||||||
|
"""Verify if the provided reset token is valid and not expired"""
|
||||||
|
if not self.reset_token or not self.reset_token_expiry:
|
||||||
|
return False
|
||||||
|
if self.reset_token != token:
|
||||||
|
return False
|
||||||
|
if datetime.utcnow() > self.reset_token_expiry:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear_reset_token(self):
|
||||||
|
"""Clear the reset token after use"""
|
||||||
|
self.reset_token = None
|
||||||
|
self.reset_token_expiry = None
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
"""Required by Flask-Login"""
|
"""Required by Flask-Login"""
|
||||||
return self.id
|
return self.id
|
||||||
@@ -187,3 +217,32 @@ class PollLog(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<PollLog {self.id} for source {self.source_id}>'
|
return f'<PollLog {self.id} for source {self.source_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
class Bookmark(db.Model):
|
||||||
|
"""User bookmarks for posts"""
|
||||||
|
|
||||||
|
__tablename__ = 'bookmarks'
|
||||||
|
|
||||||
|
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
user_id = db.Column(db.String(36), db.ForeignKey('users.id'), nullable=False, index=True)
|
||||||
|
post_uuid = db.Column(db.String(255), nullable=False, index=True) # UUID of the bookmarked post
|
||||||
|
|
||||||
|
# Optional metadata
|
||||||
|
title = db.Column(db.String(500), nullable=True) # Cached post title
|
||||||
|
platform = db.Column(db.String(50), nullable=True) # Cached platform info
|
||||||
|
source = db.Column(db.String(100), nullable=True) # Cached source info
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship('User', backref=db.backref('bookmarks', lazy='dynamic', order_by='Bookmark.created_at.desc()'))
|
||||||
|
|
||||||
|
# Unique constraint - user can only bookmark a post once
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint('user_id', 'post_uuid', name='unique_user_bookmark'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Bookmark {self.post_uuid} by user {self.user_id}>'
|
||||||
|
|||||||
@@ -143,13 +143,20 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"stackoverflow": {
|
"stackexchange": {
|
||||||
"name": "Stack Overflow",
|
"name": "Stack Overflow",
|
||||||
"icon": "📚",
|
"icon": "📚",
|
||||||
"color": "#f48024",
|
"color": "#f48024",
|
||||||
"prefix": "",
|
"prefix": "",
|
||||||
"supports_communities": false,
|
"supports_communities": false,
|
||||||
"communities": [
|
"communities": [
|
||||||
|
{
|
||||||
|
"id": "stackoverflow",
|
||||||
|
"name": "Stack Overflow",
|
||||||
|
"display_name": "Stack Overflow",
|
||||||
|
"icon": "📚",
|
||||||
|
"description": "Programming Q&A community"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "featured",
|
"id": "featured",
|
||||||
"name": "Featured",
|
"name": "Featured",
|
||||||
@@ -257,6 +264,12 @@
|
|||||||
"community": "https://hnrss.org/frontpage",
|
"community": "https://hnrss.org/frontpage",
|
||||||
"max_posts": 50,
|
"max_posts": 50,
|
||||||
"priority": "low"
|
"priority": "low"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "stackexchange",
|
||||||
|
"community": "stackoverflow",
|
||||||
|
"max_posts": 50,
|
||||||
|
"priority": "medium"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Page Not Found - BalanceBoard</title>
|
<title>Page Not Found - {{ APP_NAME }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Server Error - BalanceBoard</title>
|
<title>Server Error - {{ APP_NAME }}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
.error-container {
|
.error-container {
|
||||||
|
|||||||
409
templates/_admin_base.html
Normal file
409
templates/_admin_base.html
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
|
<style>
|
||||||
|
/* ===== SHARED ADMIN STYLES ===== */
|
||||||
|
.admin-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header h1 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-section {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ADMIN NAVIGATION ===== */
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 2px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BUTTONS ===== */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--surface-elevation-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--surface-elevation-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-warning:hover {
|
||||||
|
background: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGES ===== */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-admin {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-user {
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-enabled, .status-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-disabled, .status-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-running {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TABLES ===== */
|
||||||
|
.admin-table {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
color: white;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:hover {
|
||||||
|
background: var(--hover-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATS & CARDS ===== */
|
||||||
|
.admin-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: var(--background-color);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FORMS ===== */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UTILITIES ===== */
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message.warning {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== RESPONSIVE ===== */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-info {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PAGE-SPECIFIC OVERRIDES ===== */
|
||||||
|
{% block admin_styles %}{% endblock %}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include '_nav.html' %}
|
||||||
|
|
||||||
|
<div class="admin-container">
|
||||||
|
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
||||||
|
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>{% block page_title %}Admin Panel{% endblock %}</h1>
|
||||||
|
<p>{% block page_description %}Manage system settings and content{% endblock %}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block admin_content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block admin_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
templates/_nav.html
Normal file
48
templates/_nav.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!-- Modern Top Navigation -->
|
||||||
|
<nav class="top-nav">
|
||||||
|
<div class="nav-content">
|
||||||
|
<div class="nav-left">
|
||||||
|
<a href="{{ url_for('index') }}" class="logo-section">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
||||||
|
<span class="brand-text">{{ APP_NAME }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-center">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" placeholder="Search content..." class="search-input">
|
||||||
|
<button class="search-btn">🔍</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-right">
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<div class="user-menu">
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="user-avatar">
|
||||||
|
{% if current_user.profile_picture_url %}
|
||||||
|
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
||||||
|
{% else %}
|
||||||
|
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="username">{{ current_user.username }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown">
|
||||||
|
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
||||||
|
<a href="{{ url_for('bookmarks') }}" class="dropdown-item">📚 Bookmarks</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="anonymous-actions">
|
||||||
|
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
|
||||||
|
<a href="{{ url_for('signup') }}" class="register-btn">📝 Sign Up</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
@@ -1,316 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Admin Panel - BalanceBoard</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Admin Panel - {{ APP_NAME }}{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
border-bottom: 3px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header h1 {
|
{% block page_title %}Admin Panel{% endblock %}
|
||||||
margin: 0 0 8px 0;
|
{% block page_description %}Manage users, content, and system settings{% endblock %}
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header p {
|
{% block admin_styles %}
|
||||||
margin: 0;
|
.user-avatar {
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
border-bottom: 2px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn {
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 3px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn.active {
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-bottom-color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-btn:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-content.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: var(--surface-color);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
border-left: 4px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table {
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 2px 4px var(--surface-elevation-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table th {
|
|
||||||
background: var(--primary-dark);
|
|
||||||
color: white;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table td {
|
|
||||||
padding: 16px;
|
|
||||||
border-bottom: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.users-table tr:hover {
|
|
||||||
background: var(--hover-overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-admin {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-user {
|
|
||||||
background: var(--background-color);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-active {
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-inactive {
|
|
||||||
background: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-danger:hover {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning {
|
|
||||||
background: #ffc107;
|
|
||||||
color: #212529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn-warning:hover {
|
|
||||||
background: #e0a800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
|
||||||
display: inline-block;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-messages {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flash-message.warning {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card {
|
|
||||||
background: var(--background-color);
|
|
||||||
padding: 16px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border-left: 3px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card h4 {
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-card p {
|
|
||||||
margin: 4px 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--divider-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--primary-color);
|
|
||||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -322,46 +18,15 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
{% block admin_content %}
|
||||||
.admin-tabs {
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.system-info {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<a href="{{ url_for('index') }}" class="back-link">← Back to Feed</a>
|
|
||||||
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>Admin Panel</h1>
|
|
||||||
<p>Manage users, content, and system settings</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
||||||
{% if messages %}
|
|
||||||
<div class="flash-messages">
|
|
||||||
{% for category, message in messages %}
|
|
||||||
<div class="flash-message {{ category }}">{{ message }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="admin-tabs">
|
<div class="admin-tabs">
|
||||||
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
<button class="tab-btn active" onclick="showTab('overview')">Overview</button>
|
||||||
@@ -425,7 +90,7 @@
|
|||||||
<div id="users" class="tab-content">
|
<div id="users" class="tab-content">
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h3 class="section-title">User Management</h3>
|
<h3 class="section-title">User Management</h3>
|
||||||
<div class="users-table">
|
<div class="admin-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -556,10 +221,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endblock %}
|
||||||
|
|
||||||
<script>
|
{% block admin_scripts %}
|
||||||
function showTab(tabName) {
|
<script>
|
||||||
|
function showTab(tabName) {
|
||||||
// Hide all tabs
|
// Hide all tabs
|
||||||
const tabs = document.querySelectorAll('.tab-content');
|
const tabs = document.querySelectorAll('.tab-content');
|
||||||
tabs.forEach(tab => tab.classList.remove('active'));
|
tabs.forEach(tab => tab.classList.remove('active'));
|
||||||
@@ -573,7 +239,6 @@
|
|||||||
|
|
||||||
// Add active class to clicked button
|
// Add active class to clicked button
|
||||||
event.target.classList.add('active');
|
event.target.classList.add('active');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
{% endblock %}
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,52 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Polling Management - Admin - BalanceBoard</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Polling Management - Admin - {{ APP_NAME }}{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
{% block page_title %}Polling Management{% endblock %}
|
||||||
display: inline-block;
|
{% block page_description %}Manage data collection sources and schedules{% endblock %}
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-enabled {
|
{% block admin_styles %}
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-disabled {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.source-card {
|
.source-card {
|
||||||
background: var(--surface-color);
|
background: var(--surface-color);
|
||||||
@@ -174,22 +133,62 @@
|
|||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>📡 Polling Management</h1>
|
|
||||||
<p>Configure automatic data collection from content sources</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
.add-source-form {
|
||||||
{% if messages %}
|
background: var(--surface-color);
|
||||||
{% for category, message in messages %}
|
border: 1px solid var(--divider-color);
|
||||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
border-radius: 12px;
|
||||||
{% endfor %}
|
padding: 24px;
|
||||||
{% endif %}
|
margin-bottom: 24px;
|
||||||
{% endwith %}
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input, .form-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scheduler-status {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
|
||||||
<!-- Scheduler Status -->
|
<!-- Scheduler Status -->
|
||||||
<div class="scheduler-status">
|
<div class="scheduler-status">
|
||||||
@@ -497,6 +496,58 @@
|
|||||||
function closeEditModal() {
|
function closeEditModal() {
|
||||||
document.getElementById('edit-modal').style.display = 'none';
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
}
|
}
|
||||||
</script>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
{% block admin_scripts %}
|
||||||
|
<script>
|
||||||
|
const platformConfig = {{ platform_config|tojson|safe }};
|
||||||
|
|
||||||
|
function updateSourceOptions() {
|
||||||
|
const platformSelect = document.getElementById('platform');
|
||||||
|
const sourceSelect = document.getElementById('source_id');
|
||||||
|
const selectedPlatform = platformSelect.value;
|
||||||
|
|
||||||
|
// Clear existing options
|
||||||
|
sourceSelect.innerHTML = '<option value="">Select source...</option>';
|
||||||
|
|
||||||
|
if (selectedPlatform && platformConfig.platforms[selectedPlatform]) {
|
||||||
|
const communities = platformConfig.platforms[selectedPlatform].communities || [];
|
||||||
|
communities.forEach(community => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = community.id;
|
||||||
|
option.textContent = community.display_name || community.name;
|
||||||
|
option.dataset.displayName = community.display_name || community.name;
|
||||||
|
sourceSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDisplayName() {
|
||||||
|
const sourceSelect = document.getElementById('source_id');
|
||||||
|
const displayNameInput = document.getElementById('display_name');
|
||||||
|
const selectedOption = sourceSelect.options[sourceSelect.selectedIndex];
|
||||||
|
|
||||||
|
if (selectedOption && selectedOption.dataset.displayName) {
|
||||||
|
displayNameInput.value = selectedOption.dataset.displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(sourceId, displayName, interval, maxPosts, fetchComments, priority) {
|
||||||
|
// Fill form with current values
|
||||||
|
const modal2 = document.getElementById('edit-modal');
|
||||||
|
const form = document.getElementById('edit-form');
|
||||||
|
form.action = `/admin/polling/${sourceId}/update`;
|
||||||
|
document.getElementById('edit_display_name').value = displayName;
|
||||||
|
document.getElementById('edit_interval').value = interval;
|
||||||
|
document.getElementById('edit_max_posts').value = maxPosts;
|
||||||
|
document.getElementById('edit_fetch_comments').value = fetchComments;
|
||||||
|
document.getElementById('edit_priority').value = priority;
|
||||||
|
|
||||||
|
modal2.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('edit-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,74 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "_admin_base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Polling Logs - {{ source.display_name }} - Admin</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
|
||||||
<style>
|
|
||||||
.admin-container {
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-header {
|
{% block title %}Polling Logs - {{ source.display_name }} - Admin{% endblock %}
|
||||||
background: linear-gradient(135deg, var(--primary-dark) 0%, #2a5068 100%);
|
|
||||||
color: white;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table {
|
{% block page_title %}Polling Logs - {{ source.display_name }}{% endblock %}
|
||||||
width: 100%;
|
{% block page_description %}View polling history and error logs for this source{% endblock %}
|
||||||
border-collapse: collapse;
|
|
||||||
background: var(--surface-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table th {
|
{% block admin_styles %}
|
||||||
background: var(--primary-color);
|
.error-detail {
|
||||||
color: white;
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table td {
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-table tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-running {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-detail {
|
|
||||||
background: #fff3cd;
|
background: #fff3cd;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -77,74 +15,33 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.no-logs {
|
||||||
padding: 8px 16px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
display: inline-block;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: var(--divider-color);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: #d0d0d0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-logs {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
{% endblock %}
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="admin-container">
|
|
||||||
<div class="admin-header">
|
|
||||||
<h1>📋 Polling Logs</h1>
|
|
||||||
<p>{{ source.display_name }} ({{ source.platform}}:{{ source.source_id }})</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{% block admin_content %}
|
||||||
|
<div class="admin-table">
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
<table class="log-table">
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Started</th>
|
<th>Timestamp</th>
|
||||||
<th>Completed</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Posts Found</th>
|
<th>Posts Found</th>
|
||||||
<th>New</th>
|
<th>New Posts</th>
|
||||||
<th>Updated</th>
|
<th>Updated Posts</th>
|
||||||
<th>Details</th>
|
<th>Error Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ log.started_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
<td>{{ log.poll_time.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||||
<td>
|
|
||||||
{% if log.completed_at %}
|
|
||||||
{{ log.completed_at.strftime('%Y-%m-%d %H:%M:%S') }}
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if log.completed_at %}
|
|
||||||
{{ ((log.completed_at - log.started_at).total_seconds())|round(1) }}s
|
|
||||||
{% else %}
|
|
||||||
-
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
<td>
|
||||||
{% if log.status == 'success' %}
|
{% if log.status == 'success' %}
|
||||||
<span class="status-badge status-success">Success</span>
|
<span class="status-badge status-success">Success</span>
|
||||||
@@ -179,10 +76,9 @@
|
|||||||
<p>Logs will appear here after the first poll.</p>
|
<p>Logs will appear here after the first poll.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="margin-top: 24px;">
|
<div style="margin-top: 24px;">
|
||||||
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
<a href="{{ url_for('admin_polling') }}" class="btn btn-secondary">← Back to Polling Management</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Create Admin Account - BalanceBoard{% endblock %}
|
{% block title %}Create Admin Account - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<a href="{{ url_for('index') }}">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo" style="max-width: 80px; border-radius: 50%;">
|
||||||
|
</a>
|
||||||
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
<h1><span class="balance">balance</span><span class="board">Board</span></h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create Administrator Account</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,5 +77,60 @@
|
|||||||
.board {
|
.board {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure form styles are properly applied */
|
||||||
|
.auth-form .form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--background-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form button:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(77, 182, 172, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}BalanceBoard{% endblock %}</title>
|
<title>{% block title %}{{ APP_NAME }}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('serve_theme', filename='modern-card-ui/styles.css') }}">
|
||||||
<style>
|
<style>
|
||||||
/* Auth pages styling */
|
/* Auth pages styling */
|
||||||
|
|||||||
272
templates/bookmarks.html
Normal file
272
templates/bookmarks.html
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Bookmarks - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
|
|
||||||
|
<div style="max-width: 1200px; margin: 0 auto; padding: 24px;">
|
||||||
|
<div style="margin-bottom: 32px;">
|
||||||
|
<h1 style="color: var(--text-primary); margin-bottom: 8px;">📚 Your Bookmarks</h1>
|
||||||
|
<p style="color: var(--text-secondary); font-size: 1.1rem;">Posts you've saved for later reading</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bookmarks-container">
|
||||||
|
<div id="loading" style="text-align: center; padding: 40px; color: var(--text-secondary);">
|
||||||
|
<div style="font-size: 1.2rem;">Loading your bookmarks...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" style="display: none; text-align: center; margin-top: 32px;">
|
||||||
|
<button id="prev-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">← Previous</button>
|
||||||
|
<span id="page-info" style="margin: 0 16px; color: var(--text-secondary);"></span>
|
||||||
|
<button id="next-btn" style="padding: 8px 16px; margin: 0 8px; background: var(--surface-elevation-1); border: 1px solid var(--border-color); border-radius: 6px; color: var(--text-primary); cursor: pointer;">Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bookmark-item {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-item.archived {
|
||||||
|
opacity: 0.6;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-title:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-remove {
|
||||||
|
background: var(--error-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-remove:hover {
|
||||||
|
background: var(--error-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-preview {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-date {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--error-color);
|
||||||
|
background: var(--error-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentPage = 1;
|
||||||
|
let pagination = null;
|
||||||
|
|
||||||
|
async function loadBookmarks(page = 1) {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
|
||||||
|
const response = await fetch(`/api/bookmarks?page=${page}&per_page=20`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to load bookmarks');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBookmarks(data.posts);
|
||||||
|
updatePagination(data.pagination);
|
||||||
|
currentPage = page;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading bookmarks:', error);
|
||||||
|
document.getElementById('bookmarks-container').innerHTML = `
|
||||||
|
<div class="error-state">
|
||||||
|
<h3>Error loading bookmarks</h3>
|
||||||
|
<p>${error.message}</p>
|
||||||
|
<button onclick="loadBookmarks()" style="margin-top: 12px; padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 6px; cursor: pointer;">Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBookmarks(posts) {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
|
||||||
|
const container = document.getElementById('bookmarks-container');
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>📚 No bookmarks yet</h3>
|
||||||
|
<p>Start exploring and bookmark posts you want to read later!</p>
|
||||||
|
<a href="/" style="display: inline-block; margin-top: 16px; padding: 12px 24px; background: var(--primary-color); color: white; text-decoration: none; border-radius: 8px;">Browse Posts</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = posts.map(post => `
|
||||||
|
<div class="bookmark-item ${post.archived ? 'archived' : ''}">
|
||||||
|
<div class="bookmark-header">
|
||||||
|
<a href="${post.url}" class="bookmark-title">${post.title}</a>
|
||||||
|
<button class="bookmark-remove" onclick="removeBookmark('${post.id}', this)">
|
||||||
|
🗑️ Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-meta">
|
||||||
|
<span>👤 ${post.author}</span>
|
||||||
|
<span>📍 ${post.source}</span>
|
||||||
|
<span>⭐ ${post.score}</span>
|
||||||
|
<span>💬 ${post.comments_count}</span>
|
||||||
|
${post.archived ? '<span style="color: var(--warning-color);">📦 Archived</span>' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-preview">${post.content_preview}</div>
|
||||||
|
|
||||||
|
<div class="bookmark-date">
|
||||||
|
Bookmarked on ${new Date(post.bookmarked_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePagination(paginationData) {
|
||||||
|
pagination = paginationData;
|
||||||
|
const paginationEl = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (paginationData.total_pages <= 1) {
|
||||||
|
paginationEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paginationEl.style.display = 'block';
|
||||||
|
|
||||||
|
document.getElementById('prev-btn').disabled = !paginationData.has_prev;
|
||||||
|
document.getElementById('next-btn').disabled = !paginationData.has_next;
|
||||||
|
document.getElementById('page-info').textContent = `Page ${paginationData.current_page} of ${paginationData.total_pages}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeBookmark(postId, button) {
|
||||||
|
if (!confirm('Are you sure you want to remove this bookmark?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Removing...';
|
||||||
|
|
||||||
|
const response = await fetch('/api/bookmark', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ post_uuid: postId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to remove bookmark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload bookmarks to reflect changes
|
||||||
|
loadBookmarks(currentPage);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing bookmark:', error);
|
||||||
|
alert('Error removing bookmark: ' + error.message);
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = '🗑️ Remove';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination event listeners
|
||||||
|
document.getElementById('prev-btn').addEventListener('click', () => {
|
||||||
|
if (pagination && pagination.has_prev) {
|
||||||
|
loadBookmarks(currentPage - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('next-btn').addEventListener('click', () => {
|
||||||
|
if (pagination && pagination.has_next) {
|
||||||
|
loadBookmarks(currentPage + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load bookmarks on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadBookmarks();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,48 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Dashboard - BalanceBoard{% endblock %}
|
{% block title %}Dashboard - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Modern Top Navigation -->
|
{% include '_nav.html' %}
|
||||||
<nav class="top-nav">
|
|
||||||
<div class="nav-content">
|
|
||||||
<div class="nav-left">
|
|
||||||
<div class="logo-section">
|
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
|
||||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-center">
|
|
||||||
<div class="search-bar">
|
|
||||||
<input type="text" placeholder="Search content..." class="search-input">
|
|
||||||
<button class="search-btn">🔍</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-right">
|
|
||||||
<div class="user-menu">
|
|
||||||
<div class="user-info">
|
|
||||||
<div class="user-avatar">
|
|
||||||
{% if current_user.profile_picture_url %}
|
|
||||||
<img src="{{ current_user.profile_picture_url }}" alt="Avatar">
|
|
||||||
{% else %}
|
|
||||||
<div class="avatar-placeholder">{{ current_user.username[:2].upper() }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<span class="username">{{ current_user.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="user-dropdown">
|
|
||||||
<a href="{{ url_for('settings') }}" class="dropdown-item">⚙️ Settings</a>
|
|
||||||
{% if current_user.is_admin %}
|
|
||||||
<a href="{{ url_for('admin_panel') }}" class="dropdown-item">👨💼 Admin Panel</a>
|
|
||||||
{% endif %}
|
|
||||||
<a href="{{ url_for('logout') }}" class="dropdown-item">🚪 Logout</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
@@ -50,17 +11,9 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<h3>Content Filters</h3>
|
<h3>Content Filters</h3>
|
||||||
<div class="filter-item active" data-filter="no_filter">
|
<div id="filter-list" class="filter-list">
|
||||||
<span class="filter-icon">🌐</span>
|
<!-- Filters will be loaded dynamically -->
|
||||||
<span>All Content</span>
|
<div class="loading-filters">Loading filters...</div>
|
||||||
</div>
|
|
||||||
<div class="filter-item" data-filter="safe_content">
|
|
||||||
<span class="filter-icon">✅</span>
|
|
||||||
<span>Safe Content</span>
|
|
||||||
</div>
|
|
||||||
<div class="filter-item" data-filter="custom">
|
|
||||||
<span class="filter-icon">🎯</span>
|
|
||||||
<span>Custom Filter</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,7 +29,7 @@
|
|||||||
<h3>Quick Stats</h3>
|
<h3>Quick Stats</h3>
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-number">156</div>
|
<div class="stat-number">{{ quick_stats.posts_today if quick_stats else 0 }}</div>
|
||||||
<div class="stat-label">Posts Today</div>
|
<div class="stat-label">Posts Today</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -90,10 +43,12 @@
|
|||||||
<!-- Content Feed -->
|
<!-- Content Feed -->
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<div class="content-header">
|
<div class="content-header">
|
||||||
<h1>Your Feed</h1>
|
<h1>{% if anonymous %}Public Feed{% else %}Your Feed{% endif %}</h1>
|
||||||
<div class="content-actions">
|
<div class="content-actions">
|
||||||
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
<button class="refresh-btn" onclick="refreshFeed()">🔄 Refresh</button>
|
||||||
|
{% if not anonymous %}
|
||||||
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
<a href="{{ url_for('settings_filters') }}" class="filter-btn">🔧 Customize</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,14 +303,14 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-communities {
|
.loading-communities, .loading-filters {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-communities {
|
.no-communities, .no-filters {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -438,6 +393,24 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-search-btn {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-search-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #2c3e50;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-container {
|
.feed-container {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -714,9 +687,11 @@ let postsData = [];
|
|||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let currentCommunity = '';
|
let currentCommunity = '';
|
||||||
let currentPlatform = '';
|
let currentPlatform = '';
|
||||||
|
let currentFilter = 'no_filter';
|
||||||
let paginationData = {};
|
let paginationData = {};
|
||||||
let platformConfig = {};
|
let platformConfig = {};
|
||||||
let communitiesData = [];
|
let communitiesData = [];
|
||||||
|
let filtersData = [];
|
||||||
|
|
||||||
// User experience settings
|
// User experience settings
|
||||||
let userSettings = {{ user_settings|tojson }};
|
let userSettings = {{ user_settings|tojson }};
|
||||||
@@ -724,8 +699,8 @@ let userSettings = {{ user_settings|tojson }};
|
|||||||
// Load posts on page load
|
// Load posts on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadPlatformConfig();
|
loadPlatformConfig();
|
||||||
|
loadFilters();
|
||||||
loadPosts();
|
loadPosts();
|
||||||
setupFilterSwitching();
|
|
||||||
setupInfiniteScroll();
|
setupInfiniteScroll();
|
||||||
setupAutoRefresh();
|
setupAutoRefresh();
|
||||||
});
|
});
|
||||||
@@ -753,6 +728,54 @@ async function loadPlatformConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load available filters
|
||||||
|
async function loadFilters() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/filters');
|
||||||
|
const data = await response.json();
|
||||||
|
filtersData = data.filters || [];
|
||||||
|
|
||||||
|
renderFilters(filtersData);
|
||||||
|
setupFilterSwitching();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading filters:', error);
|
||||||
|
// Show fallback filters
|
||||||
|
const fallbackFilters = [
|
||||||
|
{id: 'no_filter', name: 'All Content', icon: '🌐', active: true, description: 'No filtering'}
|
||||||
|
];
|
||||||
|
renderFilters(fallbackFilters);
|
||||||
|
setupFilterSwitching();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render filters in sidebar
|
||||||
|
function renderFilters(filters) {
|
||||||
|
const filterList = document.getElementById('filter-list');
|
||||||
|
if (!filterList) return;
|
||||||
|
|
||||||
|
if (filters.length === 0) {
|
||||||
|
filterList.innerHTML = '<div class="no-filters">No filters available</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtersHTML = filters.map(filter => {
|
||||||
|
return `
|
||||||
|
<div class="filter-item ${filter.active ? 'active' : ''}" data-filter="${filter.id}" title="${filter.description}">
|
||||||
|
<span class="filter-icon">${filter.icon}</span>
|
||||||
|
<span>${filter.name}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
filterList.innerHTML = filtersHTML;
|
||||||
|
|
||||||
|
// Set current filter based on active filter
|
||||||
|
const activeFilter = filters.find(f => f.active);
|
||||||
|
if (activeFilter) {
|
||||||
|
currentFilter = activeFilter.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render communities in sidebar
|
// Render communities in sidebar
|
||||||
function renderCommunities(communities) {
|
function renderCommunities(communities) {
|
||||||
const communityList = document.getElementById('community-list');
|
const communityList = document.getElementById('community-list');
|
||||||
@@ -786,7 +809,7 @@ function renderCommunities(communities) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load posts from API
|
// Load posts from API
|
||||||
async function loadPosts(page = 1, community = '', platform = '', append = false) {
|
async function loadPosts(page = 1, community = '', platform = '', append = false, filter = null) {
|
||||||
try {
|
try {
|
||||||
// Build query parameters
|
// Build query parameters
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -794,6 +817,8 @@ async function loadPosts(page = 1, community = '', platform = '', append = false
|
|||||||
params.append('per_page', 20);
|
params.append('per_page', 20);
|
||||||
if (community) params.append('community', community);
|
if (community) params.append('community', community);
|
||||||
if (platform) params.append('platform', platform);
|
if (platform) params.append('platform', platform);
|
||||||
|
if (filter || currentFilter) params.append('filter', filter || currentFilter);
|
||||||
|
if (currentSearchQuery) params.append('q', currentSearchQuery);
|
||||||
|
|
||||||
const response = await fetch(`/api/posts?${params}`);
|
const response = await fetch(`/api/posts?${params}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -986,25 +1011,35 @@ function savePost(postId) {
|
|||||||
|
|
||||||
// Filter switching functionality
|
// Filter switching functionality
|
||||||
function setupFilterSwitching() {
|
function setupFilterSwitching() {
|
||||||
const filterItems = document.querySelectorAll('.filter-item');
|
document.addEventListener('click', function(event) {
|
||||||
|
if (event.target.closest('.filter-item')) {
|
||||||
|
const filterItem = event.target.closest('.filter-item');
|
||||||
|
|
||||||
filterItems.forEach(item => {
|
// Remove active class from all filter items
|
||||||
item.addEventListener('click', function() {
|
document.querySelectorAll('.filter-item').forEach(f => f.classList.remove('active'));
|
||||||
// Remove active class from all items
|
|
||||||
filterItems.forEach(f => f.classList.remove('active'));
|
|
||||||
|
|
||||||
// Add active class to clicked item
|
// Add active class to clicked item
|
||||||
this.classList.add('active');
|
filterItem.classList.add('active');
|
||||||
|
|
||||||
// Get filter type
|
// Get filter type
|
||||||
const filterType = this.dataset.filter;
|
const filterType = filterItem.dataset.filter;
|
||||||
|
currentFilter = filterType;
|
||||||
|
|
||||||
// Apply filter (for now just reload)
|
// Update header to show current filter
|
||||||
if (filterType && filterType !== 'custom') {
|
const contentHeader = document.querySelector('.content-header h1');
|
||||||
loadPosts(); // In future, pass filter parameter
|
const filterName = filterItem.textContent.trim();
|
||||||
|
contentHeader.textContent = `${filterName} Feed`;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const postsContainer = document.getElementById('posts-container');
|
||||||
|
const loadingIndicator = document.getElementById('loading-indicator');
|
||||||
|
loadingIndicator.style.display = 'flex';
|
||||||
|
postsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
loadPosts(1, currentCommunity, currentPlatform, false, filterType);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh feed function
|
// Refresh feed function
|
||||||
@@ -1028,15 +1063,52 @@ function refreshFeed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
|
let currentSearchQuery = '';
|
||||||
|
|
||||||
document.querySelector('.search-input').addEventListener('keypress', function(e) {
|
document.querySelector('.search-input').addEventListener('keypress', function(e) {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
const query = this.value.trim();
|
const query = this.value.trim();
|
||||||
if (query) {
|
performSearch(query);
|
||||||
alert(`Search functionality coming soon! You searched for: "${query}"`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.querySelector('.search-btn').addEventListener('click', function() {
|
||||||
|
const query = document.querySelector('.search-input').value.trim();
|
||||||
|
performSearch(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
function performSearch(query) {
|
||||||
|
currentSearchQuery = query;
|
||||||
|
currentPage = 1;
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
document.querySelector('.content-header h1').textContent = `Search results for "${query}"`;
|
||||||
|
// Show clear search button
|
||||||
|
if (!document.querySelector('.clear-search-btn')) {
|
||||||
|
const clearBtn = document.createElement('button');
|
||||||
|
clearBtn.className = 'clear-search-btn';
|
||||||
|
clearBtn.textContent = '✕ Clear search';
|
||||||
|
clearBtn.onclick = clearSearch;
|
||||||
|
document.querySelector('.content-actions').prepend(clearBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
currentSearchQuery = '';
|
||||||
|
document.querySelector('.search-input').value = '';
|
||||||
|
// Restore original feed title based on user state
|
||||||
|
const isAnonymous = {{ 'true' if anonymous else 'false' }};
|
||||||
|
document.querySelector('.content-header h1').textContent = isAnonymous ? 'Public Feed' : 'Your Feed';
|
||||||
|
const clearBtn = document.querySelector('.clear-search-btn');
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.remove();
|
||||||
|
}
|
||||||
|
loadPosts();
|
||||||
|
}
|
||||||
|
|
||||||
// Setup infinite scroll functionality
|
// Setup infinite scroll functionality
|
||||||
function setupInfiniteScroll() {
|
function setupInfiniteScroll() {
|
||||||
if (!userSettings?.experience?.infinite_scroll) {
|
if (!userSettings?.experience?.infinite_scroll) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Log In - BalanceBoard{% endblock %}
|
{% block title %}Log In - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Welcome back!</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,6 +37,10 @@
|
|||||||
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
<label for="remember" style="margin-bottom: 0;">Remember me</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="text-align: right; margin-bottom: 16px;">
|
||||||
|
<a href="{{ url_for('password_reset_request') }}" style="color: var(--primary-color); text-decoration: none; font-size: 14px;">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Log In</button>
|
<button type="submit">Log In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -44,6 +48,7 @@
|
|||||||
<span>or</span>
|
<span>or</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if auth0_configured %}
|
||||||
<div class="social-auth-buttons">
|
<div class="social-auth-buttons">
|
||||||
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
<a href="{{ url_for('auth0_login') }}" class="social-btn auth0-btn">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -52,6 +57,7 @@
|
|||||||
Continue with Auth0
|
Continue with Auth0
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
<p>Don't have an account? <a href="{{ url_for('signup') }}">Sign up</a></p>
|
||||||
|
|||||||
43
templates/password_reset.html
Normal file
43
templates/password_reset.html
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Set New Password - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
|
<p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">New Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus minlength="6">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Confirm New Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Reset Password</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
41
templates/password_reset_request.html
Normal file
41
templates/password_reset_request.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Reset Password - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-logo">
|
||||||
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
|
<p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash-message {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" class="auth-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
<input type="email" id="email" name="email" required autofocus>
|
||||||
|
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
|
||||||
|
Enter the email address associated with your account and we'll send you a password reset link.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit">Send Reset Link</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p>Remember your password? <a href="{{ url_for('login') }}">Log in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ post.title }} - BalanceBoard{% endblock %}
|
{% block title %}{{ post.title }} - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Modern Top Navigation -->
|
<!-- Modern Top Navigation -->
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
<div class="nav-content">
|
<div class="nav-content">
|
||||||
<div class="nav-left">
|
<div class="nav-left">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard" class="nav-logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }}" class="nav-logo">
|
||||||
<span class="brand-text"><span class="brand-balance">balance</span><span class="brand-board">Board</span></span>
|
<span class="brand-text">{{ APP_NAME }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
🐙 View on GitHub
|
🐙 View on GitHub
|
||||||
{% elif post.platform == 'devto' %}
|
{% elif post.platform == 'devto' %}
|
||||||
📝 View on Dev.to
|
📝 View on Dev.to
|
||||||
{% elif post.platform == 'stackoverflow' %}
|
{% elif post.platform == 'stackexchange' %}
|
||||||
📚 View on Stack Overflow
|
📚 View on Stack Overflow
|
||||||
{% else %}
|
{% else %}
|
||||||
🔗 View Original Source
|
🔗 View Original Source
|
||||||
@@ -136,10 +136,8 @@
|
|||||||
<section class="comments-section">
|
<section class="comments-section">
|
||||||
<h2>Comments ({{ comments|length }})</h2>
|
<h2>Comments ({{ comments|length }})</h2>
|
||||||
|
|
||||||
{% if comments %}
|
{% macro render_comment(comment, depth=0) %}
|
||||||
<div class="comments-list">
|
<div class="comment" style="margin-left: {{ depth * 24 }}px;">
|
||||||
{% for comment in comments %}
|
|
||||||
<div class="comment">
|
|
||||||
<div class="comment-header">
|
<div class="comment-header">
|
||||||
<span class="comment-author">{{ comment.author }}</span>
|
<span class="comment-author">{{ comment.author }}</span>
|
||||||
<span class="comment-separator">•</span>
|
<span class="comment-separator">•</span>
|
||||||
@@ -153,7 +151,21 @@
|
|||||||
<span>▲ {{ comment.score or 0 }}</span>
|
<span>▲ {{ comment.score or 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if comment.replies %}
|
||||||
|
<div class="comment-replies">
|
||||||
|
{% for reply in comment.replies %}
|
||||||
|
{{ render_comment(reply, depth + 1) }}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% if comments %}
|
||||||
|
<div class="comments-list">
|
||||||
|
{% for comment in comments %}
|
||||||
|
{{ render_comment(comment) }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -554,12 +566,24 @@
|
|||||||
.comment {
|
.comment {
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid #f1f5f9;
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment:last-child {
|
.comment:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Threaded comment styling */
|
||||||
|
.comment[style*="margin-left"] {
|
||||||
|
padding-left: 16px;
|
||||||
|
border-left: 2px solid #e2e8f0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-replies {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-header {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -654,6 +678,10 @@ function sharePost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePost() {
|
function savePost() {
|
||||||
|
// TODO: Implement save post functionality
|
||||||
|
// User can save posts to their profile for later viewing
|
||||||
|
// This needs database backend integration with user_saved_posts table
|
||||||
|
// Same implementation needed as dashboard.html savePost function
|
||||||
alert('Save functionality coming soon!');
|
alert('Save functionality coming soon!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Settings - BalanceBoard{% endblock %}
|
{% block title %}Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -230,6 +230,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Community Settings - BalanceBoard{% endblock %}
|
{% block title %}Community Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
.platform-icon.reddit { background: #ff4500; }
|
.platform-icon.reddit { background: #ff4500; }
|
||||||
.platform-icon.hackernews { background: #ff6600; }
|
.platform-icon.hackernews { background: #ff6600; }
|
||||||
.platform-icon.lobsters { background: #ac130d; }
|
.platform-icon.lobsters { background: #ac130d; }
|
||||||
.platform-icon.stackoverflow { background: #f48024; }
|
.platform-icon.stackexchange { background: #f48024; }
|
||||||
|
|
||||||
.community-grid {
|
.community-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -235,6 +235,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Community Settings</h1>
|
<h1>Community Settings</h1>
|
||||||
@@ -268,7 +269,7 @@
|
|||||||
<div class="platform-group">
|
<div class="platform-group">
|
||||||
<h3>
|
<h3>
|
||||||
<span class="platform-icon {{ platform }}">
|
<span class="platform-icon {{ platform }}">
|
||||||
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackoverflow' %}S{% endif %}
|
{% if platform == 'reddit' %}R{% elif platform == 'hackernews' %}H{% elif platform == 'lobsters' %}L{% elif platform == 'stackexchange' %}S{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{{ platform|title }}
|
{{ platform|title }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Experience Settings - BalanceBoard{% endblock %}
|
{% block title %}Experience Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -241,6 +241,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="experience-settings">
|
<div class="experience-settings">
|
||||||
<div class="experience-header">
|
<div class="experience-header">
|
||||||
<h1>Experience Settings</h1>
|
<h1>Experience Settings</h1>
|
||||||
@@ -330,6 +331,34 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Time-based Content Filter -->
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-content">
|
||||||
|
<div class="setting-text">
|
||||||
|
<h3>Show Recent Posts Only</h3>
|
||||||
|
<p>Only show posts from the last few days instead of all posts</p>
|
||||||
|
<div class="time-filter-options" style="margin-top: 12px; {% if not experience_settings.time_filter_enabled %}display: none;{% endif %}">
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="1" {% if experience_settings.time_filter_days == 1 %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last 24 hours
|
||||||
|
</label>
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="3" {% if experience_settings.time_filter_days == 3 %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last 3 days
|
||||||
|
</label>
|
||||||
|
<label style="color: var(--text-secondary); font-size: 0.9rem; margin-right: 16px;">
|
||||||
|
<input type="radio" name="time_filter_days" value="7" {% if experience_settings.time_filter_days == 7 or not experience_settings.time_filter_days %}checked{% endif %} style="margin-right: 4px;">
|
||||||
|
Last week
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="toggle-switch">
|
||||||
|
<input type="checkbox" name="time_filter_enabled" {% if experience_settings.time_filter_enabled %}checked{% endif %} onchange="toggleTimeFilterOptions(this)">
|
||||||
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -338,4 +367,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleTimeFilterOptions(checkbox) {
|
||||||
|
const options = document.querySelector('.time-filter-options');
|
||||||
|
if (checkbox.checked) {
|
||||||
|
options.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
options.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Filter Settings - BalanceBoard{% endblock %}
|
{% block title %}Filter Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -263,6 +263,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Filter Settings</h1>
|
<h1>Filter Settings</h1>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Profile Settings - BalanceBoard{% endblock %}
|
{% block title %}Profile Settings - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
@@ -225,6 +225,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% include '_nav.html' %}
|
||||||
<div class="settings-container">
|
<div class="settings-container">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h1>Profile Settings</h1>
|
<h1>Profile Settings</h1>
|
||||||
@@ -242,7 +243,6 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
<h2>Profile Picture</h2>
|
<h2>Profile Picture</h2>
|
||||||
<div class="profile-avatar">
|
<div class="profile-avatar">
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<p>Upload a new profile picture to personalize your account</p>
|
<p>Upload a new profile picture to personalize your account</p>
|
||||||
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
<form id="upload-form" method="POST" action="{{ url_for('upload_avatar') }}" enctype="multipart/form-data">
|
||||||
<div class="file-upload">
|
<div class="file-upload">
|
||||||
<input type="file" id="avatar" name="avatar" accept="image/*" onchange="this.form.submit()">
|
<input type="file" id="avatar" name="avatar" accept="image/*">
|
||||||
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
<label for="avatar" class="file-upload-label">Choose New Picture</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
<p class="help-text">PNG, JPG, or GIF. Maximum size 2MB.</p>
|
||||||
@@ -266,7 +266,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<div class="profile-section">
|
<div class="profile-section">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}Sign Up - BalanceBoard{% endblock %}
|
{% block title %}Sign Up - {{ APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-logo">
|
<div class="auth-logo">
|
||||||
<img src="{{ url_for('serve_logo') }}" alt="BalanceBoard Logo">
|
<img src="{{ url_for('serve_logo') }}" alt="{{ APP_NAME }} Logo">
|
||||||
<h1><span class="balance">balance</span>Board</h1>
|
<h1><span class="balance">balance</span>Board</h1>
|
||||||
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
<p style="color: var(--text-secondary); margin-top: 8px;">Create your account</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@
|
|||||||
|
|
||||||
<div class="engagement-info">
|
<div class="engagement-info">
|
||||||
<span class="reply-count">{{replies}} replies</span>
|
<span class="reply-count">{{replies}} replies</span>
|
||||||
|
<button class="bookmark-btn" onclick="toggleBookmark('{{id}}', this)" data-post-id="{{id}}">
|
||||||
|
<span class="bookmark-icon">🔖</span>
|
||||||
|
<span class="bookmark-text">Save</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,9 @@
|
|||||||
<a href="/settings/filters" class="dropdown-item">
|
<a href="/settings/filters" class="dropdown-item">
|
||||||
🎛️ Filters
|
🎛️ Filters
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/bookmarks" class="dropdown-item">
|
||||||
|
📚 Bookmarks
|
||||||
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a href="/admin" class="dropdown-item" style="display: none;">
|
<a href="/admin" class="dropdown-item" style="display: none;">
|
||||||
🛠️ Admin
|
🛠️ Admin
|
||||||
@@ -352,6 +355,79 @@
|
|||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', checkAuthState);
|
document.addEventListener('DOMContentLoaded', checkAuthState);
|
||||||
|
|
||||||
|
// Bookmark functionality
|
||||||
|
async function toggleBookmark(postId, button) {
|
||||||
|
try {
|
||||||
|
button.disabled = true;
|
||||||
|
const originalText = button.querySelector('.bookmark-text').textContent;
|
||||||
|
button.querySelector('.bookmark-text').textContent = 'Saving...';
|
||||||
|
|
||||||
|
const response = await fetch('/api/bookmark', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ post_uuid: postId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || 'Failed to toggle bookmark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
updateBookmarkButton(button, data.bookmarked);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling bookmark:', error);
|
||||||
|
alert('Error: ' + error.message);
|
||||||
|
button.querySelector('.bookmark-text').textContent = originalText;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBookmarkButton(button, isBookmarked) {
|
||||||
|
const icon = button.querySelector('.bookmark-icon');
|
||||||
|
const text = button.querySelector('.bookmark-text');
|
||||||
|
|
||||||
|
if (isBookmarked) {
|
||||||
|
button.classList.add('bookmarked');
|
||||||
|
icon.textContent = '📌';
|
||||||
|
text.textContent = 'Saved';
|
||||||
|
} else {
|
||||||
|
button.classList.remove('bookmarked');
|
||||||
|
icon.textContent = '🔖';
|
||||||
|
text.textContent = 'Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bookmark states for visible posts
|
||||||
|
async function loadBookmarkStates() {
|
||||||
|
const bookmarkButtons = document.querySelectorAll('.bookmark-btn');
|
||||||
|
|
||||||
|
for (const button of bookmarkButtons) {
|
||||||
|
const postId = button.getAttribute('data-post-id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookmark-status/${postId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.bookmarked) {
|
||||||
|
updateBookmarkButton(button, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading bookmark status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bookmark states when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(loadBookmarkStates, 500); // Small delay to ensure posts are rendered
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -460,6 +460,45 @@ header .post-count::before {
|
|||||||
.engagement-info {
|
.engagement-info {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark Button */
|
||||||
|
.bookmark-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
background: rgba(77, 182, 172, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn.bookmarked {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn.bookmarked .bookmark-icon {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tags */
|
/* Tags */
|
||||||
|
|||||||
Reference in New Issue
Block a user