41 Commits

Author SHA1 Message Date
c5622d8e38 Docker: add .env.example, .dockerignore; polish README Docker section
- Add .env.example (referenced by README Quick Start; populates
  SECRET_KEY/POSTGRES_*/AUTH0_* used by docker-compose).
- Add .dockerignore so `COPY . .` no longer bakes .git, __pycache__,
  venv, local data, and secrets into the image.
- Rewrite README Docker Deployment into a single `docker compose
  up -d --build` flow on port 5021; fix local-dev access port 5000->5021.
- Drop obsolete `version: '3.8'` from docker-compose.yml.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 04:00:10 -05:00
0d178fdf3d Merge pull request 'refactor/phase-0-bugfixes' (#30) from refactor/phase-0-bugfixes into main
Reviewed-on: https://git.scorpi.us/chelsea/balanceboard/pulls/30
2026-07-02 21:35:16 -05:00
6cf35ca034 Phase 1-3 + 6: pluggable filter system, app factory, test harness
Implements the parallel.md workstreams (Agents A-E) toward REFACTOR_GOAL.md.

Phase 1 — app factory + blueprints + /api/v1:
- app.py -> create_app() factory (no module-level app); entrypoints updated
- routes/ (auth, pages, settings, admin, assets) + blueprints/api.py at /api/v1
- config.py / extensions.py / security.py extracted; services/ layer added
- endpoint names preserved so template url_for() calls keep resolving
  (static check: all 27 template url_for endpoints are defined routes)

Phase 2 — one pluggable filter system:
- filter_pipeline/registry.py: @register_stage / @register_plugin + discover_modules
- engine._init_stages() instantiates registered stages (no hardcoded dict);
  process_batch is AI-aware: only short-circuits to the AI-disabled path when
  a filterset's stages declare requires_ai, so offline filtersets run with AI off
- BaseFilterPlugin gets a consumer (stages/plugins.py); Keyword/Quality
  re-enabled via filter_config.json plugins config
- comment tree modes ported to stages/comment_filter.py + shared rules.py;
  wired into /api/v1/posts/<uuid> and /api/v1/comments/<uuid> via
  FilterEngine.filter_comments() (fails open)
- offline quality_filter filterset exercises plugins+ranker without AI
- legacy filter_lib / comment_lib / html_generation_lib / generate_html /
  active_html path deleted

Phase 3 prep — pluggable fetchers + Postgres models:
- Post / Comment SQLAlchemy models added to models.py
- migrate_content_to_db.py backfill (idempotent by uuid, batched, --dry-run)
- platforms/ fetcher registry (extension point)
- live reads/writes still go through PostService (disk JSON); cutover deferred

Phase 6 — test harness:
- pytest.ini + tests/ (conftest with in-memory SQLite fixture, no Postgres;
  stubbed polling/filter singletons)
- test_app_factory.py (route registration, no module-level app),
  test_api_contracts.py (posts/post_detail/comments/filters shape with
  monkeypatched post_service + get_filter_engine),
  test_filter_pipeline.py + test_plugin_contract.py (Flask-free; validated
  locally 12/12 incl. drop-in stage/plugin discovered with zero core edits)

Other: .gitignore added (__pycache__, data/, secrets); pytest in requirements.

Verification: py_compile clean across the project; the Flask-free filter-pipeline
and plugin-contract tests pass locally. App-factory / API-contract tests need
deps+docker to run; runtime flask routes / Auth0-repeated-create_app also gated
on docker.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 02:29:46 -05:00
cdba720a1c Phase 0: stop-the-bleeding bugfixes
- Defer polling_service.start() and FilterEngine init to a one-shot
  before_request hook so importing app.py no longer spawns a scheduler
  thread (also fixes migrate_*.py import side effects).
- Fix migrate_bookmarks.py: init_db returns None, so use the shared
  `db` instead of assigning its None return.
- Enforce MIN_PASSWORD_LENGTH (8) in the password-reset route for
  consistency with signup (was hardcoded 6).
- post_detail.html: replace undefined moment(...).fromNow() (always
  "Recently") with a new timeago Jinja filter + data-timestamp attrs
  that also drive the existing JS updater; make nl2br escape-then-Markup
  and drop | safe from comment/post content to close the XSS hole.
- filter_pipeline: when AI is disabled but a filterset requires it,
  pass posts through with status=FAILED + explicit error instead of
  silently degrading to no_filter.

No source-file mojibake found; content-encoding ingest is a Phase 3 concern.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 01:30:13 -05:00
chelsea
718cc36973 Fix platform-agnostic community filtering logic
Enhanced the community matching logic in /api/posts to work for all platforms by using platform-agnostic matching rules:

1. Exact source match (source == community)
2. Platform match (platform == community)
3. Partial source match (substring)
4. Partial post ID match (substring)

This resolves the issue where users with empty communities couldn't see posts and works equally well for Reddit, HackerNews, Lobsters, GitHub, etc.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 18:04:22 -05:00
chelsea
9d286c8466 Fix IndentationError causing logged-in users to see no feed
The issue was caused by incorrect indentation in the community filtering logic (lines 451-468) which prevented the app from starting properly.

Fixed:
- Corrected 13-space indentation to 12 spaces for proper Python syntax
- Ensured consistent 4-space tab width throughout the block

This resolves the urgent issue where logged-in users couldn't see their feed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 16:30:21 -05:00
chelsea
e40d5463a6 Fix community visibility for logged-in users - add robust error handling and fallback communities to prevent 'No communities available' display 2025-10-12 16:04:59 -05:00
chelsea
52cf5c0092 Standardize admin panel styling inconsistencies
- Added missing form classes to admin base template
- Standardized button styles across all admin templates
- Added modal styling with consistent spacing and colors
- Added form input, select, and label styling
- Added utility classes for add-source forms
- Removed duplicate CSS classes from individual templates
- Improved button hover states and disabled states

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:27:53 -05:00
chelsea
efabac7fd5 Fix community settings page 500 error
- Added comprehensive error handling for platform config loading
- Added validation for collection_targets structure
- Added defensive programming for community data processing
- Added logging for debugging community list building
- Prevents crashes when config files are missing or malformed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:22:53 -05:00
chelsea
b438762758 Fix post detail page navigation issues
- Updated back button to use JavaScript function instead of direct href
- Added URL parameter preservation when navigating back to feed
- Improved fallback logic to handle browser history properly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:16:38 -05:00
chelsea
301c2b33f0 Re-enable community filtering for logged-in users
Restores the user's community preference filtering that was temporarily
disabled to fix an urgent feed issue for logged-in users.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 15:14:44 -05:00
chelsea
fc440eafa3 Fix post detail page navigation issues
- Change back to feed button from onclick to proper link to ensure it always works
- Make platform badge clickable to external source when available (upper left clickable link)
- This addresses the issue where posts had no upper left clickable link and back button didn't work
2025-10-12 13:28:41 -05:00
chelsea
343d6b51ea Temporarily disable community filter to fix urgent issue: logged in users not seeing feed
This disables the community-based filtering in /api/posts to allow logged in users to see posts in their feed. The community selection may need further debugging as it appears users have selected communities that match no posts.
2025-10-12 13:27:41 -05:00
chelsea
02d4e5a7e4 Fix community settings and admin panel layout issues
## Problems Fixed:
1. **Community settings grid layout** - Cards were too spread out horizontally
2. **Missing navigation elements** - No back link or breadcrumbs on community settings
3. **Admin panel grid layouts** - Stats and system info cards too wide on large screens
4. **Responsive layout issues** - Poor layout on different screen sizes

## Root Cause:
The CSS grid layouts were using `auto-fill` and `auto-fit` with minimum sizes that
caused elements to spread too wide on large screens, creating an awkward UI.

## Solutions Implemented:

### Community Settings Page:
- **Improved grid layout** - Better responsive grid with max 3 columns
- **Added back navigation** - "← Back to Settings" link for better UX
- **Responsive breakpoints** - Single column on mobile, max 3 on desktop
- **Better max-width** - Increased container width for better balance

### Admin Panel Base Template:
- **Fixed admin stats grid** - Limited to 4 columns max, responsive breakpoints
- **Fixed system info grid** - Limited to 3 columns max with proper responsive design
- **Added mobile support** - Single column layout on mobile devices
- **Improved spacing** - Better gap management and max-width constraints

### CSS Improvements:
- Mobile-first responsive design
- Proper breakpoints at 768px and 1200px
- Maximum column limits to prevent over-stretching
- Better visual balance on all screen sizes

The admin and community settings interfaces now have proper, responsive layouts\!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 04:08:09 -05:00
chelsea
b5d30c6427 Fix community settings page 500 error - missing platform config loading
## Problem Fixed:
The /settings/communities page was throwing a 500 error with:
`NameError: name 'platform_config' is not defined`

## Root Cause:
In the settings_communities() function, I was trying to use platform_config
without loading it first using the load_platform_config() function.

## Solution:
Added platform_config = load_platform_config() before using the variable.

This fixes the 500 error when users try to access community settings.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 04:00:47 -05:00
chelsea
736d8fc7c1 Fix Issue #16: Resolve admin setup page styling and form corruption
## Problem Fixed:
The admin setup page (admin_setup.html) had incomplete form styling that could appear "corrupted" or broken, especially when form validation errors occurred.

## Root Cause:
The admin_setup.html template was missing explicit form group styles and relying only on base.html auth-form styles, which weren't sufficient for all form states and could lead to layout issues.

## Solution Implemented:

### Enhanced Admin Setup Form Styling
- **Added explicit form-group styles** - Ensures proper spacing and layout
- **Complete auth-form style definitions** - All form elements now have consistent styling
- **Proper focus states** - Form inputs have correct focus indicators
- **Box-sizing fix** - Prevents layout overflow issues
- **Enhanced button styling** - Consistent with other admin pages
- **Form validation support** - Proper styling for error states

### Style Additions:
- Form group margin and spacing
- Input field padding, borders, and backgrounds
- Focus states with proper color transitions
- Button hover effects and animations
- Auth footer styling for better layout

The admin setup page now has robust, consistent styling that matches the rest of the admin interface and won't appear corrupted under various states.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:33:48 -05:00
chelsea
8d4c8dfbad Fix Issue #17: Standardize admin page styling and eliminate inconsistencies
## Problems Fixed:
1. **Template inheritance inconsistency** - Some admin pages used standalone HTML while others extended base.html
2. **CSS duplication** - Each admin page had duplicate styles for similar components
3. **Header styling variations** - Different admin pages had slightly different header styles
4. **Inconsistent class naming** - Mixed naming patterns across admin templates

## Root Cause:
Admin pages were developed independently without a shared styling foundation, leading to code duplication and visual inconsistencies.

## Solution Implemented:

### New Shared Admin Base
- **Created `_admin_base.html`** - Unified base template for all admin pages
- **Consolidated styles** - Moved common admin styles to shared base template
- **Standardized components** - Unified buttons, tables, badges, forms, etc.
- **Consistent layout** - Standard admin container, header, and navigation structure

### Refactored Templates
- **`admin.html`** - Now extends `_admin_base.html`, removed 300+ lines of duplicate CSS
- **`admin_polling.html`** - Converted to use base template, cleaner structure
- **`admin_polling_logs.html`** - Completely rewritten to use base template
- **Consistent class names** - All admin tables now use `.admin-table` instead of mixed names

### Benefits
- **Maintainability** - Single source of truth for admin styling
- **Consistency** - All admin pages now have identical look and feel
- **Performance** - Reduced CSS duplication improves load times
- **Extensibility** - Easy to add new admin pages with consistent styling

All admin pages now share a unified, professional appearance\!

Commit: [current]

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:31:07 -05:00
chelsea
94ffa69d21 Fix Issue #18: Community settings now match admin panel configuration
## Problem Fixed:
Community selection in settings was using hardcoded list that didn't match the actual enabled communities in the admin panel's collection_targets configuration.

## Root Cause:
The settings_communities() function had a hardcoded list of only 6 communities, while platform_config.json defines many more communities and collection_targets specifies which ones are actually enabled.

## Solution:
- **Dynamic community loading** - Reads from platform_config.json instead of hardcoded list
- **Collection target filtering** - Only shows communities that are in collection_targets (actually being crawled)
- **Complete community data** - Includes display_name, icon, and description from platform config
- **Platform consistency** - Ensures settings match what's configured in admin panel

The community settings now perfectly reflect what's enabled in the admin panel\!

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:26:50 -05:00
chelsea
146ad754c0 Fix filtration system - connect sidebar filters to filter pipeline
Investigation revealed the filtration system had several issues:

1. **Hardcoded filters**: Sidebar had hardcoded filters that didn't match actual filtersets
2. **No backend integration**: Filter switching didn't pass filter parameters to API
3. **Missing filter endpoint**: No API to get available filters dynamically

Fixes implemented:
- Added /api/filters endpoint to get available filtersets from filter engine
- Updated dashboard to load filters dynamically from backend
- Connected filter switching to actually apply different filtersets
- Added filter override parameter to /api/posts endpoint
- Updated JavaScript to properly handle filter state and switching
- Added proper loading states and error handling

Available filters now show:
- All Content (no_filter)
- Safe Content (safe_content)
- Tech Only (tech_only)
- High Quality (high_quality)
- Custom Example (custom_example)

The filtration system now properly applies the selected filterset to posts
using the existing filter pipeline infrastructure.

Fixes #19

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:22:11 -05:00
chelsea
cdc415b0c1 Implement comprehensive bookmark/save system
Added full bookmark functionality for users to save posts:

Backend Features:
- New Bookmark model with user_id, post_uuid, and cached metadata
- API endpoints:
  - POST /api/bookmark - Toggle bookmark status
  - GET /api/bookmarks - Get user's bookmarks with pagination
  - GET /api/bookmark-status/<uuid> - Check if post is bookmarked
- Database migration script for bookmarks table

Frontend Features:
- New /bookmarks page with post list and management
- Bookmark buttons on post cards with save/unsave toggle
- Real-time bookmark status loading and updates
- Navigation menu integration
- Responsive design with archived post handling

UI Components:
- Modern bookmark button with hover states
- Pagination for bookmark listings
- Empty state for users with no bookmarks
- Error handling and loading states
- Remove bookmark functionality

The system handles:
- Unique bookmarks per user/post combination
- Cached post metadata for performance
- Graceful handling of deleted/archived posts
- Authentication requirements for all bookmark features

Users can now save posts for later reading and manage their bookmarks
through a dedicated bookmarks page.

Fixes #20

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:18:55 -05:00
chelsea
48868df4d9 Add time-based post filtering feature
Implemented configurable time-based filtering to show only recent posts:

- Added new experience settings for time filtering:
  - time_filter_enabled: Toggle to enable/disable time filtering
  - time_filter_days: Number of days to show (1, 3, or 7 days)

Changes:
- Updated settings_experience.html with time filter controls
- Added JavaScript toggle for showing/hiding time filter options
- Modified backend to save and validate new time filter settings
- Updated API posts endpoint to filter posts by timestamp when enabled
- Added time filtering to anonymous user default settings

Users can now limit their feed to show only posts from:
- Last 24 hours
- Last 3 days
- Last week (default)

This addresses the need for a "show only posts from last x time" feature
as a default filtering option.

Fixes #21

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:13:58 -05:00
chelsea
ac94215f84 Add configurable logo and application name support
Added environment variables APP_NAME and LOGO_PATH to make the application
branding configurable:

- APP_NAME (default: 'BalanceBoard'): Sets the application name
- LOGO_PATH (default: 'logo.png'): Sets the logo file path

Changes:
- Added configuration variables to app.py
- Updated logo serving route to support custom paths
- Added template context processor to inject APP_NAME
- Updated all templates to use {{ APP_NAME }} instead of hardcoded 'BalanceBoard'
- Updated navigation and branding to use configurable values

Users can now customize their installation by setting:
export APP_NAME="My Custom Board"
export LOGO_PATH="/path/to/my/logo.png"

Fixes #22

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:11:23 -05:00
chelsea
b47155cc36 Fix Stack Overflow crawling platform name mismatch
The issue was that Stack Overflow was configured with platform name
'stackoverflow' but the data collection code expected 'stackexchange'.
Fixed by:

1. Renamed platform from 'stackoverflow' to 'stackexchange' in platform_config.json
2. Added Stack Overflow collection target to enable crawling
3. Updated templates and app.py to use the correct platform name
4. Added default 'stackoverflow' community alongside existing featured/newest

This resolves the platform name mismatch that prevented Stack Overflow
from being crawlable.

Fixes #23

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:07:41 -05:00
chelsea
29a9d521e7 Fix community selection filtering in API posts endpoint
The issue was that user community preferences stored in settings weren't
being applied when fetching posts. Added logic to filter posts based on
user's selected communities from their settings.

Fixes #24

~claude

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 03:05:02 -05:00
chelsea
2c518fce4a Fix avatar upload username/email requirement issue (Issue #12)
- Remove duplicate onchange handler from avatar file input
- Prevent potential form submission conflicts between inline JS and event listener
- Avatar upload now properly uses only the JavaScript event listener with validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 00:58:03 -05:00
f92851b415 Fix navigation and logo issues (Issues #13, #14)
- Add navigation bar to admin setup page (Fixes issue #14)
- Make logo clickable to go to front page in admin setup (Fixes issue #13)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-12 00:50:11 -05:00
466badd326 Fix avatar upload requiring username/email (Issue #12)
- Remove unnecessary wrapper form around Profile Picture section
- Avatar upload form is now standalone, not nested
- Prevents browser validation from requiring username/email when uploading avatar

Fixes #12
2025-10-11 23:58:01 -05:00
29b4a9d339 Add topbar navigation to all pages and make logo clickable (Issues #14, #13)
- Create reusable _nav.html navigation include
- Add topbar to all settings pages (settings, profile, communities, filters, experience)
- Add topbar to all admin pages (admin, polling, polling_logs, setup)
- Replace hardcoded nav in dashboard with include
- Wrap logo in link to index page (fixes clicking logo to go home)

Fixes #14, #13
2025-10-11 23:56:52 -05:00
b0b9a9e912 Fix Auth0 500 error when not configured (Issue #5)
- Add check for AUTH0 credentials before attempting login
- Show friendly error message if Auth0 not configured
- Hide Auth0 button on login page when not configured
- Add try/catch for auth0.authorize_redirect() failures

Fixes #5
2025-10-11 23:40:48 -05:00
6a1834bbd2 Fix quick stats to show dynamic data (Issue #7)
- Add calculate_quick_stats() to get real-time post counts
- Calculate posts from last 24 hours instead of hardcoded value
- Pass quick_stats to dashboard template
- Update template to display dynamic posts_today count

Fixes #7
2025-10-11 23:38:06 -05:00
63fa44ed2c Fix comment tree display (Issue #10)
- Add build_comment_tree() to organize comments hierarchically
- Create recursive Jinja macro to render nested comments
- Add visual styling with left border and indentation
- Comments now display as threaded tree structure

Fixes #10
2025-10-11 23:36:27 -05:00
b84ebce8f1 Fix /settings/filters 500 error - change filter_sets from list to dict
Template expects dictionary but route was passing a list, causing:
jinja2.exceptions.UndefinedError: 'list object' has no attribute 'no_filter'

Changed to build dictionary mapping filter names to full configs.

Fixes #9
2025-10-11 23:15:29 -05:00
b87fb829ca Add comprehensive filter pipeline documentation
Documentation includes:
- Architecture overview (3-level caching)
- Pipeline stages description
- Configuration guide
- Usage examples (user & developer)
- AI integration setup
- Performance benchmarks
- Monitoring and troubleshooting
- Plugin system guide
- Built-in filtersets documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 23:00:34 -05:00
8c1e055a05 Integrate FilterEngine with app.py (Phase 4)
Complete integration of filter pipeline with web application:

App.py Integration:
- Initialize FilterEngine singleton at startup
- Update /api/posts endpoint to use FilterEngine.apply_filterset()
- Apply user's filterset preference from settings
- Sort posts by filter_score (highest first), then timestamp
- Add filter metadata to post responses (filter_score, categories, tags)

Settings Page Updates:
- Dynamically load available filtersets from FilterEngine
- Show filterset descriptions in settings UI
- Validate filterset selection against FilterEngine

Security:
- Update _is_safe_filterset() to use FilterEngine's list
- Dynamic ALLOWED_FILTERSETS from filtersets.json

User Experience:
- Posts automatically filtered based on user preferences
- Quality/relevance scores affect post ordering
- Transparent filter metadata available in API

Caching:
- FilterEngine uses 3-level cache for efficiency
- Cache reused across page loads (5min TTL)
- AI results cached permanently

Next Steps:
- Polling service integration
- Database model for persistent results
- UI for cache stats and filter debugging

Related to filtering engine implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:57:18 -05:00
a3ea1e9bdb Add filter pipeline stages and plugins (Phase 2 & 3)
Implements complete content filtering pipeline with AI-powered analysis:

Phase 2 - Pipeline Stages:
- CategorizerStage: AI topic detection with content-hash caching
- ModeratorStage: Safety/quality analysis (violence, hate speech, quality scores)
- FilterStage: Fast rule-based filtering from filtersets.json
- RankerStage: Multi-factor scoring (quality, recency, source tier, engagement)

Phase 3 - Filter Plugins:
- KeywordFilterPlugin: Blocklist/allowlist keyword filtering
- QualityFilterPlugin: Quality metrics (length, caps, clickbait detection)

AI Client:
- OpenRouterClient: Llama 70B integration with retry logic
- Methods: categorize(), moderate(), score_quality(), analyze_sentiment()
- Content-hash based caching for cost efficiency

Pipeline Flow:
Raw Post → Categorizer → Moderator → Filter → Ranker → Scored Post

Key Features:
- All AI results cached permanently by content hash
- Parallel processing support (10 workers)
- Fallback modes when AI disabled
- Comprehensive scoring breakdown
- Plugin architecture for extensibility

Related to filtering engine implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:54:38 -05:00
94e12041ec Add filter pipeline core infrastructure (Phase 1)
Implements plugin-based content filtering system with multi-level caching:

Core Components:
- FilterEngine: Main orchestrator for content filtering
- FilterCache: 3-level caching (memory, AI results, filterset results)
- FilterConfig: Configuration loader for filter_config.json & filtersets.json
- FilterResult & AIAnalysisResult: Data models for filter results

Architecture:
- BaseStage: Abstract class for pipeline stages
- BaseFilterPlugin: Abstract class for filter plugins
- Multi-threaded parallel processing support
- Content-hash based AI result caching (cost savings)
- Filterset result caching (fast filterset switching)

Configuration:
- filter_config.json: AI models, caching, parallel workers
- Using only Llama 70B for cost efficiency
- Compatible with existing filtersets.json

Integration:
- apply_filterset() API compatible with user preferences
- process_batch() for batch post processing
- Lazy-loaded stages to avoid import errors when AI disabled

Related to issue #8 (filtering engine implementation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:46:10 -05:00
07df6d8f0a Fix 500 error: Change register route to signup
Fixed BuildError caused by incorrect endpoint name in anonymous mode.
The route is called 'signup' not 'register' in app.py line 878.

Error was:
werkzeug.routing.exceptions.BuildError: Could not build url for endpoint 'register'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:26:35 -05:00
3ab3b04643 Clean up redundant authentication checks in dashboard template
Removed redundant current_user.is_authenticated checks in the else block
of the navigation menu. The else block only executes for authenticated
users per app.py logic (line 278 vs 293), so the nested checks were
dead code that created confusion.

Changes:
- Removed defensive checks for unauthenticated users in authenticated block
- Added clarifying comment about when else block executes
- Simplified template logic for better maintainability
- Removed dead code paths (Anonymous User label, ? avatar)

Addresses concerns raised in Issue #2 about confusing template logic.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:08:42 -05:00
236ec2abbe Fix anonymous access 500 error and add environment variable control
- Fixed dashboard.html template error accessing current_user.username for anonymous users
- Added ALLOW_ANONYMOUS_ACCESS environment variable with default true
- Enhanced index route logic to properly check config before allowing anonymous access
- Added proper environment variable to docker-compose.yml
- Anonymous access now works without 500 server errors

Fixes issue #2 completely - anonymous access is now functional

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 21:44:13 -05:00
c7bd634ad6 Add search functionality (Issue #3)
Backend changes:
- Added search query parameter (q) to /api/posts endpoint
- Search filters posts by title, content, author, and source
- Case-insensitive search with substring matching

Frontend changes:
- Made search bar functional with Enter key and click support
- Added performSearch() function to trigger searches
- Added Clear Search button that appears during active search
- Search results update feed title to show query
- Integrated search with existing pagination and filtering
- Preserves anonymous/authenticated feed title when clearing search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:47:04 -05:00
5d6da930df Investigate comments loading issue (Issue #4)
- Added debug logging to post_detail route to track comment loading
- Created migration script for poll source fields (max_posts, fetch_comments, priority)
- Migration adds default values to ensure comments are fetched

The issue may be that existing poll sources in database dont have fetch_comments field.
Migration needs to be run on server with database access.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 19:43:55 -05:00
96 changed files with 8462 additions and 3906 deletions

38
.dockerignore Normal file
View File

@@ -0,0 +1,38 @@
# Version control
.git
.gitignore
# Python caches / virtualenvs
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.coverage
htmlcov/
# Local environment / secrets — never bake into the image
.env
.env.local
openrouter_key.txt
# Editor / OS
.DS_Store
.idea/
.vscode/
# Local runtime data (mounted as volumes in docker-compose, not baked in)
data/
backups/
app.log
*.db
*.sqlite
# Docker artifacts
Dockerfile
docker-compose.yml
.dockerignore
# Docs / design notes not needed at runtime
*.md

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# BalanceBoard environment configuration
# Copy this file to .env and edit values for your deployment:
# cp .env.example .env
# --- Flask ---
# Secret key for session signing. CHANGE THIS in production (use a long random string).
SECRET_KEY=change-this-secret-key-in-production
# Allow browsing the public feed without an account (true/false).
ALLOW_ANONYMOUS_ACCESS=true
# App branding (optional).
APP_NAME=BalanceBoard
LOGO_PATH=logo.png
# --- Database ---
# A full SQLAlchemy URI takes precedence over the POSTGRES_* vars below.
# Example: postgresql+psycopg2://balanceboard:balanceboard123@localhost:5432/balanceboard
DATABASE_URL=
# Used when DATABASE_URL is empty. docker-compose sets these for the app container;
# for local dev, point these at your own PostgreSQL instance.
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=balanceboard
POSTGRES_PASSWORD=balanceboard123
POSTGRES_DB=balanceboard
# --- Auth0 (optional; leave blank to disable OAuth login) ---
AUTH0_DOMAIN=
AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_AUDIENCE=

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# App runtime
data/
app.log
openrouter_key.txt
*.db
*.sqlite
# Test / CI
.pytest_cache/
.coverage
htmlcov/
# Editor / OS
.DS_Store
.idea/
.vscode/

View File

@@ -35,7 +35,6 @@ RUN mkdir -p \
/app/data/moderation \ /app/data/moderation \
/app/static/avatars \ /app/static/avatars \
/app/backups \ /app/backups \
/app/active_html \
&& chown -R appuser:appuser /app && chown -R appuser:appuser /app
# Switch to non-root user # Switch to non-root user
@@ -49,8 +48,8 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5021/ || exit 1 CMD curl -f http://localhost:5021/ || exit 1
# Set Flask app environment variable # Set Flask app environment variable
ENV FLASK_APP=app.py ENV FLASK_APP=app:create_app
# Run the application directly with Flask # Run the application directly with Flask
# Note: start_server.py has venv checks that don't apply in Docker # Note: start_server.py has venv checks that don't apply in Docker
CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5021"] CMD ["python", "-m", "flask", "--app", "app:create_app", "run", "--host=0.0.0.0", "--port=5021"]

368
FILTER_PIPELINE.md Normal file
View 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)

View File

@@ -44,7 +44,7 @@ A Reddit-style content aggregator that collects posts from multiple platforms (R
4. **Initialize the database** 4. **Initialize the database**
```bash ```bash
python3 -c "from app import app, db; app.app_context().push(); db.create_all()" python3 -c "from app import create_app; from database import db; app = create_app(); app.app_context().push(); db.create_all()"
``` ```
5. **Run migrations (if needed)** 5. **Run migrations (if needed)**
@@ -58,26 +58,41 @@ A Reddit-style content aggregator that collects posts from multiple platforms (R
``` ```
7. **Access the application** 7. **Access the application**
- Open browser to `http://localhost:5000` - Open browser to `http://localhost:5021`
- Create an account or browse anonymously - Create an account or browse anonymously
### Docker Deployment ### Docker Deployment
1. **Build the image** The included `docker-compose.yml` runs the app plus a PostgreSQL database, with persistent volumes for the database, collected posts, and backups.
1. **Configure environment**
```bash
cp .env.example .env
# Edit .env — at minimum set SECRET_KEY to a long random string
```
2. **Build and start the stack**
```bash
docker compose up -d --build
```
3. **Access the application**
- Open browser to `http://localhost:5021`
- Create an account or browse anonymously
Useful commands:
```bash
docker compose logs -f app # follow app logs
docker compose down # stop the stack (keeps data volumes)
docker compose down -v # stop and delete all data volumes
```
To publish the image to the registry instead of building locally:
```bash ```bash
docker build -t git.scorpi.us/chelsea/balanceboard:latest . docker build -t git.scorpi.us/chelsea/balanceboard:latest .
```
2. **Push to registry**
```bash
docker push git.scorpi.us/chelsea/balanceboard:latest 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. See [DEPLOYMENT.md](DEPLOYMENT.md) for detailed deployment instructions.
## Configuration ## Configuration

127
REFACTOR_GOAL.md Normal file
View File

@@ -0,0 +1,127 @@
# Goal: Refactor BalanceBoard into a client/server architecture
## Objective
Transform BalanceBoard from a Flask monolith with mixed server-rendered
Jinja pages and inline-JS API consumption into a clean two-tier system:
- a backend that is a pure JSON API, and
- a separate frontend client that consumes it.
Preserve all existing working logic (auth, polling, data collection, filter
pipeline, models) and fix the known bugs along the way.
## Target end state
- Backend: Flask serving only JSON under /api/*. No render_template, no
Jinja page routes, no static-HTML generation. Split into blueprints
(auth, posts, comments, bookmarks, settings, filters, admin).
Posts/comments stored in PostgreSQL, not JSON files. One filter system
(filter_pipeline/), not two. No import-time side effects.
- Frontend: a Vite-built SPA served as static files by Flask at /
(same origin), calling /api/* with session-cookie credentials. Replaces
dashboard.html, post_detail.html, settings/admin/bookmark templates,
and the dead generate_html.py path.
- Auth: Flask-Login session cookies retained (same-origin), exposed via
/api/auth/* endpoints. No JWT unless a cross-origin/mobile client is
later required.
## Non-goals (this round)
- Do not switch to FastAPI (defer; revisit after the split is stable).
- Do not introduce JWT/CORS (same-origin only).
- Do not rewrite the filter pipeline logic or data-collection fetchers;
only consolidate and wire them.
## Phases (each independently shippable)
### Phase 0 - Stop the bleeding (bugfixes, no architecture change)
- Fix UTF-8 mojibake across .py/.html/.json (icons, checkmarks, separators).
- Fix migrate_bookmarks.py (it treats init_db's None return as a db object).
- Make password min-length consistent (8 everywhere; reset route uses 6).
- Fix post_detail.html undefined moment filter (comment times always
render "Recently").
- Close the comment.content | safe XSS hole in post_detail.html.
- Make non-no_filter filtersets not silently no-op when AI is disabled
(reject clearly or document; do not silently pass everything).
- Remove import-time side effects from app.py (polling_service.start()
and filter_engine init must not run on import).
### Phase 1 - Complete the API surface in Flask
- Audit existing /api/* endpoints; fill gaps so every Jinja page's data
need has an API endpoint:
- /api/auth/login, /api/auth/register, /api/auth/me, /api/auth/logout,
/api/auth/password-reset/*
- /api/posts, /api/posts/<uuid> (post + comment tree), /api/comments/<post_uuid>
- /api/bookmarks, /api/bookmark (toggle), /api/bookmark-status/<uuid>
- /api/settings (GET/PUT profile, communities, filters, experience),
/api/filtersets, /api/platforms
- /api/admin/users/*, /api/admin/polling/*, /api/admin/cache, /api/admin/backup
- Convert server-rendered form POST routes (settings, profile, avatar
upload, admin) to JSON endpoints; keep flash-message behavior as API
status codes/messages.
- Split app.py into blueprints + a services/ layer.
### Phase 2 - Collapse the filter stack to one system
- Adopt filter_pipeline/ as the single filter engine.
- Port filter_lib's rule operators and comment_lib's tree modes
(tree-pruning, individual, score/time/length modes) into filter_pipeline
as a comment-filtering stage.
- Wire comment filtering into the live /api/posts/<uuid> path (today only
the dead static path filters comments).
- Delete filter_lib.py, comment_lib.py, html_generation_lib.py,
generate_html.py, the active_html/ route, and the theme template path.
Keep themes/ only as CSS/JS asset bundles.
### Phase 3 - Move posts/comments into PostgreSQL
- Add Post and Comment SQLAlchemy models; map the existing JSON schema
(uuid, platform, id, title, author, timestamp, score, replies, url,
content, source, tags, meta, moderation_uuid, parent_comment_uuid).
- Write a one-shot backfill migration that ingests data/posts/*.json and
data/comments/*.json into the DB.
- Replace _load_posts_cache() and the directory scans in /api/posts,
/api/platforms, /api/content-timestamp with DB queries + a real TTL cache
(Flask-Caching now; Redis layer later if needed).
- Keep data/ as an archive/export only, not the source of truth.
### Phase 4 - Build the SPA client (feature-by-feature parity)
- Scaffold a Vite SPA (framework TBD), served by Flask as static files at /
with a catch-all fallback to index.html.
- Build in order: feed (list + pagination + filters + communities) ->
post detail (comment tree) -> auth (login/register/password-reset) ->
bookmarks -> settings (profile/communities/filters/experience) ->
admin (users/polling/logs).
- Dev: Vite proxy -> Flask. Prod: Flask serves built dist/.
- Use credentials: 'include' on all fetch calls; session cookies do the rest.
### Phase 5 - Cut over and delete the old render path
- Once the SPA reaches parity, remove the Jinja templates, render_template
calls, the templates/ folder, and the static-HTML generation.
- Remove the now-dead _nav.html, base.html, page templates, and the
serve_theme/serve_logo page-serving routes (replace with plain static
asset routes).
- Update README/DEPLOYMENT/Dockerfile for the new single-container deploy.
### Phase 6 - Hardening
- Add a test suite (pytest for services + API; component tests for the SPA).
- Add lint/format (ruff + the SPA's linter) and a CI check.
- Add a charset/encoding CI gate so the mojibake never returns.
- Lock the API with a versioned prefix (/api/v1/*) so future changes do not
break shipped clients.
## Decisions baked in (defaults)
- Backend framework: Flask (kept) - not FastAPI this round.
- Auth: Flask-Login session cookies, same-origin - not JWT.
- Deployment: single container, Flask serves the built SPA - not split hosts.
- Storage: PostgreSQL for posts/comments - not JSON files.
## Open decisions (pick before Phase 4 starts)
- Frontend framework: React / Vue / Svelte / Solid. No wrong answer at this
app's size; pick what you enjoy maintaining.
- Whether to version the API as /api/v1 from day one (recommended - cheap
insurance).
## Success criteria
- app.py is gone or reduced to app-factory + blueprint registration.
- No render_template exists in the codebase.
- One filter system; one render path.
- Posts/comments queried from Postgres; no full-disk-scan cache.
- Importing the app module has zero side effects (no scheduler, no polling).
- The SPA, served by Flask, reproduces all current user-facing features.

1706
app.py

File diff suppressed because it is too large Load Diff

1
blueprints/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Flask blueprints for BalanceBoard."""

464
blueprints/api.py Normal file
View File

@@ -0,0 +1,464 @@
"""Versioned JSON API blueprint."""
import json
import logging
from datetime import datetime, timedelta
from flask import Blueprint, jsonify, request
from flask_login import current_user, login_required
from config import DEFAULT_PAGE_SIZE
from database import db
from extensions import get_filter_engine
from models import Bookmark
from security import is_safe_filterset
from services import get_display_name_for_source, load_platform_config, post_service
logger = logging.getLogger(__name__)
FILTER_ICON_MAP = {
"no_filter": "\U0001f310",
"safe_content": "\u2705",
"tech_only": "\U0001f4bb",
"high_quality": "\u2b50",
"custom_example": "\U0001f3af",
}
def _current_filterset():
"""Return selected filterset from query override or current user settings."""
filter_override = request.args.get("filter", "")
if filter_override and is_safe_filterset(filter_override):
return filter_override
if current_user.is_authenticated:
try:
user_settings = json.loads(current_user.settings) if current_user.settings else {}
return user_settings.get("filter_set", "no_filter")
except (json.JSONDecodeError, TypeError):
pass
return "no_filter"
FILTER_NAME_MAP = {
"no_filter": "All Content",
"safe_content": "Safe Content",
"tech_only": "Tech Only",
"high_quality": "High Quality",
"custom_example": "Custom Example",
}
def create_api_blueprint(name="api"):
"""Create the API blueprint so it can be mounted with a version prefix."""
bp = Blueprint(name, __name__)
@bp.get("/posts")
def posts():
"""Get paginated posts with filtering."""
try:
platform_config = load_platform_config()
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", DEFAULT_PAGE_SIZE))
community = request.args.get("community", "")
platform = request.args.get("platform", "")
search_query = request.args.get("q", "").lower().strip()
filter_override = request.args.get("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 (json.JSONDecodeError, TypeError):
filterset_name = "no_filter"
user_communities = []
time_filter_enabled = False
time_filter_days = 7
if filter_override and is_safe_filterset(filter_override):
filterset_name = filter_override
cached_posts, cached_comments = post_service.load()
time_cutoff = None
if time_filter_enabled:
cutoff_date = datetime.utcnow() - timedelta(days=time_filter_days)
time_cutoff = cutoff_date.timestamp()
raw_posts = []
for post_data in cached_posts.values():
if time_filter_enabled and time_cutoff:
post_timestamp = post_data.get("timestamp", 0)
if post_timestamp < time_cutoff:
continue
if community and post_data.get("source", "").lower() != community.lower():
continue
if platform and post_data.get("platform", "").lower() != platform.lower():
continue
if user_communities:
post_source = post_data.get("source", "").lower()
post_platform = post_data.get("platform", "").lower()
post_id = post_data.get("id", "").lower()
matches_community = any(
post_source == selected.lower()
or post_platform == selected.lower()
or selected.lower() in post_source
or selected.lower() in post_id
for selected in user_communities
if isinstance(selected, str)
)
if not matches_community:
continue
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
):
continue
raw_posts.append(post_data)
filtered_posts = get_filter_engine().apply_filterset(
raw_posts, filterset_name, use_cache=True
)
response_posts = []
for post_data in filtered_posts:
post_uuid = post_data.get("uuid")
source_display = get_display_name_for_source(
post_data.get("platform", ""),
post_data.get("source", ""),
platform_config,
)
response_posts.append(
{
"id": post_uuid,
"title": post_data.get("title", "Untitled"),
"author": post_data.get("author", "Unknown"),
"platform": post_data.get("platform", "unknown"),
"score": post_data.get("score", 0),
"timestamp": post_data.get("timestamp", 0),
"url": f"/post/{post_uuid}",
"comments_count": len(cached_comments.get(post_uuid, [])),
"content_preview": (post_data.get("content", "") or "")[:200] + "..." if post_data.get("content") else "",
"source": post_data.get("source", ""),
"source_display": source_display,
"tags": post_data.get("tags", []),
"external_url": post_data.get("url", ""),
"filter_score": post_data.get("_filter_score", 0.5),
"filter_categories": post_data.get("_filter_categories", []),
"filter_tags": post_data.get("_filter_tags", []),
}
)
response_posts.sort(key=lambda x: (x["filter_score"], x["timestamp"]), reverse=True)
total_posts = len(response_posts)
start_idx = (page - 1) * per_page
end_idx = start_idx + per_page
total_pages = (total_posts + per_page - 1) // per_page
return jsonify(
{
"posts": response_posts[start_idx:end_idx],
"pagination": {
"current_page": page,
"total_pages": total_pages,
"total_posts": total_posts,
"per_page": per_page,
"has_next": page < total_pages,
"has_prev": page > 1,
},
}
)
except Exception as e:
logger.error(f"Error loading posts: {e}")
return jsonify(
{
"posts": [],
"error": str(e),
"pagination": {
"current_page": 1,
"total_pages": 0,
"total_posts": 0,
"per_page": DEFAULT_PAGE_SIZE,
"has_next": False,
"has_prev": False,
},
}
)
@bp.get("/posts/<post_uuid>")
def post_detail(post_uuid):
"""Get one post with its comment tree."""
try:
platform_config = load_platform_config()
cached_posts, cached_comments = post_service.load()
post_data = cached_posts.get(post_uuid)
if not post_data:
return jsonify({"error": "Post not found"}), 404
post = dict(post_data)
post["source_display"] = get_display_name_for_source(
post.get("platform", ""), post.get("source", ""), platform_config
)
filterset_name = _current_filterset()
filtered_comments = get_filter_engine().filter_comments(
cached_comments.get(post_uuid, []), filterset_name
)
comments = post_service.build_comment_tree(filtered_comments)
return jsonify({"post": post, "comments": comments})
except Exception as e:
logger.error(f"Error loading post {post_uuid}: {e}")
return jsonify({"error": "Failed to load post"}), 500
@bp.get("/comments/<post_uuid>")
def comments(post_uuid):
"""Get comments for a post as a tree."""
try:
_, cached_comments = post_service.load()
filterset_name = _current_filterset()
filtered_comments = get_filter_engine().filter_comments(
cached_comments.get(post_uuid, []), filterset_name
)
return jsonify({"comments": post_service.build_comment_tree(filtered_comments)})
except Exception as e:
logger.error(f"Error loading comments for {post_uuid}: {e}")
return jsonify({"error": "Failed to load comments"}), 500
@bp.get("/platforms")
def platforms():
"""Get platform configuration and available communities."""
try:
platform_config = load_platform_config()
communities = []
for key, count in post_service.source_counts().items():
platform, source = key.split(":", 1)
platform_info = platform_config.get("platforms", {}).get(platform, {})
community_info = None
if platform_info.get("supports_communities"):
for community in platform_info.get("communities", []):
if community["id"] == source:
community_info = community
break
if community_info:
communities.append(
{
"platform": platform,
"id": source,
"name": community_info["name"],
"display_name": community_info["display_name"],
"icon": community_info.get("icon", platform_info.get("icon", "\U0001f4c4")),
"count": count,
"description": community_info.get("description", ""),
}
)
else:
display_name = get_display_name_for_source(platform, source, platform_config)
communities.append(
{
"platform": platform,
"id": source,
"name": source or platform,
"display_name": display_name,
"icon": platform_info.get("icon", "\U0001f4c4"),
"count": count,
"description": f"Posts from {display_name}",
}
)
communities.sort(key=lambda x: x["count"], reverse=True)
return jsonify(
{
"platforms": platform_config.get("platforms", {}),
"communities": communities,
"total_communities": len(communities),
}
)
except Exception as e:
logger.error(f"Error loading platform configuration: {e}")
return jsonify({"platforms": {}, "communities": [], "total_communities": 0, "error": str(e)})
@bp.get("/content-timestamp")
def content_timestamp():
"""Get the last content update timestamp for auto-refresh."""
try:
return jsonify({"timestamp": post_service.latest_content_mtime()})
except Exception as e:
logger.error(f"Error getting content timestamp: {e}")
return jsonify({"error": "Failed to get content timestamp"}), 500
@bp.post("/bookmark")
@login_required
def bookmark():
"""Toggle bookmark status for a post."""
try:
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
existing_bookmark = Bookmark.query.filter_by(
user_id=current_user.id, post_uuid=post_uuid
).first()
if existing_bookmark:
db.session.delete(existing_bookmark)
db.session.commit()
return jsonify({"bookmarked": False, "message": "Bookmark removed"})
cached_posts, _ = post_service.load()
post_data = cached_posts.get(post_uuid, {})
new_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(new_bookmark)
db.session.commit()
return jsonify({"bookmarked": True, "message": "Bookmark added"})
except Exception as e:
db.session.rollback()
logger.error(f"Error toggling bookmark: {e}")
return jsonify({"error": "Failed to toggle bookmark"}), 500
@bp.get("/bookmarks")
@login_required
def bookmarks():
"""Get the current user's bookmarks."""
try:
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", DEFAULT_PAGE_SIZE))
bookmarks_query = Bookmark.query.filter_by(user_id=current_user.id).order_by(
Bookmark.created_at.desc()
)
total_bookmarks = bookmarks_query.count()
bookmark_rows = bookmarks_query.offset((page - 1) * per_page).limit(per_page).all()
cached_posts, cached_comments = post_service.load()
bookmark_posts = []
for bookmark_row in bookmark_rows:
post_data = cached_posts.get(bookmark_row.post_uuid)
if post_data:
bookmark_posts.append(
{
"id": bookmark_row.post_uuid,
"title": post_data.get("title", bookmark_row.title or "Untitled"),
"author": post_data.get("author", "Unknown"),
"platform": post_data.get("platform", bookmark_row.platform or "unknown"),
"score": post_data.get("score", 0),
"timestamp": post_data.get("timestamp", 0),
"url": f"/post/{bookmark_row.post_uuid}",
"comments_count": len(cached_comments.get(bookmark_row.post_uuid, [])),
"content_preview": (post_data.get("content", "") or "")[:200] + "..." if post_data.get("content") else "",
"source": post_data.get("source", bookmark_row.source or ""),
"bookmarked_at": bookmark_row.created_at.isoformat(),
"external_url": post_data.get("url", ""),
}
)
else:
bookmark_posts.append(
{
"id": bookmark_row.post_uuid,
"title": bookmark_row.title or "Untitled",
"author": "Unknown",
"platform": bookmark_row.platform or "unknown",
"score": 0,
"timestamp": 0,
"url": f"/post/{bookmark_row.post_uuid}",
"comments_count": 0,
"content_preview": "Content no longer available",
"source": bookmark_row.source or "",
"bookmarked_at": bookmark_row.created_at.isoformat(),
"external_url": "",
"archived": True,
}
)
total_pages = (total_bookmarks + per_page - 1) // per_page
return jsonify(
{
"posts": bookmark_posts,
"pagination": {
"current_page": page,
"total_pages": total_pages,
"total_posts": total_bookmarks,
"per_page": per_page,
"has_next": page < total_pages,
"has_prev": page > 1,
},
}
)
except Exception as e:
logger.error(f"Error getting bookmarks: {e}")
return jsonify({"error": "Failed to get bookmarks"}), 500
@bp.get("/bookmark-status/<post_uuid>")
@login_required
def bookmark_status(post_uuid):
"""Check if a post is bookmarked by the current user."""
try:
bookmark_row = Bookmark.query.filter_by(
user_id=current_user.id, post_uuid=post_uuid
).first()
return jsonify({"bookmarked": bookmark_row is not None})
except Exception as e:
logger.error(f"Error checking bookmark status: {e}")
return jsonify({"error": "Failed to check bookmark status"}), 500
@bp.get("/filters")
def filters():
"""Get available filtersets."""
try:
filter_rows = []
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 (json.JSONDecodeError, TypeError):
pass
filter_engine = get_filter_engine()
for filterset_name in filter_engine.get_available_filtersets():
filterset_config = filter_engine.config.get_filterset(filterset_name)
if filterset_config:
filter_rows.append(
{
"id": filterset_name,
"name": FILTER_NAME_MAP.get(
filterset_name, filterset_name.replace("_", " ").title()
),
"description": filterset_config.get("description", ""),
"icon": FILTER_ICON_MAP.get(filterset_name, "\U0001f527"),
"active": filterset_name == current_filter,
}
)
return jsonify({"filters": filter_rows})
except Exception as e:
logger.error(f"Error getting filters: {e}")
return jsonify({"error": "Failed to get filters"}), 500
return bp

View File

@@ -1,159 +0,0 @@
"""
Comment Library
Atomic functions for comment processing and tree manipulation.
"""
import json
from pathlib import Path
from typing import List, Dict, Optional
class comment_lib:
"""Atomic comment processing functions"""
@staticmethod
def build_comment_tree(flat_comments: List[Dict]) -> List[Dict]:
"""
Convert flat array of comments to nested tree structure.
Returns list of root-level comments with nested children.
"""
if not flat_comments:
return []
# Create lookup dict
comment_map = {c['uuid']: {**c, 'children': []} for c in flat_comments}
# Build tree
roots = []
for comment in flat_comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_map:
comment_map[parent_uuid]['children'].append(comment_map[comment['uuid']])
else:
roots.append(comment_map[comment['uuid']])
return roots
@staticmethod
def flatten_comment_tree(tree: List[Dict]) -> List[Dict]:
"""
Convert nested tree structure to flat array.
Removes 'children' key from each comment.
"""
flat = []
def traverse(nodes):
for node in nodes:
children = node.pop('children', [])
flat.append(node)
if children:
traverse(children)
traverse(tree)
return flat
@staticmethod
def load_comments_for_post(post_uuid: str, data_dir: str) -> List[Dict]:
"""
Load all comment files linked to a post.
Scans comment directory for comments with matching post_uuid.
"""
comments_dir = Path(data_dir) / 'comments'
if not comments_dir.exists():
return []
comments = []
for comment_file in comments_dir.glob('*.json'):
with open(comment_file, 'r') as f:
comment = json.load(f)
if comment.get('post_uuid') == post_uuid:
comments.append(comment)
return comments
@staticmethod
def sort_comments(comments: List[Dict], by: str = 'score', order: str = 'desc') -> List[Dict]:
"""
Sort comments by specified field.
Args:
comments: List of comment dicts
by: Field to sort by ('score', 'timestamp', 'depth', 'author')
order: 'asc' or 'desc'
Returns:
Sorted list of comments
"""
reverse = (order == 'desc')
return sorted(comments, key=lambda c: c.get(by, 0), reverse=reverse)
@staticmethod
def get_comment_depth(comment: Dict, comment_map: Dict) -> int:
"""
Calculate actual depth of a comment by traversing up parent chain.
Useful for recalculating depth after filtering.
"""
depth = 0
current_uuid = comment.get('parent_comment_uuid')
while current_uuid and current_uuid in comment_map:
depth += 1
current_uuid = comment_map[current_uuid].get('parent_comment_uuid')
return depth
@staticmethod
def get_comment_stats(comments: List[Dict]) -> Dict:
"""
Get statistics about a comment list.
Returns:
Dict with total, max_depth, avg_score, etc.
"""
if not comments:
return {
'total': 0,
'max_depth': 0,
'avg_score': 0,
'total_score': 0
}
depths = [c.get('depth', 0) for c in comments]
scores = [c.get('score', 0) for c in comments]
return {
'total': len(comments),
'max_depth': max(depths) if depths else 0,
'avg_score': sum(scores) / len(scores) if scores else 0,
'total_score': sum(scores)
}
@staticmethod
def filter_by_depth(comments: List[Dict], max_depth: int) -> List[Dict]:
"""
Filter comments to only include those at or below max_depth.
"""
return [c for c in comments if c.get('depth', 0) <= max_depth]
@staticmethod
def get_top_level_comments(comments: List[Dict]) -> List[Dict]:
"""
Get only top-level comments (depth 0, no parent).
"""
return [c for c in comments if c.get('depth', 0) == 0 or not c.get('parent_comment_uuid')]
@staticmethod
def count_replies(comment_uuid: str, comments: List[Dict]) -> int:
"""
Count total number of replies (direct and nested) for a comment.
"""
count = 0
for comment in comments:
if comment.get('parent_comment_uuid') == comment_uuid:
count += 1
# Recursively count this comment's replies
count += comment_lib.count_replies(comment['uuid'], comments)
return count

43
config.py Normal file
View File

@@ -0,0 +1,43 @@
"""Application configuration.
Centralizes environment-driven Flask config and app-wide constants so they
can be imported by the app factory, services, and route modules without each
reaching into ``os.getenv`` independently.
"""
import os
from dotenv import load_dotenv
# Load .env once when the config module is imported.
load_dotenv()
class Config:
"""Flask configuration loaded from the environment."""
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max request body (avatar uploads)
ALLOW_ANONYMOUS_ACCESS = os.getenv("ALLOW_ANONYMOUS_ACCESS", "true").lower() == "true"
# Branding
APP_NAME = os.getenv("APP_NAME", "BalanceBoard")
LOGO_PATH = os.getenv("LOGO_PATH", "logo.png")
# Auth0 (optional; empty disables OAuth login)
AUTH0_DOMAIN = os.getenv("AUTH0_DOMAIN", "")
AUTH0_CLIENT_ID = os.getenv("AUTH0_CLIENT_ID", "")
AUTH0_CLIENT_SECRET = os.getenv("AUTH0_CLIENT_SECRET", "")
AUTH0_AUDIENCE = os.getenv("AUTH0_AUDIENCE", "")
# App-wide constants (not Flask config keys). Imported directly by modules.
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
UPLOAD_FOLDER = "static/avatars"
MAX_FILENAME_LENGTH = 100
DEFAULT_PORT = 5021
DEFAULT_PAGE_SIZE = 20
MIN_PASSWORD_LENGTH = 8
MAX_USERNAME_LENGTH = 80
MAX_EMAIL_LENGTH = 120
MAX_COMMUNITY_NAME_LENGTH = 100

View File

@@ -11,6 +11,8 @@ from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple from typing import List, Dict, Tuple
from data_collection_lib import data_methods from data_collection_lib import data_methods
from database import db
from models import Comment, Post
# ===== STORAGE FUNCTIONS ===== # ===== STORAGE FUNCTIONS =====
@@ -103,6 +105,52 @@ def create_moderation_stub(target_id: str, target_type: str, dirs: Dict) -> str:
return mod_uuid return mod_uuid
def upsert_post_record(post: Dict):
"""Best-effort DB upsert; JSON files remain an archive/export artifact."""
try:
db.session.merge(Post(
uuid=post["uuid"],
external_id=post.get("id"),
platform=post.get("platform", "") or "",
source=post.get("source", "") or "",
title=(post.get("title") or "")[:500],
author=post.get("author"),
url=post.get("url"),
content=post.get("content"),
score=int(post.get("score", 0) or 0),
timestamp=int(post.get("timestamp", 0) or 0),
tags=post.get("tags"),
moderation_uuid=post.get("moderation_uuid"),
))
db.session.commit()
except Exception as e:
db.session.rollback()
print(f"Warning: could not persist post {post.get('uuid')} to DB: {e}")
def upsert_comment_record(comment: Dict):
"""Best-effort DB upsert for collected comments."""
try:
db.session.merge(Comment(
uuid=comment["uuid"],
post_uuid=comment.get("post_uuid") or "",
platform=comment.get("platform"),
parent_comment_uuid=comment.get("parent_comment_uuid"),
comment_id=comment.get("comment_id") or comment.get("id"),
author=comment.get("author"),
content=comment.get("content"),
score=int(comment.get("score", 0) or 0),
timestamp=int(comment.get("timestamp", 0) or 0),
depth=int(comment.get("depth", 0) or 0),
moderation_uuid=comment.get("moderation_uuid"),
))
db.session.commit()
except Exception as e:
db.session.rollback()
print(f"Warning: could not persist comment {comment.get('uuid')} to DB: {e}")
# ===== POST FUNCTIONS ===== # ===== POST FUNCTIONS =====
def save_post(post: Dict, platform: str, index: Dict, dirs: Dict) -> str: def save_post(post: Dict, platform: str, index: Dict, dirs: Dict) -> str:
@@ -122,6 +170,8 @@ def save_post(post: Dict, platform: str, index: Dict, dirs: Dict) -> str:
with open(post_file, 'w') as f: with open(post_file, 'w') as f:
json.dump(post, f, indent=2) json.dump(post, f, indent=2)
upsert_post_record(post)
# Update index # Update index
index[post_id] = post_uuid index[post_id] = post_uuid
@@ -147,6 +197,8 @@ def save_comment(comment: Dict, post_uuid: str, platform: str, dirs: Dict) -> st
with open(comment_file, 'w') as f: with open(comment_file, 'w') as f:
json.dump(comment, f, indent=2) json.dump(comment, f, indent=2)
upsert_comment_record(comment)
return comment_uuid return comment_uuid

View File

@@ -2,6 +2,7 @@ import requests
import json import json
import datetime as dt import datetime as dt
import time import time
from platforms import discover_modules, get_platform_fetcher
from collections import defaultdict, deque from collections import defaultdict, deque
@@ -70,21 +71,12 @@ _rate_limiter = RateLimiter()
class data_methods(): class data_methods():
@staticmethod @staticmethod
def getData(platform, start_date, end_date, community, max_posts): def getData(platform, start_date, end_date, community, max_posts):
if platform == "reddit": discover_modules(["platforms.builtins"])
return data_methods.fetchers.getRedditData(start_date, end_date, community, max_posts) fetcher = get_platform_fetcher(platform)
elif platform == "pushshift": if not fetcher:
return data_methods.fetchers.getPushshiftData(start_date, end_date, community, max_posts)
elif platform == "hackernews":
return data_methods.fetchers.getHackerNewsData(start_date, end_date, community, max_posts)
elif platform == "lobsters":
return data_methods.fetchers.getLobstersData(start_date, end_date, community, max_posts)
elif platform == "stackexchange":
return data_methods.fetchers.getStackExchangeData(start_date, end_date, community, max_posts)
elif platform == "rss":
return data_methods.fetchers.getRSSData(start_date, end_date, community, max_posts)
else:
print("dataGrab.getData: platform not recognized") print("dataGrab.getData: platform not recognized")
return None return None
return fetcher.fetch_posts(start_date, end_date, community, max_posts)
# ===== ATOMIC UTILITY FUNCTIONS ===== # ===== ATOMIC UTILITY FUNCTIONS =====
class utils(): class utils():

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
postgres: postgres:
image: postgres:15 image: postgres:15
@@ -40,6 +38,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:-}
@@ -51,7 +50,6 @@ services:
- ./data:/app/data - ./data:/app/data
- ./static:/app/static - ./static:/app/static
- ./backups:/app/backups - ./backups:/app/backups
- ./active_html:/app/active_html
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

43
extensions.py Normal file
View File

@@ -0,0 +1,43 @@
"""Shared extension instances and accessors.
Extensions are created here (unbound to any app) and initialized against the
app inside ``create_app()``. This lets blueprints/services import the single
shared ``login_manager`` / ``oauth`` without importing ``app`` itself.
``filter_engine`` and ``polling_service`` are accessed lazily so importing this
module (or ``app``) has no side effects.
"""
import logging
from flask_login import LoginManager
from authlib.integrations.flask_client import OAuth
logger = logging.getLogger(__name__)
# Flask-Login: bound to the app in create_app().
login_manager = LoginManager()
login_manager.login_view = "login"
login_manager.login_message = "Please log in to access this page."
# Authlib OAuth: bound to the app in create_app().
oauth = OAuth()
def get_filter_engine():
"""Return the singleton FilterEngine instance (lazy).
``FilterEngine.get_instance()`` is itself a lazy singleton, so this is
cheap to call from routes/services. Kept as a function (not a module
global) so importing this module never triggers filter-engine init.
"""
from filter_pipeline import FilterEngine
return FilterEngine.get_instance()
def get_polling_service():
"""Return the shared polling service singleton (lazy import)."""
from polling_service import polling_service
return polling_service

49
filter_config.json Normal file
View File

@@ -0,0 +1,49 @@
{
"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", "plugins", "ranker"],
"batch_size": 50,
"enable_parallel": true,
"stage_modules": []
},
"plugins": {
"modules": [],
"enabled": ["keyword", "quality"],
"configs": {
"keyword": {
"enabled": true,
"blocklist": [],
"allowlist": [],
"check_title": true,
"check_content": true
},
"quality": {
"enabled": true,
"min_title_length": 10,
"max_title_length": 300,
"min_content_length": 0,
"max_caps_ratio": 0.5,
"max_exclamation_marks": 3
}
}
},
"output": {
"filtered_dir": "data/filtered",
"save_rejected": false
}
}

View File

@@ -1,345 +0,0 @@
"""
Filter Library
Bare bones utilities for filtering posts and comments based on rules.
"""
import json
from pathlib import Path
from typing import Dict, List, Any, Optional
from abc import ABC, abstractmethod
class filter_lib:
"""Atomic filter utility functions"""
@staticmethod
def load_filterset(path: str) -> Dict:
"""Load filterset JSON from file"""
with open(path, 'r') as f:
return json.load(f)
@staticmethod
def load_data_by_uuid(uuid: str, data_dir: str) -> Optional[Dict]:
"""Load single JSON file by UUID"""
file_path = Path(data_dir) / f"{uuid}.json"
if not file_path.exists():
return None
with open(file_path, 'r') as f:
return json.load(f)
@staticmethod
def merge_moderation(item: Dict, moderation_data: Dict) -> Dict:
"""Merge item with its moderation data by UUID"""
mod_uuid = item.get('moderation_uuid')
if mod_uuid and mod_uuid in moderation_data:
item['moderation'] = moderation_data[mod_uuid]
else:
item['moderation'] = {}
return item
@staticmethod
def get_nested_value(obj: Dict, path: str) -> Any:
"""Get value from nested dict using dot notation (e.g., 'moderation.flags.is_safe')"""
keys = path.split('.')
value = obj
for key in keys:
if isinstance(value, dict) and key in value:
value = value[key]
else:
return None
return value
@staticmethod
def evaluate_rule(value: Any, operator: str, target: Any) -> bool:
"""Evaluate single rule: value operator target"""
if value is None:
return False
if operator == 'equals':
return value == target
elif operator == 'not_equals':
return value != target
elif operator == 'in':
return value in target
elif operator == 'not_in':
return value not in target
elif operator == 'min':
return value >= target
elif operator == 'max':
return value <= target
elif operator == 'after':
return value > target
elif operator == 'before':
return value < target
elif operator == 'contains':
return target in value
elif operator == 'excludes':
if isinstance(value, list):
return not any(item in target for item in value)
return target not in value
elif operator == 'includes':
if isinstance(value, list):
return target in value
return False
elif operator == 'includes_any':
# Special case for topic matching
if isinstance(value, list) and isinstance(target, list):
for topic_item in value:
for rule in target:
if (topic_item.get('topic') == rule.get('topic') and
topic_item.get('confidence', 0) >= rule.get('confidence_min', 0)):
return True
return False
elif operator == 'min_length':
return len(str(value)) >= target
elif operator == 'max_length':
return len(str(value)) <= target
else:
return False
@staticmethod
def apply_rules(item: Dict, rules: Dict) -> bool:
"""
Apply multiple rules to item, return True if all pass (AND logic).
Rules format: {"field.path": {"operator": value}}
"""
if not rules:
return True # Empty rules = pass all
for field_path, rule_def in rules.items():
value = filter_lib.get_nested_value(item, field_path)
# Support multiple operators per field
for operator, target in rule_def.items():
if not filter_lib.evaluate_rule(value, operator, target):
return False
return True
class CommentFilterMode(ABC):
"""Abstract base class for comment filtering modes"""
@staticmethod
@abstractmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on rules and moderation data. Override in subclasses."""
pass
class TreePruningMode(CommentFilterMode):
"""
Tree Pruning Filter Mode (Default)
Fruit of the poisonous tree: if parent fails moderation, remove all children.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""
Filter comments using tree pruning.
Build tree structure, evaluate from root down, prune toxic branches.
"""
if not comments:
return []
# Merge moderation data into comments
for comment in comments:
filter_lib.merge_moderation(comment, moderation_data)
# Build tree structure
tree = TreePruningMode._build_tree(comments)
# Prune tree based on rules
pruned = TreePruningMode._prune_tree(tree, rules)
# Flatten back to list
return TreePruningMode._flatten_tree(pruned)
@staticmethod
def _build_tree(comments: List[Dict]) -> List[Dict]:
"""Build nested tree from flat comment list"""
# Create lookup dict
comment_map = {c['uuid']: {**c, 'children': []} for c in comments}
# Build tree
roots = []
for comment in comments:
parent_uuid = comment.get('parent_comment_uuid')
if parent_uuid and parent_uuid in comment_map:
comment_map[parent_uuid]['children'].append(comment_map[comment['uuid']])
else:
roots.append(comment_map[comment['uuid']])
return roots
@staticmethod
def _prune_tree(tree: List[Dict], rules: Dict) -> List[Dict]:
"""
Recursively prune tree.
If node fails rules, remove it and all children.
"""
pruned = []
for node in tree:
# Check if this node passes rules
if filter_lib.apply_rules(node, rules):
# Node passes, recursively check children
if node.get('children'):
node['children'] = TreePruningMode._prune_tree(node['children'], rules)
pruned.append(node)
# If node fails, it and all children are discarded (tree pruning)
return pruned
@staticmethod
def _flatten_tree(tree: List[Dict]) -> List[Dict]:
"""Flatten tree back to list"""
flat = []
def traverse(nodes):
for node in nodes:
children = node.pop('children', [])
flat.append(node)
if children:
traverse(children)
traverse(tree)
return flat
class IndividualFilterMode(CommentFilterMode):
"""
Individual Filter Mode
Each comment evaluated independently, no tree pruning.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments individually"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply rules
if filter_lib.apply_rules(comment, rules):
filtered.append(comment)
return filtered
class ScoreBasedFilterMode(CommentFilterMode):
"""
Score-Based Filter Mode
Filter comments based on score thresholds, keeping high-quality content.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on score and rules"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Additional score-based filtering
score = comment.get('score', 0)
min_score = rules.get('score', {}).get('min', -1000) # Default very low threshold
if score >= min_score:
filtered.append(comment)
return filtered
class TimeBoundFilterMode(CommentFilterMode):
"""
Time-Bound Filter Mode
Filter comments within specific time ranges.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments within time bounds"""
from datetime import datetime
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Time-based filtering
timestamp = comment.get('timestamp')
if timestamp:
try:
comment_time = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
time_rules = rules.get('timestamp', {})
after = time_rules.get('after')
before = time_rules.get('before')
if after:
after_time = datetime.fromisoformat(after.replace('Z', '+00:00'))
if comment_time <= after_time:
continue
if before:
before_time = datetime.fromisoformat(before.replace('Z', '+00:00'))
if comment_time >= before_time:
continue
filtered.append(comment)
except (ValueError, TypeError):
# Skip malformed timestamps
continue
else:
# No timestamp, include if no time rules
if 'timestamp' not in rules:
filtered.append(comment)
return filtered
class ContentLengthFilterMode(CommentFilterMode):
"""
Content Length Filter Mode
Filter comments based on content length criteria.
"""
@staticmethod
def filter(comments: List[Dict], rules: Dict, moderation_data: Dict) -> List[Dict]:
"""Filter comments based on content length"""
filtered = []
for comment in comments:
# Merge moderation
filter_lib.merge_moderation(comment, moderation_data)
# Apply basic rules first
if not filter_lib.apply_rules(comment, rules):
continue
# Content length filtering
content = comment.get('content', '')
content_length = len(content)
length_rules = rules.get('content_length', {})
min_length = length_rules.get('min', 0)
max_length = length_rules.get('max', float('inf'))
if min_length <= content_length <= max_length:
filtered.append(comment)
return filtered

View File

@@ -0,0 +1,17 @@
"""
Filter Pipeline Package
Content filtering, categorization, and ranking system for BalanceBoard.
"""
from .engine import FilterEngine
from .models import FilterResult, ProcessingStatus
from .registry import register_stage, register_plugin
__all__ = [
'FilterEngine',
'FilterResult',
'ProcessingStatus',
'register_stage',
'register_plugin',
]
__version__ = '1.0.0'

View 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
View 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
View 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()
}

453
filter_pipeline/engine.py Normal file
View File

@@ -0,0 +1,453 @@
"""
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
from .registry import discover_modules, get_registered_stages
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 from the registry (lazy loading)."""
if self._stages is not None:
return
# Import built-ins and any configured extension modules for decorator
# side effects. This keeps engine orchestration independent of concrete
# stage classes and gives plugins a zero-core-edit registration path.
discover_modules([
'filter_pipeline.stages.categorizer',
'filter_pipeline.stages.moderator',
'filter_pipeline.stages.filter',
'filter_pipeline.stages.ranker',
'filter_pipeline.stages.plugins',
'filter_pipeline.stages.comment_filter',
'filter_pipeline.plugins.keyword',
'filter_pipeline.plugins.quality',
*self.config.config.get('pipeline', {}).get('stage_modules', []),
*self.config.config.get('plugins', {}).get('modules', []),
])
self._stages = {
name: stage_cls(self.config, self.cache)
for name, stage_cls in get_registered_stages().items()
}
logger.info(
"Initialized %s registered pipeline stages: %s",
len(self._stages),
', '.join(sorted(self._stages.keys()))
)
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 (registry-driven). This must happen regardless of
# whether AI is enabled: offline filtersets (rules/plugins/ranker) still
# need their stages instantiated to run.
self._init_stages()
# Get pipeline stages for this filterset
stage_names = self._get_stages_for_filterset(filterset_name)
# If AI is disabled but the filterset's stages require AI, do NOT silently
# pass everything as no_filter. Pass the posts through (so the feed is not
# blanked) but mark every result as FAILED with an explicit error so the
# degradation is observable, not silent. Filtersets whose stages are all
# offline (filter/plugins/ranker/comment_filter) still run normally.
if not self.config.is_ai_enabled() and self._stages_need_ai(stage_names):
logger.warning(
f"AI disabled but filterset '{filterset_name}' requires AI stages "
f"({stage_names}) - passing posts through unfiltered with FAILED status"
)
return self._process_ai_disabled(filterset_name, posts)
# 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 _stages_need_ai(self, stage_names: List[str]) -> bool:
"""Return True if any named stage class declares ``requires_ai``."""
from .registry import get_stage_class
for name in stage_names:
stage_cls = get_stage_class(name)
if stage_cls is not None and getattr(stage_cls, 'requires_ai', False):
return True
return False
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 _process_ai_disabled(self, filterset_name: str, posts: List[Dict[str, Any]]) -> List[FilterResult]:
"""Pass posts through unfiltered when the requested filterset needs AI
but AI is disabled. Unlike no_filter, every result is marked FAILED with
an explicit error so the degradation is observable rather than silent.
"""
results = []
for post in posts:
result = FilterResult(
post_uuid=post.get('uuid', ''),
passed=True, # do not blank the feed
score=0.5, # neutral score
categories=[],
tags=[],
filterset_name=filterset_name,
processed_at=datetime.now(),
status=ProcessingStatus.FAILED,
error=f"AI disabled: filterset '{filterset_name}' requires AI; passed through unfiltered"
)
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")
def filter_comments(
self,
comments: List[Dict[str, Any]],
filterset_name: str = 'no_filter'
) -> List[Dict[str, Any]]:
"""Filter a post's flat comment list according to a filterset.
Comment filtering is tree-shaped (per post) and lives in the registered
``comment_filter`` stage rather than the per-post stage pipeline. The
caller (API endpoint) builds the tree from the returned flat list via
``PostService.build_comment_tree``.
Fails open: if the ``comment_filter`` stage is not registered, the
comments are returned unchanged so a missing stage never blanks them.
"""
if not comments:
return []
self._init_stages()
comment_stage = self._stages.get('comment_filter')
if not comment_stage:
logger.warning("comment_filter stage not registered; returning comments unfiltered")
return comments
return comment_stage.filter_comments(comments, filterset_name)

121
filter_pipeline/models.py Normal file
View 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__})

View 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']

View 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}>"

View File

@@ -0,0 +1,97 @@
"""
Keyword Filter Plugin
Simple keyword-based filtering.
"""
import logging
from typing import Dict, Any, Optional, List
from .base import BaseFilterPlugin
from ..registry import register_plugin
logger = logging.getLogger(__name__)
@register_plugin("keyword")
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()

View File

@@ -0,0 +1,130 @@
"""
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
from ..registry import register_plugin
logger = logging.getLogger(__name__)
@register_plugin("quality")
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))

View File

@@ -0,0 +1,62 @@
"""Registries for filter pipeline stages and plugins."""
import importlib
import logging
from typing import Any, Dict, Iterable, Optional, Type
logger = logging.getLogger(__name__)
_STAGE_REGISTRY: Dict[str, Type[Any]] = {}
_PLUGIN_REGISTRY: Dict[str, Type[Any]] = {}
_DISCOVERED_MODULES = set()
def register_stage(name: str):
"""Register a pipeline stage class under a config name."""
def decorator(stage_cls: Type[Any]):
if name in _STAGE_REGISTRY and _STAGE_REGISTRY[name] is not stage_cls:
logger.warning("Replacing registered filter stage '%s'", name)
_STAGE_REGISTRY[name] = stage_cls
return stage_cls
return decorator
def register_plugin(name: str):
"""Register a filter plugin class under a config name."""
def decorator(plugin_cls: Type[Any]):
if name in _PLUGIN_REGISTRY and _PLUGIN_REGISTRY[name] is not plugin_cls:
logger.warning("Replacing registered filter plugin '%s'", name)
_PLUGIN_REGISTRY[name] = plugin_cls
return plugin_cls
return decorator
def get_stage_class(name: str) -> Optional[Type[Any]]:
"""Return a registered stage class by name."""
return _STAGE_REGISTRY.get(name)
def get_plugin_class(name: str) -> Optional[Type[Any]]:
"""Return a registered plugin class by name."""
return _PLUGIN_REGISTRY.get(name)
def get_registered_stages() -> Dict[str, Type[Any]]:
"""Return a copy of registered stage classes."""
return dict(_STAGE_REGISTRY)
def get_registered_plugins() -> Dict[str, Type[Any]]:
"""Return a copy of registered plugin classes."""
return dict(_PLUGIN_REGISTRY)
def discover_modules(module_names: Iterable[str]):
"""Import modules for registration side effects once."""
for module_name in module_names:
if not module_name or module_name in _DISCOVERED_MODULES:
continue
importlib.import_module(module_name)
_DISCOVERED_MODULES.add(module_name)

80
filter_pipeline/rules.py Normal file
View File

@@ -0,0 +1,80 @@
"""Shared rule evaluation for posts and comments."""
from typing import Any, Dict
def get_nested_value(obj: Dict[str, Any], path: str) -> Any:
"""Get a nested dict value using dot notation."""
value = obj
for key in path.split("."):
if isinstance(value, dict) and key in value:
value = value[key]
else:
return None
return value
def evaluate_rule(value: Any, operator: str, target: Any) -> bool:
"""Evaluate one rule operator."""
if value is None:
return False
if operator == "equals":
return value == target
if operator == "not_equals":
return value != target
if operator == "in":
return value in target
if operator == "not_in":
return value not in target
if operator == "min":
return value >= target
if operator == "max":
return value <= target
if operator == "after":
return value > target
if operator == "before":
return value < target
if operator == "contains":
return target in value
if operator == "excludes":
if isinstance(value, list):
return not any(item in target for item in value)
return value not in target
if operator == "includes":
if isinstance(value, list):
return target in value
return False
if operator == "includes_any":
if isinstance(value, list) and isinstance(target, list):
for item in value:
if isinstance(item, dict):
for rule in target:
if (
isinstance(rule, dict)
and item.get("topic") == rule.get("topic")
and item.get("confidence", 0) >= rule.get("confidence_min", 0)
):
return True
elif item in target:
return True
return False
if operator == "min_length":
return len(str(value)) >= target
if operator == "max_length":
return len(str(value)) <= target
return False
def apply_rules(item: Dict[str, Any], rules: Dict[str, Dict[str, Any]]) -> bool:
"""Return True when all field rules pass."""
if not rules:
return True
for field_path, rule_def in rules.items():
value = get_nested_value(item, field_path)
for operator, target in rule_def.items():
if not evaluate_rule(value, operator, target):
return False
return True

View File

@@ -0,0 +1,22 @@
"""
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
from .plugins import PluginStage
from .comment_filter import CommentFilterStage
__all__ = [
'BaseStage',
'CategorizerStage',
'ModeratorStage',
'FilterStage',
'RankerStage',
'PluginStage',
'CommentFilterStage',
]

View File

@@ -0,0 +1,72 @@
"""
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
``requires_ai`` marks stages that need the AI client. The engine uses it
to decide whether a filterset can run with AI disabled (offline filtersets
that only use rule/plugin/ranker stages still run; AI stages short-circuit
to the AI-disabled path so the feed is not silently blanked).
"""
requires_ai: bool = False
def __init__(self, config: "FilterConfig", cache: Any):
"""
Initialize stage.
Args:
config: FilterConfig instance for this pipeline run
cache: FilterCache instance
"""
self.config = config
self.cache = cache
# Stages are enabled by default; a per-stage enabled flag can be set
# by subclasses reading their own config section. FilterConfig is not a
# dict, so do not call ``config.get(...)`` here.
self.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}>"

View File

@@ -0,0 +1,171 @@
"""
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 ..registry import register_stage
from ..models import FilterResult, AIAnalysisResult
from ..cache import FilterCache
from ..ai_client import OpenRouterClient
logger = logging.getLogger(__name__)
@register_stage("categorizer")
class CategorizerStage(BaseStage):
"""
Stage 1: Categorize content and extract tags.
Uses AI to detect topics/categories with content-hash based caching.
"""
requires_ai = True
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

View File

@@ -0,0 +1,124 @@
"""Comment filtering stage and tree modes."""
import logging
from datetime import datetime
from typing import Any, Dict, List
from .base_stage import BaseStage
from ..models import FilterResult
from ..registry import register_stage
from ..rules import apply_rules
logger = logging.getLogger(__name__)
@register_stage("comment_filter")
class CommentFilterStage(BaseStage):
"""Apply filterset comment rules using configured tree modes."""
def get_name(self) -> str:
return "CommentFilter"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""Post pipeline no-op; comments are filtered through filter_comments()."""
return result
def filter_comments(self, comments: List[Dict[str, Any]], filterset_name: str) -> List[Dict[str, Any]]:
if not comments:
return []
filterset = self.config.get_filterset(filterset_name) or {}
rules = filterset.get("comment_rules", {})
mode = filterset.get("comment_filter_mode", "individual")
if not rules:
return [dict(comment) for comment in comments]
if mode == "tree_pruning":
return self._filter_tree_pruning(comments, rules)
if mode == "score_based":
return self._filter_individual(comments, rules, extra_check=self._passes_score_rules)
if mode == "time_bound":
return self._filter_individual(comments, rules, extra_check=self._passes_time_rules)
if mode == "content_length":
return self._filter_individual(comments, rules, extra_check=self._passes_length_rules)
return self._filter_individual(comments, rules)
def _filter_tree_pruning(self, comments: List[Dict[str, Any]], rules: Dict[str, Any]) -> List[Dict[str, Any]]:
comment_map = {comment["uuid"]: {**comment, "children": []} for comment in comments if comment.get("uuid")}
roots = []
for comment in comments:
uuid = comment.get("uuid")
if not uuid or uuid not in comment_map:
continue
parent_uuid = comment.get("parent_comment_uuid")
if parent_uuid and parent_uuid in comment_map:
comment_map[parent_uuid]["children"].append(comment_map[uuid])
else:
roots.append(comment_map[uuid])
def prune(nodes):
pruned = []
for node in nodes:
if self._passes_comment_rules(node, rules):
node["children"] = prune(node.get("children", []))
pruned.append(node)
return pruned
return self._flatten_tree(prune(roots))
def _filter_individual(self, comments, rules, extra_check=None):
filtered = []
for comment in comments:
item = dict(comment)
if self._passes_comment_rules(item, rules) and (extra_check is None or extra_check(item, rules)):
filtered.append(item)
return filtered
def _passes_comment_rules(self, comment: Dict[str, Any], rules: Dict[str, Any]) -> bool:
return apply_rules(comment, rules)
def _passes_score_rules(self, comment: Dict[str, Any], rules: Dict[str, Any]) -> bool:
score_rules = rules.get("score", {})
min_score = score_rules.get("min", -1000)
return comment.get("score", 0) >= min_score
def _passes_time_rules(self, comment: Dict[str, Any], rules: Dict[str, Any]) -> bool:
time_rules = rules.get("timestamp", {})
timestamp = comment.get("timestamp")
if not timestamp:
return "timestamp" not in rules
try:
if isinstance(timestamp, (int, float)):
comment_time = datetime.fromtimestamp(timestamp)
else:
comment_time = datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
after = time_rules.get("after")
before = time_rules.get("before")
if after and comment_time <= datetime.fromisoformat(str(after).replace("Z", "+00:00")):
return False
if before and comment_time >= datetime.fromisoformat(str(before).replace("Z", "+00:00")):
return False
return True
except (ValueError, TypeError):
return False
def _passes_length_rules(self, comment: Dict[str, Any], rules: Dict[str, Any]) -> bool:
length_rules = rules.get("content_length", {})
content_length = len(comment.get("content", ""))
min_length = length_rules.get("min", 0)
max_length = length_rules.get("max", float("inf"))
return min_length <= content_length <= max_length
def _flatten_tree(self, tree: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
flat = []
def traverse(nodes):
for node in nodes:
children = node.pop("children", [])
flat.append(node)
traverse(children)
traverse(tree)
return flat

View File

@@ -0,0 +1,46 @@
"""
Filter Stage
Apply filterset rules to posts (no AI needed - fast rule evaluation).
"""
import logging
from typing import Dict, Any
from .base_stage import BaseStage
from ..models import FilterResult
from ..registry import register_stage
from ..rules import apply_rules
logger = logging.getLogger(__name__)
@register_stage("filter")
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 post rules to a post."""
filterset = self.config.get_filterset(result.filterset_name)
if not filterset:
logger.warning(f"Filterset '{result.filterset_name}' not found")
return result
item = dict(post)
if result.moderation_data:
item["moderation"] = result.moderation_data
if not apply_rules(item, filterset.get("post_rules", {})):
result.passed = False
logger.debug(f"Filter: Post {post.get('uuid', '')} rejected by filterset rules")
return result
logger.debug(f"Filter: Post {post.get('uuid', '')} passed filterset '{result.filterset_name}'")
return result

View File

@@ -0,0 +1,157 @@
"""
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 ..registry import register_stage
from ..models import FilterResult, AIAnalysisResult
from ..cache import FilterCache
from ..ai_client import OpenRouterClient
logger = logging.getLogger(__name__)
@register_stage("moderator")
class ModeratorStage(BaseStage):
"""
Stage 2: Content moderation and quality analysis.
Uses AI to analyze safety, quality, and sentiment with content-hash based caching.
"""
requires_ai = True
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

View File

@@ -0,0 +1,89 @@
"""Plugin consumer stage for registered BaseFilterPlugin implementations."""
import logging
from typing import Any, Dict, List
from .base_stage import BaseStage
from ..models import FilterResult
from ..registry import get_plugin_class, register_stage
logger = logging.getLogger(__name__)
@register_stage("plugins")
class PluginStage(BaseStage):
"""Run configured filter plugins against each post."""
def __init__(self, config, cache):
super().__init__(config, cache)
self._plugin_instances = None
def get_name(self) -> str:
return "Plugins"
def process(self, post: Dict[str, Any], result: FilterResult) -> FilterResult:
"""Apply configured plugins to a post/result pair."""
plugins = self._get_plugins(result.filterset_name)
if not plugins:
return result
context = {
"filterset_name": result.filterset_name,
"categories": result.categories,
"tags": result.tags,
"moderation": result.moderation_data,
"score_breakdown": result.score_breakdown,
}
plugin_scores = []
for plugin in plugins:
if not plugin.is_enabled():
continue
try:
if plugin.should_filter(post, context):
result.passed = False
result.tags.append(f"plugin:{plugin.get_name()}:rejected")
logger.debug("Plugin %s rejected post %s", plugin.get_name(), post.get("uuid", ""))
return result
score = plugin.score(post, context)
plugin_scores.append(score)
result.score_breakdown[f"plugin:{plugin.get_name()}"] = score
result.tags.append(f"plugin:{plugin.get_name()}")
except Exception as e:
logger.error("Plugin %s failed: %s", plugin.get_name(), e)
result.error = f"plugin:{plugin.get_name()}: {e}"
result.passed = False
return result
if plugin_scores:
result.score_breakdown["plugins"] = sum(plugin_scores) / len(plugin_scores)
# Blend plugin judgment with the current score without replacing
# ranking completely. Ranker can still run later and overwrite the
# final score from its own weighted factors.
result.score = (result.score + result.score_breakdown["plugins"]) / 2
return result
def _get_plugins(self, filterset_name: str) -> List[Any]:
if self._plugin_instances is None:
self._plugin_instances = self._build_plugin_instances()
filterset = self.config.get_filterset(filterset_name) or {}
plugin_names = filterset.get("plugins")
if plugin_names is None:
plugin_names = self.config.config.get("plugins", {}).get("enabled", [])
return [self._plugin_instances[name] for name in plugin_names if name in self._plugin_instances]
def _build_plugin_instances(self) -> Dict[str, Any]:
plugin_config = self.config.config.get("plugins", {})
instances = {}
for name, settings in plugin_config.get("configs", {}).items():
plugin_cls = get_plugin_class(name)
if not plugin_cls:
logger.warning("Configured plugin '%s' is not registered", name)
continue
instances[name] = plugin_cls(settings or {})
return instances

View File

@@ -0,0 +1,203 @@
"""
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 ..registry import register_stage
from ..models import FilterResult
logger = logging.getLogger(__name__)
@register_stage("ranker")
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

View File

@@ -68,5 +68,15 @@
"moderation.flags.is_blocked": {"equals": false} "moderation.flags.is_blocked": {"equals": false}
}, },
"comment_filter_mode": "tree_pruning" "comment_filter_mode": "tree_pruning"
},
"quality_filter": {
"description": "Offline quality + keyword filter (no AI required)",
"pipeline_stages": ["plugins", "ranker"],
"plugins": ["keyword", "quality"],
"post_rules": {},
"comment_rules": {
"content": {"min_length": 5}
},
"comment_filter_mode": "individual"
} }
} }

View File

@@ -1,297 +0,0 @@
#!/usr/bin/env python3
"""
Static HTML Generator
Generates static HTML from collected posts/comments with filtering and moderation.
"""
import argparse
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional
from filter_lib import filter_lib, TreePruningMode, IndividualFilterMode
from comment_lib import comment_lib
from html_generation_lib import html_generation_lib
class HTMLGenerator:
"""Generate static HTML from filtered posts and comments"""
def __init__(self, data_dir: str = "./data", filtersets_path: str = "./filtersets.json"):
self.data_dir = Path(data_dir)
self.filtersets_path = filtersets_path
# Load filtersets
self.filtersets = filter_lib.load_filterset(filtersets_path)
# Load moderation data into memory for faster access
self.moderation_data = self._load_all_moderation()
def _load_all_moderation(self) -> Dict:
"""Load all moderation files into a dict keyed by UUID"""
moderation_dir = self.data_dir / "moderation"
moderation_data = {}
if moderation_dir.exists():
for mod_file in moderation_dir.glob("*.json"):
mod_uuid = mod_file.stem
with open(mod_file, 'r') as f:
moderation_data[mod_uuid] = json.load(f)
return moderation_data
def _load_post_index(self) -> Dict:
"""Load post index"""
index_file = self.data_dir / "post_index.json"
if index_file.exists():
with open(index_file, 'r') as f:
return json.load(f)
return {}
def _load_post_by_uuid(self, post_uuid: str) -> Optional[Dict]:
"""Load a post by UUID"""
return filter_lib.load_data_by_uuid(post_uuid, str(self.data_dir / "posts"))
def generate(self, filterset_name: str, theme_name: str, output_dir: str):
"""
Main generation function.
Loads data, applies filters, renders HTML.
"""
print(f"\n{'='*60}")
print(f"Generating HTML")
print(f" Filterset: {filterset_name}")
print(f" Theme: {theme_name}")
print(f" Output: {output_dir}")
print(f"{'='*60}\n")
# Load filterset
if filterset_name not in self.filtersets:
print(f"Error: Filterset '{filterset_name}' not found")
return
filterset = self.filtersets[filterset_name]
post_rules = filterset.get('post_rules', {})
comment_rules = filterset.get('comment_rules', {})
comment_filter_mode = filterset.get('comment_filter_mode', 'tree_pruning')
# Choose comment filter mode
if comment_filter_mode == 'tree_pruning':
comment_filter = TreePruningMode
else:
comment_filter = IndividualFilterMode
# Load theme
try:
theme = html_generation_lib.load_theme(theme_name)
except Exception as e:
print(f"Error loading theme: {e}")
return
# Load post index
post_index = self._load_post_index()
print(f"Found {len(post_index)} posts in index")
# Filter and render posts
filtered_posts = []
generation_stats = {
'total_posts_checked': 0,
'posts_passed': 0,
'posts_failed': 0,
'total_comments_checked': 0,
'comments_passed': 0,
'comments_failed': 0
}
for post_id, post_uuid in post_index.items():
generation_stats['total_posts_checked'] += 1
# Load post
post = self._load_post_by_uuid(post_uuid)
if not post:
continue
# Merge moderation data
filter_lib.merge_moderation(post, self.moderation_data)
# Apply post rules
if not filter_lib.apply_rules(post, post_rules):
generation_stats['posts_failed'] += 1
continue
generation_stats['posts_passed'] += 1
# Load comments for this post
comments = comment_lib.load_comments_for_post(post_uuid, str(self.data_dir))
if comments:
generation_stats['total_comments_checked'] += len(comments)
# Filter comments using selected mode
filtered_comments = comment_filter.filter(comments, comment_rules, self.moderation_data)
generation_stats['comments_passed'] += len(filtered_comments)
generation_stats['comments_failed'] += len(comments) - len(filtered_comments)
# Build comment tree for rendering
comment_tree = comment_lib.build_comment_tree(filtered_comments)
post['comments'] = comment_tree
else:
post['comments'] = []
filtered_posts.append(post)
print(f"\nFiltering Results:")
print(f" Posts: {generation_stats['posts_passed']}/{generation_stats['total_posts_checked']} passed")
print(f" Comments: {generation_stats['comments_passed']}/{generation_stats['total_comments_checked']} passed")
# Create output directory
output_path = Path(output_dir) / filterset_name
output_path.mkdir(parents=True, exist_ok=True)
# Render index page
for post in filtered_posts:
post['post_url'] = f"{post['uuid']}.html"
index_html = html_generation_lib.render_index(filtered_posts, theme, filterset_name)
html_generation_lib.write_html_file(index_html, str(output_path / "index.html"))
# Render individual post pages
for post in filtered_posts:
post_html = html_generation_lib.render_post_page(post, theme, post.get('comments'))
post_filename = f"{post['uuid']}.html"
html_generation_lib.write_html_file(post_html, str(output_path / post_filename))
# Generate metadata file
metadata = {
"generated_at": datetime.now().isoformat(),
"filterset": filterset_name,
"filterset_config": filterset,
"theme": theme_name,
"output_directory": str(output_path),
"statistics": {
**generation_stats,
"posts_generated": len(filtered_posts)
},
"comment_filter_mode": comment_filter_mode
}
metadata_file = output_path / "metadata.json"
with open(metadata_file, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"\nGeneration Complete:")
print(f" Index page: {output_path / 'index.html'}")
print(f" Individual posts: {len(filtered_posts)} files")
print(f" Metadata: {metadata_file}")
print(f"{'='*60}\n")
def interactive_mode():
"""Interactive mode for human use"""
print("\n=== HTML Generator - Interactive Mode ===\n")
# List available filtersets
try:
filtersets = filter_lib.load_filterset("./filtersets.json")
print("Available filtersets:")
for i, (name, config) in enumerate(filtersets.items(), 1):
desc = config.get('description', 'No description')
print(f" {i}. {name} - {desc}")
filterset_choice = input("\nEnter filterset name or number: ").strip()
# Handle numeric choice
if filterset_choice.isdigit():
idx = int(filterset_choice) - 1
filterset_name = list(filtersets.keys())[idx]
else:
filterset_name = filterset_choice
# List available themes
themes_dir = Path("./themes")
if themes_dir.exists():
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
print("\nAvailable themes:")
for i, theme in enumerate(themes, 1):
print(f" {i}. {theme}")
theme_choice = input("\nEnter theme name or number: ").strip()
if theme_choice.isdigit():
idx = int(theme_choice) - 1
theme_name = themes[idx]
else:
theme_name = theme_choice
else:
theme_name = "vanilla-js"
# Output directory
output_dir = input("\nOutput directory [./active_html]: ").strip()
if not output_dir:
output_dir = "./active_html"
# Run generation
generator = HTMLGenerator()
generator.generate(filterset_name, theme_name, output_dir)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
def main():
"""Main entry point with CLI argument parsing"""
parser = argparse.ArgumentParser(
description="Generate static HTML from collected posts with filtering"
)
parser.add_argument(
'--filterset',
default='safe_content',
help='Filterset name to use (default: safe_content)'
)
parser.add_argument(
'--theme',
default='vanilla-js',
help='Theme name to use (default: vanilla-js)'
)
parser.add_argument(
'--output',
default='./active_html',
help='Output directory (default: ./active_html)'
)
parser.add_argument(
'--interactive',
action='store_true',
help='Run in interactive mode'
)
parser.add_argument(
'--data-dir',
default='./data',
help='Data directory (default: ./data)'
)
parser.add_argument(
'--filtersets-file',
default='./filtersets.json',
help='Filtersets file (default: ./filtersets.json)'
)
args = parser.parse_args()
if args.interactive:
interactive_mode()
else:
generator = HTMLGenerator(
data_dir=args.data_dir,
filtersets_path=args.filtersets_file
)
generator.generate(args.filterset, args.theme, args.output)
if __name__ == "__main__":
main()

View File

@@ -1,515 +0,0 @@
"""
HTML Generation Library
Atomic functions for loading themes and rendering HTML from templates.
"""
import json
from pathlib import Path
from typing import Dict, List, Optional
from datetime import datetime
import jinja2
class html_generation_lib:
"""Atomic HTML generation functions"""
@staticmethod
def load_theme(theme_name: str, themes_dir: str = './themes') -> Dict:
"""
Load theme configuration and templates.
Returns:
Dict with theme config, template paths, and metadata
"""
theme_dir = Path(themes_dir) / theme_name
theme_config_path = theme_dir / 'theme.json'
if not theme_config_path.exists():
raise FileNotFoundError(f"Theme config not found: {theme_config_path}")
with open(theme_config_path, 'r') as f:
config = json.load(f)
# Load template files
templates = {}
if 'templates' in config:
for template_name, template_path in config['templates'].items():
full_path = Path(template_path)
if full_path.exists():
with open(full_path, 'r') as f:
templates[template_name] = f.read()
config['loaded_templates'] = templates
config['theme_dir'] = str(theme_dir)
return config
@staticmethod
def render_template(template_string: str, data: Dict) -> str:
"""
Render template string with data using Jinja2 templating.
Handles nested expressions and complex logic better.
Args:
template_string: Template with {{variable}} placeholders
data: Dict of data to inject
Returns:
Rendered HTML string
"""
# Add helper functions to data context
context = {
**data,
'formatTime': html_generation_lib.format_time,
'formatTimeAgo': html_generation_lib.format_time_ago,
'formatDateTime': html_generation_lib.format_datetime,
'truncate': html_generation_lib.truncate,
'renderMarkdown': html_generation_lib.render_markdown,
'escapeHtml': html_generation_lib.escape_html
}
# Extract template content from <template> tag if present
if '<template' in template_string:
import re
match = re.search(r'<template[^>]*>(.*?)</template>', template_string, re.DOTALL)
if match:
template_string = match.group(1)
# Use Jinja2 for template rendering
try:
template = jinja2.Template(template_string)
return template.render(**context)
except Exception as e:
print(f"Template rendering error: {e}")
return f"<!-- Template error: {e} -->"
@staticmethod
def render_post(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str:
"""
Render single post to HTML using theme's post/card/detail template.
Args:
post: Post data dict
theme: Theme config with loaded templates
comments: Optional list of comments to render with post
Returns:
Rendered HTML string
"""
# Choose template (prefer 'detail' if comments, else 'card')
template_name = 'detail' if comments else 'card'
if template_name not in theme.get('loaded_templates', {}):
template_name = 'card' # Fallback
template = theme['loaded_templates'].get(template_name)
if not template:
return f"<!-- No template found for {template_name} -->"
# Render comments if provided
comments_section = ''
if comments:
comments_section = html_generation_lib.render_comment_tree(comments, theme)
# Create post data with comments_section
post_data = dict(post)
post_data['comments_section'] = comments_section
# Render post
return html_generation_lib.render_template(template, post_data)
@staticmethod
def render_post_page(post: Dict, theme: Dict, comments: Optional[List[Dict]] = None) -> str:
"""
Render single post as a complete HTML page with navigation.
Args:
post: Post data dict
theme: Theme config with loaded templates
comments: Optional list of comments to render with post
Returns:
Complete HTML page string
"""
# Render the post content
post_content = html_generation_lib.render_post(post, theme, comments)
# Build CSS links
css_links = ''
if theme.get('css_dependencies'):
for css_path in theme['css_dependencies']:
adjusted_path = css_path.replace('./themes/', '../../themes/')
css_links += f' <link rel="stylesheet" href="{adjusted_path}">\n'
# Build JS scripts
js_scripts = ''
if theme.get('js_dependencies'):
for js_path in theme['js_dependencies']:
adjusted_path = js_path.replace('./themes/', '../../themes/')
js_scripts += f' <script src="{adjusted_path}"></script>\n'
# Create full page
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{post.get('title', 'Post')} - BalanceBoard</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="/index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="/index.html" class="nav-item">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Post Info -->
<div class="sidebar-section">
<h3>Post Info</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5; margin-bottom: 12px;">
<strong>Author:</strong> {post.get('author', 'Unknown')}<br>
<strong>Platform:</strong> {post.get('platform', 'Unknown').title()}<br>
<strong>Score:</strong> {post.get('score', 0)} points
</p>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
{post_content}
</div>
</main>
</div>
{js_scripts}
</body>
</html>'''
return page_html
@staticmethod
def render_comment_tree(comments: List[Dict], theme: Dict, depth: int = 0) -> str:
"""
Recursively render nested comment tree (unlimited depth).
Args:
comments: List of comment dicts (may have 'children')
theme: Theme config with loaded templates
depth: Current nesting depth
Returns:
Rendered HTML string for all comments
"""
if not comments:
return ''
template = theme['loaded_templates'].get('comment')
if not template:
return '<!-- No comment template -->'
html_parts = []
for comment in comments:
# Recursively render children first
children = comment.get('children', [])
if children:
children_html = html_generation_lib.render_comment_tree(children, theme, depth + 1)
else:
children_html = ''
# Add depth and children_section to comment data
comment_data = {**comment, 'depth': depth, 'children_section': children_html}
# Render this comment
comment_html = html_generation_lib.render_template(template, comment_data)
html_parts.append(comment_html)
return '\n'.join(html_parts)
@staticmethod
def render_index(posts: List[Dict], theme: Dict, filterset_name: str = '') -> str:
"""
Render index/list page with all posts.
Args:
posts: List of post dicts
theme: Theme config with loaded templates
filterset_name: Name of filterset used (for display)
Returns:
Complete HTML page
"""
template = theme['loaded_templates'].get('list') or theme['loaded_templates'].get('card')
if not template:
return '<!-- No list template -->'
# Render each post
post_items = []
for post in posts:
# Update post URL to use Flask route
post_data = dict(post)
post_data['post_url'] = f"/post/{post['uuid']}"
post_html = html_generation_lib.render_template(template, post_data)
post_items.append(post_html)
# Create full page
css_links = ''
if theme.get('css_dependencies'):
for css_path in theme['css_dependencies']:
# Adjust relative paths to work from subdirectories (e.g., active_html/no_filter/)
# Convert ./themes/... to ../../themes/...
adjusted_path = css_path.replace('./themes/', '../../themes/')
css_links += f' <link rel="stylesheet" href="{adjusted_path}">\n'
js_scripts = ''
if theme.get('js_dependencies'):
for js_path in theme['js_dependencies']:
# Adjust relative paths to work from subdirectories
adjusted_path = js_path.replace('./themes/', '../../themes/')
js_scripts += f' <script src="{adjusted_path}"></script>\n'
page_html = f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BalanceBoard - Content Feed</title>
{css_links}
</head>
<body>
<!-- BalanceBoard Navigation -->
<nav class="balanceboard-nav">
<div class="nav-container">
<a href="index.html" class="nav-brand">
<img src="../../logo.png" alt="BalanceBoard Logo" class="nav-logo">
<div>
<div class="nav-brand-text">
<span class="brand-balance">balance</span><span class="brand-board">Board</span>
</div>
<div class="nav-subtitle">Filtered Content Feed</div>
</div>
</a>
</div>
</nav>
<div class="app-layout">
<!-- Sidebar -->
<aside class="sidebar">
<!-- User Card -->
<div class="sidebar-section user-card">
<div class="login-prompt">
<div class="user-avatar">?</div>
<p>Join BalanceBoard to customize your feed</p>
<a href="/login" class="btn-login">Log In</a>
<a href="/signup" class="btn-signup">Sign Up</a>
</div>
</div>
<!-- Navigation -->
<div class="sidebar-section">
<h3>Navigation</h3>
<ul class="nav-menu">
<li><a href="index.html" class="nav-item active">
<span class="nav-icon">🏠</span>
<span>Home</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">🔥</span>
<span>Popular</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">⭐</span>
<span>Saved</span>
</a></li>
<li><a href="#" class="nav-item">
<span class="nav-icon">📊</span>
<span>Analytics</span>
</a></li>
</ul>
</div>
<!-- Filters -->
<div class="sidebar-section">
<h3>Filter by Platform</h3>
<div class="filter-tags">
<a href="#" class="filter-tag active">All</a>
<a href="#" class="filter-tag">Reddit</a>
<a href="#" class="filter-tag">HackerNews</a>
<a href="#" class="filter-tag">Lobsters</a>
</div>
</div>
<!-- About -->
<div class="sidebar-section">
<h3>About</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); line-height: 1.5;">
BalanceBoard filters and curates content from multiple platforms to help you stay informed.
</p>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div class="container">
<header>
<h1>{filterset_name.replace('_', ' ').title() if filterset_name else 'All Posts'}</h1>
<p class="post-count">{len(posts)} posts</p>
</header>
<div id="posts-container">
{''.join(post_items)}
</div>
</div>
</main>
</div>
</body>
</html>'''
return page_html
@staticmethod
def write_html_file(html: str, output_path: str) -> None:
"""
Write HTML string to file.
Args:
html: HTML content
output_path: File path to write to
"""
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html)
# Helper functions for templates
@staticmethod
def format_time(timestamp: int) -> str:
"""Format timestamp as time"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime('%H:%M')
@staticmethod
def format_time_ago(timestamp: int) -> str:
"""Format timestamp as relative time (e.g., '2 hours ago')"""
now = datetime.now()
dt = datetime.fromtimestamp(timestamp)
diff = now - dt
seconds = diff.total_seconds()
if seconds < 60:
return 'just now'
elif seconds < 3600:
minutes = int(seconds / 60)
return f'{minutes} minute{"s" if minutes != 1 else ""} ago'
elif seconds < 86400:
hours = int(seconds / 3600)
return f'{hours} hour{"s" if hours != 1 else ""} ago'
elif seconds < 604800:
days = int(seconds / 86400)
return f'{days} day{"s" if days != 1 else ""} ago'
else:
weeks = int(seconds / 604800)
return f'{weeks} week{"s" if weeks != 1 else ""} ago'
@staticmethod
def format_datetime(timestamp: int) -> str:
"""Format timestamp as full datetime"""
dt = datetime.fromtimestamp(timestamp)
return dt.strftime('%B %d, %Y at %H:%M')
@staticmethod
def truncate(text: str, max_length: int) -> str:
"""Truncate text to max length"""
if len(text) <= max_length:
return text
return text[:max_length].strip() + '...'
@staticmethod
def render_markdown(text: str) -> str:
"""Basic markdown rendering"""
if not text:
return ''
# Basic markdown conversions
html = text
html = html.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
html = html.replace('\n\n', '</p><p>')
html = html.replace('\n', '<br>')
# Bold and italic
import re
html = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', html)
html = re.sub(r'\*(.*?)\*', r'<em>\1</em>', html)
# Images (must be processed before links since they use similar syntax)
html = re.sub(r'!\[(.*?)\]\((.*?)\)', r'<img src="\2" alt="\1" style="max-width: 100%; height: auto; display: block; margin: 0.5em 0;" />', html)
# Links
html = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2" target="_blank">\1</a>', html)
return f'<p>{html}</p>'
@staticmethod
def escape_html(text: str) -> str:
"""Escape HTML entities"""
return (text
.replace('&', '&amp;')
.replace('<', '&lt;')
.replace('>', '&gt;')
.replace('"', '&quot;')
.replace("'", '&#x27;'))

41
migrate_bookmarks.py Normal file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Migration script to create the bookmarks table.
"""
import os
import sys
from database import init_db, 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 (init_db binds the shared `db` extension; it
# returns None, so use the module-level `db` directly).
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()

168
migrate_content_to_db.py Normal file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env python
"""Backfill on-disk content JSON into the Postgres ``posts``/``comments`` tables.
Phase 3 prep (Agent D): reads ``data/posts/*.json`` and ``data/comments/*.json``
and upserts them into the ``Post`` / ``Comment`` models. Live reads/writes still
go through ``PostService`` (disk JSON) — this script only populates the DB so a
later cutover has data to read. It is idempotent: existing rows are skipped by
``uuid`` (run it again after collecting new content to backfill only the new
files).
Usage::
python migrate_content_to_db.py [--data-dir data] [--batch-size 500] [--dry-run]
Requires the full Flask/Postgres stack (``DATABASE_URL`` or the POSTGRES_*
env vars) — run inside the docker compose environment, not locally.
"""
import argparse
import json
import logging
import sys
from pathlib import Path
from app import create_app
from database import db
from models import Comment, Post
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("migrate_content_to_db")
def _load_json(path: Path):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, OSError) as e:
logger.warning("Skipping unreadable file %s: %s", path, e)
return None
def _existing_uuids(model, uuids):
"""Return the subset of ``uuids`` already present in the table."""
if not uuids:
return set()
found = set()
# Chunk to avoid huge IN clauses.
for i in range(0, len(uuids), 500):
chunk = uuids[i:i + 500]
rows = db.session.query(model.uuid).filter(model.uuid.in_(chunk)).all()
found.update(r[0] for r in rows)
return found
def backfill_posts(posts_dir: Path, batch_size: int, dry_run: bool) -> int:
files = sorted(posts_dir.glob("*.json")) if posts_dir.exists() else []
if not files:
logger.info("No post files found in %s", posts_dir)
return 0
records = []
for pf in files:
data = _load_json(pf)
if not data or not data.get("uuid"):
continue
records.append(data)
seen = _existing_uuids(Post, [r["uuid"] for r in records])
inserted = 0
batch = []
for r in records:
if r["uuid"] in seen:
continue
batch.append(Post(
uuid=r["uuid"],
external_id=r.get("id"),
platform=r.get("platform", "") or "",
source=r.get("source", "") or "",
title=(r.get("title") or "")[:500],
author=r.get("author"),
url=r.get("url"),
content=r.get("content"),
score=int(r.get("score", 0) or 0),
timestamp=int(r.get("timestamp", 0) or 0),
tags=r.get("tags"),
moderation_uuid=r.get("moderation_uuid"),
))
inserted += 1
if len(batch) >= batch_size:
_flush(batch, dry_run)
batch = []
_flush(batch, dry_run)
logger.info("Posts: backfilled %d new (%d already present)", inserted, len(seen))
return inserted
def backfill_comments(comments_dir: Path, batch_size: int, dry_run: bool) -> int:
files = sorted(comments_dir.glob("*.json")) if comments_dir.exists() else []
if not files:
logger.info("No comment files found in %s", comments_dir)
return 0
records = []
for cf in files:
data = _load_json(cf)
if not data or not data.get("uuid"):
continue
records.append(data)
seen = _existing_uuids(Comment, [r["uuid"] for r in records])
inserted = 0
batch = []
for r in records:
if r["uuid"] in seen:
continue
batch.append(Comment(
uuid=r["uuid"],
post_uuid=r.get("post_uuid") or "",
platform=r.get("platform"),
parent_comment_uuid=r.get("parent_comment_uuid"),
comment_id=r.get("comment_id"),
author=r.get("author"),
content=r.get("content"),
score=int(r.get("score", 0) or 0),
timestamp=int(r.get("timestamp", 0) or 0),
depth=int(r.get("depth", 0) or 0),
moderation_uuid=r.get("moderation_uuid"),
))
inserted += 1
if len(batch) >= batch_size:
_flush(batch, dry_run)
batch = []
_flush(batch, dry_run)
logger.info("Comments: backfilled %d new (%d already present)", inserted, len(seen))
return inserted
def _flush(batch, dry_run):
if not batch:
return
if dry_run:
logger.info("[dry-run] would insert %d rows", len(batch))
return
db.session.bulk_save_objects(batch)
db.session.commit()
def main(argv=None):
parser = argparse.ArgumentParser(description="Backfill content JSON into Postgres.")
parser.add_argument("--data-dir", default="data", help="Root data directory.")
parser.add_argument("--batch-size", type=int, default=500, help="Insert batch size.")
parser.add_argument("--dry-run", action="store_true", help="Log counts without writing.")
args = parser.parse_args(argv)
app = create_app()
with app.app_context():
posts_dir = Path(args.data_dir) / "posts"
comments_dir = Path(args.data_dir) / "comments"
p = backfill_posts(posts_dir, args.batch_size, args.dry_run)
c = backfill_comments(comments_dir, args.batch_size, args.dry_run)
logger.info("Done. posts=%d comments=%d", p, c)
return 0
if __name__ == "__main__":
sys.exit(main())

View 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)

View File

@@ -217,3 +217,90 @@ 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}>'
class Post(db.Model):
"""A collected post/item, mirroring the on-disk ``data/posts/*.json`` schema.
Phase 3 prep: the model and ``migrate_content_to_db.py`` backfill exist, but
live reads/writes still go through ``PostService`` (disk JSON). The cutover
is gated on Phase 2 filter behavior being stable — see ``progress.md`` and
``parallel.md`` (Agent D). Do not point the API at this model yet.
"""
__tablename__ = 'posts'
uuid = db.Column(db.String(64), primary_key=True)
external_id = db.Column(db.String(255), nullable=True, index=True)
platform = db.Column(db.String(50), nullable=False, index=True)
source = db.Column(db.String(500), nullable=False, default='', index=True)
title = db.Column(db.String(500), nullable=False, default='')
author = db.Column(db.String(255), nullable=True)
url = db.Column(db.Text, nullable=True)
content = db.Column(db.Text, nullable=True)
score = db.Column(db.Integer, nullable=False, default=0)
# Unix epoch seconds; BigInteger so far-future/large values fit.
timestamp = db.Column(db.BigInteger, nullable=False, default=0, index=True)
tags = db.Column(db.JSON, nullable=True)
moderation_uuid = db.Column(db.String(64), nullable=True, index=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
comments = db.relationship('Comment', backref='post', lazy='dynamic')
def __repr__(self):
return f'<Post {self.uuid} [{self.platform}:{self.source}]>'
class Comment(db.Model):
"""A comment on a post, mirroring ``data/comments/*.json``.
``parent_comment_uuid`` is a self-reference implementing the comment tree;
null marks a top-level comment. ``comment_id`` is the platform's own id.
"""
__tablename__ = 'comments'
uuid = db.Column(db.String(64), primary_key=True)
post_uuid = db.Column(db.String(64), db.ForeignKey('posts.uuid'), nullable=False, index=True)
platform = db.Column(db.String(50), nullable=True, index=True)
parent_comment_uuid = db.Column(db.String(64), nullable=True, index=True)
comment_id = db.Column(db.String(100), nullable=True)
author = db.Column(db.String(255), nullable=True)
content = db.Column(db.Text, nullable=True)
score = db.Column(db.Integer, nullable=False, default=0)
timestamp = db.Column(db.BigInteger, nullable=False, default=0, index=True)
depth = db.Column(db.Integer, nullable=False, default=0)
moderation_uuid = db.Column(db.String(64), nullable=True, index=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def __repr__(self):
return f'<Comment {self.uuid} on {self.post_uuid}>'

57
parallel.md Normal file
View File

@@ -0,0 +1,57 @@
# Parallel Work Handoff
Current status: Phase 1 is mostly complete. `app.py` is now a `create_app()`
factory, `/api/v1` lives in `blueprints/api.py`, and legacy Jinja route groups
are split under `routes/`. Runtime verification is still pending because the
local environment does not have the full Flask/Postgres stack available.
## Good Parallel Workstreams
### Agent A: Phase 1 Verification + Cleanup
- Owns: `app.py`, `run_app.py`, `start_server.py`, `Dockerfile`, README/DEPLOYMENT.
- Verify `flask --app app:create_app routes` and startup in a deps-installed environment.
- Check Auth0 registration under repeated `create_app()` calls.
- Confirm `url_for(...)` endpoints still exist for templates.
- Remove stale compatibility notes from `progress.md` once verified.
- Avoid changing filter pipeline internals.
### Agent B: Phase 2 Filter Registry
- Owns: `filter_pipeline/`, `filter_config.json`, `filtersets.json`.
- Add stage/plugin registry and auto-discovery.
- Replace hardcoded stage dicts in `filter_pipeline/engine.py`.
- Bridge `BaseFilterPlugin` to live `FilterResult` handling.
- Avoid editing route modules except for minimal API integration points.
### Agent C: Comment Filtering Consolidation
- Owns: `comment_lib.py`, `filter_lib.py`, comment-related pipeline stages.
- Port comment tree modes into `filter_pipeline/stages/comment_filter.py`.
- Wire filtered comments into `/api/v1/posts/<uuid>` and `/api/v1/comments/<uuid>`.
- Add small sample-data tests if a test harness is added.
- Coordinate with Agent B on registry names and stage contracts.
### Agent D: Phase 3 Data Model Prep
- Owns: `models.py`, new migration scripts, DB query services.
- Design `Post` and `Comment` SQLAlchemy models matching current JSON schema.
- Draft `migrate_content_to_db.py` backfill from `data/posts` and `data/comments`.
- Do not switch live reads/writes until Phase 2 route/filter behavior is stable.
### Agent E: Test Harness
- Owns: `tests/`, pytest config, lightweight fixtures.
- Add app-factory tests that assert route registration and endpoint names.
- Add `/api/v1` contract tests with monkeypatched `post_service` and `get_filter_engine`.
- Keep tests independent of a live Postgres where possible.
## Serialization Points
- Do Phase 1 runtime verification before deleting any more legacy UI behavior.
- Phase 2 registry work and comment filtering can happen together, but merge the registry contract first.
- Phase 3 database cutover should wait until `/api/v1/posts` and comment filtering behavior is stable.
- Phase 4 SPA work can scaffold independently, but feature parity work depends on stable `/api/v1` contracts.
## Shared Cautions
- Preserve endpoint names used by templates until the SPA replaces them.
- Do not delete Jinja templates in this pass.
- Do not remove JSON file reads until Postgres backfill and DB query paths are verified.
- Run at least `python -m py_compile` on touched Python files.
- Update `progress.md` after each substantial change.

View File

@@ -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"
} }
] ]
} }

19
platforms/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
"""Platform fetcher extension points."""
from .base import PlatformFetcher
from .registry import (
discover_modules,
get_platform_class,
get_platform_fetcher,
get_registered_platforms,
register_platform,
)
__all__ = [
"PlatformFetcher",
"discover_modules",
"get_platform_class",
"get_platform_fetcher",
"get_registered_platforms",
"register_platform",
]

18
platforms/base.py Normal file
View File

@@ -0,0 +1,18 @@
"""Base protocol for platform fetchers."""
from typing import Dict, List, Protocol
class PlatformFetcher(Protocol):
"""Fetch posts for one configured platform/community."""
name: str
def fetch_posts(
self,
start_date: str,
end_date: str,
community: str,
max_posts: int,
) -> List[Dict]:
"""Return posts normalized to the existing collection schema."""

50
platforms/builtins.py Normal file
View File

@@ -0,0 +1,50 @@
"""Built-in platform fetchers backed by the legacy fetch functions."""
from .registry import register_platform
class _LegacyMethodFetcher:
method_name = ""
def fetch_posts(self, start_date, end_date, community, max_posts):
# Import lazily so data_collection_lib can import this module while defining data_methods.
from data_collection_lib import data_methods
method = getattr(data_methods.fetchers, self.method_name)
return method(start_date, end_date, community, max_posts)
@register_platform("reddit")
class RedditFetcher(_LegacyMethodFetcher):
name = "reddit"
method_name = "getRedditData"
@register_platform("pushshift")
class PushshiftFetcher(_LegacyMethodFetcher):
name = "pushshift"
method_name = "getPushshiftData"
@register_platform("hackernews")
class HackerNewsFetcher(_LegacyMethodFetcher):
name = "hackernews"
method_name = "getHackerNewsData"
@register_platform("lobsters")
class LobstersFetcher(_LegacyMethodFetcher):
name = "lobsters"
method_name = "getLobstersData"
@register_platform("stackexchange")
class StackExchangeFetcher(_LegacyMethodFetcher):
name = "stackexchange"
method_name = "getStackExchangeData"
@register_platform("rss")
class RSSFetcher(_LegacyMethodFetcher):
name = "rss"
method_name = "getRSSData"

40
platforms/registry.py Normal file
View File

@@ -0,0 +1,40 @@
"""Platform fetcher registry for data collection."""
from importlib import import_module
from typing import Dict, Iterable, Optional, Type
from .base import PlatformFetcher
_PLATFORM_FETCHERS: Dict[str, Type[PlatformFetcher]] = {}
def register_platform(name: str):
"""Register a platform fetcher class by config/platform name."""
normalized = name.strip().lower()
if not normalized:
raise ValueError("Platform name must not be empty")
def decorator(cls: Type[PlatformFetcher]) -> Type[PlatformFetcher]:
_PLATFORM_FETCHERS[normalized] = cls
return cls
return decorator
def get_platform_class(name: str) -> Optional[Type[PlatformFetcher]]:
return _PLATFORM_FETCHERS.get((name or "").strip().lower())
def get_platform_fetcher(name: str) -> Optional[PlatformFetcher]:
cls = get_platform_class(name)
return cls() if cls else None
def get_registered_platforms() -> Dict[str, Type[PlatformFetcher]]:
return dict(_PLATFORM_FETCHERS)
def discover_modules(module_names: Iterable[str]) -> None:
for module_name in module_names:
if module_name:
import_module(module_name)

157
progress.md Normal file
View File

@@ -0,0 +1,157 @@
# BalanceBoard Refactor — Progress
Tracking work toward `REFACTOR_GOAL.md` (with the pluginability thread from
`.claude/plans/nested-stirring-dusk.md`). Updated continuously as work proceeds.
## Status legend
- [x] done [ ] todo [~] in progress [-] deferred / blocked
## Phase 0 — Stop the bleeding ✅ COMMITTED
Branch: `refactor/phase-0-bugfixes` · Commit: `cdba720`
- [x] Remove import-time side effects from `app.py` (polling/filter_engine
deferred to a one-shot `before_request`)
- [x] Fix `migrate_bookmarks.py` None bug
- [x] Password min-length consistent (8) in reset route
- [x] `post_detail.html`: `timeago` filter + `data-timestamp`; escape-then-Markup
`nl2br`; drop `| safe` from comment/post content (XSS)
- [x] AI-disabled filtersets now `status=FAILED` + explicit error (not silent)
- [x] Verified no source-file mojibake (ingest encoding deferred to Phase 3)
## Phase 1 — App factory + blueprints + `/api/v1` [~]
- [x] `config.py` (Config class + constants)
- [x] `extensions.py` (login_manager, oauth, lazy get_filter_engine/get_polling_service)
- [x] `security.py` (is_safe_filterset/is_safe_path/is_allowed_file)
- [x] `services/__init__.py`
- [x] `services/post_service.py` (cache, stats, comment tree, platform config; `app.py` wrappers now delegate here)
- [x] `services/settings_service.py` (settings parse/validate/defaults; `app.py` wrapper delegates here)
- [x] `blueprints/api.py` (Flask Blueprint mounted at `/api/v1`; includes posts, post detail, comments, platforms, bookmarks, filters)
- [x] `routes/` modules (auth, pages, settings, admin, assets extracted with endpoint names preserved)
- [x] `app.py` to `create_app()` factory (no module-level `app`; `__main__` creates a local app)
- [x] Update entrypoints (`run_app.py`, `start_server.py`, Dockerfile) to use `create_app()` / `app:create_app`
- [x] Update template/theme fetch strings `/api/*` to `/api/v1/*` (8 fetches found)
- [x] py_compile verification across the whole project (app, entrypoints, blueprints,
routes, services, config/extensions/security, models, and filter_pipeline) — passes
- [x] Static endpoint check: all 27 `url_for(...)` endpoint names used by templates
resolve to a defined route function (no missing endpoints after the route split)
- [-] Runtime verification (`flask --app app:create_app routes`, Auth0 under repeated
`create_app()`, startup) — needs `docker compose up`; static review shows the
`authlib` `oauth.register` path is idempotent (dict-based), so repeated factory
calls should not collide, but this is unconfirmed without a deps-installed env
Latest continuation 4: extracted settings/profile/avatar routes to `routes/settings.py`
and admin/polling routes to `routes/admin.py`; removed duplicate legacy `/api/*`
routes from `app.py`; converted `app.py` to `create_app()` with no module-level
Flask app; updated `run_app.py`, `start_server.py`, Dockerfile, and README init
command to use the factory.
Latest continuation 3: extracted legacy Jinja page routes to `routes/pages.py`,
asset/static routes to `routes/assets.py`, and auth/signup/password-reset/Auth0
routes to `routes/auth.py`; endpoint function names are preserved for existing
`url_for` calls. `app.py` now mainly retains legacy `/api/*`, settings, and admin routes.
Latest continuation 2: added `blueprints/api.py` and mounted it at `/api/v1`;
updated dashboard/bookmarks/theme fetch calls to `/api/v1/*` while leaving legacy
`/api/*` app routes in place for compatibility during the split.
Latest continuation: removed duplicate post/comment cache, platform-config helpers,
security helpers, and comment-tree builder from `app.py`; compatibility wrappers now
call `PostService`, `SettingsService`, and `security.py`. `services/__init__.py`
import is now unblocked by the new `services/settings_service.py`.
Design decision: API routes use a real Blueprint at `/api/v1` (templates call
them via fetch strings, not `url_for`). Jinja page/auth/settings/admin routes
use modular `register_*_routes(app)` that preserve original endpoint names, so
~60 template `url_for` calls need no changes (can't runtime-verify, minimizing
risk). Promoting page routes to full Blueprints is deferred until Phase 6 adds
a test harness to catch endpoint regressions.
## Phase 2 — One pluggable filter system [x]
- [x] `filter_pipeline/registry.py` (`@register_stage`/`@register_plugin` + module discovery)
- [x] Replace hardcoded `engine.py` stage dict with registry
- [x] Port `filter_lib` operators + `comment_lib` tree modes into pipeline (`filter_pipeline/rules.py`, `stages/comment_filter.py`)
- [x] New `stages/comment_filter.py` wired into live `/api/v1/posts/<uuid>` and `/api/v1/comments/<uuid>`
- [x] Bridge `BaseFilterPlugin``FilterResult` (`stages/plugins.py` consumer stage)
- [x] `plugins`/`stages` config wiring in filter_config.json (`stage_modules`/`plugins.modules` discovery hooks plus default `plugins` stage)
- [x] Re-enable Keyword/Quality plugins via registry and plugin consumer stage
- [x] Delete filter_lib/comment_lib/html_generation_lib/generate_html/active_html route and theme template prompt path
Latest Phase 2 continuation 3: removed the dead generated-static path. The
`/feed/<filterset>` active_html route is gone, the admin regenerate-content
route/form is gone, Docker no longer creates or mounts `/app/active_html`, and
`filter_lib.py`, `comment_lib.py`, `html_generation_lib.py`, `generate_html.py`,
and `themes/template_prompt.txt` were deleted. Focused `rg` found only planning
references afterward, and `python -m py_compile` passed for the touched app,
route, service, and filter pipeline modules.
Latest Phase 2 continuation 2: added shared rule evaluation, moved comment
filter tree modes into `filter_pipeline/stages/comment_filter.py`, wired live
`/api/v1` post-detail/comment endpoints through `FilterEngine.filter_comments()`,
and added `filter_pipeline/stages/plugins.py` so registered Keyword/Quality
plugins have a pipeline consumer. `python -m py_compile` passed for the touched
app, route, service, and filter pipeline modules after these changes.
Latest Phase 2 continuation: added a stage/plugin registry, decorated built-in
categorizer/moderator/filter/ranker stages and keyword/quality plugins, changed
`FilterEngine._init_stages()` to instantiate registered stages, and added
`pipeline.stage_modules` / `plugins.modules` discovery hooks in `filter_config.json`.
## Phase 3 — Pluggable platform fetchers + Postgres [x]
- [x] `PlatformFetcher` protocol + `@register_platform` registry
- [x] Convert if/elif dispatch → `platforms/` fetcher classes (thin adapters over existing fetch functions)
- [x] `Post`/`Comment` SQLAlchemy models + indexes (added to `models.py`; mirror the
on-disk `data/{posts,comments}/*.json` schema — uuid, platform, source, title,
external_id, author, url, content, score, timestamp, tags(JSON), moderation_uuid for posts;
uuid, post_uuid (FK→posts), platform, parent_comment_uuid (self-ref), comment_id, author,
content, score, timestamp, depth, moderation_uuid for comments)
- [x] `migrate_content_to_db.py` backfill from data/*.json (idempotent by uuid,
batched bulk insert, `--dry-run` supported; requires the docker/Postgres env)
- [x] Replace `_load_posts_cache` + directory scans with DB queries + TTL cache (`PostService` is DB-first, disk fallback only)
- [x] Fetchers write to DB; data/ becomes archive-only (`data_collection.py` upserts Post/Comment after archive JSON writes)
Latest Phase 3 continuation 2: cut live content access over to the DB path.
`PostService` now refreshes its TTL cache from `Post`/`Comment` queries first,
with the legacy JSON reader only as a local/dev fallback when DB content is
empty or unavailable. `latest_content_mtime()` and `source_counts()` also query
Postgres first. `data_collection.py` now upserts collected posts/comments into
Postgres after writing archive JSON, and the model/backfill mapping now preserves
post `external_id`, comment `platform`, and longer source strings. Focused
py_compile passed for app, routes, services, models, migration, collection,
platforms, and filter pipeline modules.
Latest Phase 3 continuation: confirmed existing parallel-agent DB prep
(`Post`/`Comment` models and `migrate_content_to_db.py`) and added the
platform fetcher extension point. New `platforms/` modules define the
`PlatformFetcher` protocol, `@register_platform` registry, and built-in
fetcher classes for reddit, pushshift, hackernews, lobsters, stackexchange,
and rss. `data_methods.getData()` now resolves platforms through the registry
instead of an if/elif chain while keeping the legacy network fetch functions as
implementation details. Verified with py_compile and a registry smoke test.
## Phase 4 — Vite SPA [ ]
- [ ] Vite project scaffold (package.json, vite.config, index.html)
- [ ] API client w/ credentials:'include'
- [ ] Feed → detail → auth → bookmarks → settings → admin
- [ ] Flask serves built dist/ with catch-all fallback; dev proxy → Flask
## Phase 5 — Cut over (gated on SPA parity) [-]
Deferred: deleting the Jinja render path is destructive and only safe once the
SPA reaches parity AND can be runtime-verified. Will not delete templates this
pass. Phase 5 also switches Dockerfile off `flask run` to a real WSGI server.
## Phase 6 — Hardening [~]
- [x] pytest scaffold (services + /api/v1 contracts) — `pytest.ini`, `tests/conftest.py`
(in-memory SQLite app fixture, no Postgres; stubbed polling/filter singletons),
`tests/test_app_factory.py` (endpoint registration + no module-level `app`),
`tests/test_api_contracts.py` (posts/post_detail/comments/filters JSON shape with
monkeypatched `post_service` + `get_filter_engine`), `tests/test_filter_pipeline.py`
(offline: registry discovery, offline plugin filterset, AI-disabled fail-open,
comment tree modes), `tests/test_plugin_contract.py` (drop-in stage/plugin)
- [ ] ruff config + CI workflow
- [ ] charset/encoding CI gate
- [x] plugin contract test (drop-in stage/plugin discovered with zero core edits) —
`tests/test_plugin_contract.py` registers a stage + plugin only in the test
module via the public decorators and asserts the engine instantiates and runs
it, plus a throwaway on-disk config selecting it
Verification note: `tests/test_filter_pipeline.py` and `tests/test_plugin_contract.py`
are Flask-free and were exercised locally with a plain-python harness (12/12 pass)
since `pytest` is not installed locally. The app-factory / API-contract tests
`py_compile` clean and run in CI/docker where Flask/SQLAlchemy/bcrypt are present.
## Verification constraints
No venv/Flask deps or Postgres available locally (see memory `env-no-local-runtime`).
All Python verified via `python -m py_compile` only; runtime/endpoint checks
require `docker compose up` or a deps-installed venv. SPA build needs npm.

8
pytest.ini Normal file
View File

@@ -0,0 +1,8 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -ra -q
filterwarnings =
ignore::DeprecationWarning

View File

@@ -12,3 +12,5 @@ authlib==1.3.2
APScheduler==3.10.4 APScheduler==3.10.4
praw==7.7.1 praw==7.7.1
feedparser==6.0.12 feedparser==6.0.12
# Test harness (Agent E)
pytest==8.3.4

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Route registration modules."""

304
routes/admin.py Normal file
View File

@@ -0,0 +1,304 @@
"""Admin route registration."""
import logging
import os
import shutil
from datetime import datetime
from flask import flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from database import db
from extensions import get_polling_service
from models import PollLog, PollSource
from services import load_platform_config, post_service
logger = logging.getLogger(__name__)
def _require_admin(redirect_endpoint="index", message="Access denied"):
if current_user.is_admin:
return None
flash(message, "error")
return redirect(url_for(redirect_endpoint))
def register_admin_routes(app, user_service):
"""Register admin routes while preserving endpoint names."""
@app.route("/admin")
@login_required
def admin_panel():
"""Admin panel - user management."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
if not user_service:
flash("User service not available", "error")
return redirect(url_for("index"))
users = user_service.get_all_users()
return render_template("admin.html", users=users)
@app.route("/admin/user/<user_id>/delete", methods=["POST"])
@login_required
def admin_delete_user(user_id):
"""Delete user (admin only)."""
denied = _require_admin("index")
if denied:
return denied
if current_user.id == user_id:
flash("You cannot delete your own account!", "error")
return redirect(url_for("admin_panel"))
user = user_service.get_user_by_id(user_id)
if user:
username = user.username
if user_service.delete_user(user_id):
flash(f"User {username} has been deleted.", "success")
logger.info(f"Admin {current_user.id} deleted user {username} ({user_id})")
else:
flash("Error deleting user", "error")
logger.error(f"Failed to delete user {user_id}")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/user/<user_id>/toggle-admin", methods=["POST"])
@login_required
def admin_toggle_admin(user_id):
"""Toggle user admin status."""
denied = _require_admin("index")
if denied:
return denied
target_user = user_service.get_user_by_id(user_id)
if target_user:
user_service.update_user_admin_status(user_id, not target_user.is_admin)
flash("Admin status updated", "success")
else:
flash("User not found", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/clear_cache", methods=["POST"])
@login_required
def admin_clear_cache():
"""Clear application cache."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
for cache_dir in ["cache", "temp"]:
if os.path.exists(cache_dir):
shutil.rmtree(cache_dir)
post_service.invalidate()
flash("Cache cleared successfully", "success")
logger.info(f"Cache cleared by admin user {current_user.id}")
except Exception as e:
flash(f"Error clearing cache: {str(e)}", "error")
logger.error(f"Cache clearing error: {e}")
return redirect(url_for("admin_panel"))
@app.route("/admin/backup_data", methods=["POST"])
@login_required
def admin_backup_data():
"""Create backup of application data."""
denied = _require_admin("admin_panel")
if denied:
return denied
try:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_name = f"balanceboard_backup_{timestamp}"
backup_dir = f"backups/{backup_name}"
os.makedirs(backup_dir, exist_ok=True)
for dir_name in ["data", "templates", "themes", "static"]:
if os.path.exists(dir_name):
shutil.copytree(dir_name, f"{backup_dir}/{dir_name}")
for file_name in ["app.py", "models.py", "database.py", "filtersets.json"]:
if os.path.exists(file_name):
shutil.copy2(file_name, backup_dir)
flash(f"Backup created: {backup_name}", "success")
except Exception as e:
flash(f"Error creating backup: {str(e)}", "error")
return redirect(url_for("admin_panel"))
@app.route("/admin/polling")
@login_required
def admin_polling():
"""Admin polling management page."""
denied = _require_admin("index", "Access denied. Admin privileges required.")
if denied:
return denied
sources = PollSource.query.order_by(PollSource.platform, PollSource.display_name).all()
scheduler_status = get_polling_service().get_status()
platform_config = load_platform_config()
return render_template(
"admin_polling.html",
sources=sources,
scheduler_status=scheduler_status,
platform_config=platform_config,
)
@app.route("/admin/polling/add", methods=["POST"])
@login_required
def admin_polling_add():
"""Add a new poll source."""
denied = _require_admin("index")
if denied:
return denied
platform = request.form.get("platform")
source_id = request.form.get("source_id")
custom_source_id = request.form.get("custom_source_id")
display_name = request.form.get("display_name")
poll_interval = int(request.form.get("poll_interval", 60))
max_posts = int(request.form.get("max_posts", 100))
fetch_comments = request.form.get("fetch_comments", "true") == "true"
priority = request.form.get("priority", "medium")
if custom_source_id and custom_source_id.strip():
source_id = custom_source_id.strip()
if not platform or not source_id or not display_name:
flash("Missing required fields", "error")
return redirect(url_for("admin_polling"))
existing = PollSource.query.filter_by(platform=platform, source_id=source_id).first()
if existing:
flash(f"Source {platform}:{source_id} already exists", "warning")
return redirect(url_for("admin_polling"))
source = PollSource(
platform=platform,
source_id=source_id,
display_name=display_name,
poll_interval_minutes=poll_interval,
max_posts=max_posts,
fetch_comments=fetch_comments,
priority=priority,
enabled=False,
created_by=current_user.id,
)
db.session.add(source)
db.session.commit()
flash(f"Added polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} added poll source {platform}:{source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/toggle", methods=["POST"])
@login_required
def admin_polling_toggle(source_id):
"""Toggle a poll source on/off."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
source.enabled = not source.enabled
db.session.commit()
status = "enabled" if source.enabled else "disabled"
flash(f"Polling {status} for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/update", methods=["POST"])
@login_required
def admin_polling_update(source_id):
"""Update poll source configuration."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
if request.form.get("poll_interval"):
source.poll_interval_minutes = int(request.form.get("poll_interval"))
if request.form.get("max_posts"):
source.max_posts = int(request.form.get("max_posts"))
if request.form.get("fetch_comments") is not None:
source.fetch_comments = request.form.get("fetch_comments") == "true"
if request.form.get("priority"):
source.priority = request.form.get("priority")
if request.form.get("display_name"):
source.display_name = request.form.get("display_name")
db.session.commit()
flash(f"Updated settings for {source.display_name}", "success")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/poll-now", methods=["POST"])
@login_required
def admin_polling_poll_now(source_id):
"""Manually trigger polling for a source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
try:
get_polling_service().poll_now(source_id)
flash(f"Polling started for {source.display_name}", "success")
except Exception as e:
flash(f"Error starting poll: {str(e)}", "error")
logger.error(f"Error triggering poll for {source_id}: {e}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/delete", methods=["POST"])
@login_required
def admin_polling_delete(source_id):
"""Delete a poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
display_name = source.display_name
db.session.delete(source)
db.session.commit()
flash(f"Deleted polling source: {display_name}", "success")
logger.info(f"Admin {current_user.id} deleted poll source {source_id}")
return redirect(url_for("admin_polling"))
@app.route("/admin/polling/<source_id>/logs")
@login_required
def admin_polling_logs(source_id):
"""View logs for a specific poll source."""
denied = _require_admin("index")
if denied:
return denied
source = PollSource.query.get(source_id)
if not source:
flash("Source not found", "error")
return redirect(url_for("admin_polling"))
logs = source.logs.limit(50).all()
return render_template("admin_polling_logs.html", source=source, logs=logs)

41
routes/assets.py Normal file
View File

@@ -0,0 +1,41 @@
"""Asset and static-file route registration."""
import logging
import os
from flask import abort, current_app, send_from_directory
from security import is_safe_path
logger = logging.getLogger(__name__)
def register_asset_routes(app):
"""Register asset routes while preserving legacy endpoint names."""
@app.route("/themes/<path:filename>")
def serve_theme(filename):
"""Serve theme files (CSS, JS)."""
if not is_safe_path(filename) or ".." in filename:
logger.warning(f"Unsafe theme file requested: {filename}")
abort(404)
return send_from_directory("themes", filename)
@app.route("/logo.png")
def serve_logo():
"""Serve configurable logo."""
logo_path = current_app.config["LOGO_PATH"]
if "/" not in logo_path:
return send_from_directory(".", logo_path)
directory = os.path.dirname(logo_path)
filename = os.path.basename(logo_path)
return send_from_directory(directory, filename)
@app.route("/static/<path:filename>")
def serve_static(filename):
"""Serve static files (avatars, etc.)."""
if not is_safe_path(filename) or ".." in filename:
logger.warning(f"Unsafe static file requested: {filename}")
abort(404)
return send_from_directory("static", filename)

290
routes/auth.py Normal file
View File

@@ -0,0 +1,290 @@
"""Authentication route registration."""
import logging
from urllib.parse import quote_plus, urlencode
from flask import current_app, flash, redirect, render_template, request, session, url_for
from flask_login import current_user, login_required, login_user, logout_user
from config import MAX_USERNAME_LENGTH, MIN_PASSWORD_LENGTH
from models import User
logger = logging.getLogger(__name__)
def register_auth_routes(app, user_service, auth0_client):
"""Register auth routes while preserving legacy endpoint names."""
@app.route("/login", methods=["GET", "POST"])
def login():
"""Login page."""
if current_user.is_authenticated:
return redirect(url_for("index"))
auth0_configured = bool(
current_app.config.get("AUTH0_DOMAIN")
and current_app.config.get("AUTH0_CLIENT_ID")
)
if request.method == "POST":
username = request.form.get("username")
password = request.form.get("password")
remember = request.form.get("remember", False) == "on"
if not user_service:
flash("User service not available", "error")
return render_template("login.html", auth0_configured=auth0_configured)
user = user_service.authenticate(username, password)
if user:
login_user(user, remember=remember)
flash(f"Welcome back, {user.username}!", "success")
next_page = request.args.get("next")
return redirect(next_page) if next_page else redirect(url_for("index"))
flash("Invalid username or password", "error")
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")
user = User.query.filter_by(email=email).first()
flash(
"If an account exists with that email, a password reset link has been sent.",
"success",
)
if user and user.password_hash:
token = user.generate_reset_token()
reset_url = url_for("password_reset", token=token, _external=True)
logger.info(f"Password reset requested for {email}. Reset URL: {reset_url}")
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"))
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) < MIN_PASSWORD_LENGTH:
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
return render_template("password_reset.html")
if password != confirm_password:
flash("Passwords do not match", "error")
return render_template("password_reset.html")
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")
@app.route("/auth0/login")
def auth0_login():
"""Redirect to Auth0 for authentication."""
if not current_app.config.get("AUTH0_DOMAIN") or not current_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)
return auth0_client.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")
def auth0_callback():
"""Handle Auth0 callback and create/login user."""
try:
token = auth0_client.authorize_access_token()
user_info = token.get("userinfo")
if not user_info:
user_info = auth0_client.parse_id_token(token)
auth0_id = user_info.get("sub")
email = user_info.get("email")
username = (
user_info.get("nickname")
or user_info.get("preferred_username")
or email.split("@")[0]
)
if not auth0_id or not email:
flash("Unable to get user information from Auth0", "error")
return redirect(url_for("login"))
user = user_service.get_user_by_auth0_id(auth0_id)
if not user:
existing_user = user_service.get_user_by_email(email)
if existing_user:
user_service.link_auth0_account(existing_user.id, auth0_id)
user = existing_user
flash(f"Account linked successfully! Welcome back, {user.username}!", "success")
else:
base_username = username[:MAX_USERNAME_LENGTH - 3]
unique_username = base_username
counter = 1
while user_service.username_exists(unique_username):
unique_username = f"{base_username}_{counter}"
counter += 1
user_id = user_service.create_user(
username=unique_username,
email=email,
password=None,
is_admin=False,
auth0_id=auth0_id,
)
if user_id:
user = user_service.get_user_by_id(user_id)
flash(f"Account created successfully! Welcome, {user.username}!", "success")
else:
flash("Failed to create user account", "error")
return redirect(url_for("login"))
else:
flash(f"Welcome back, {user.username}!", "success")
if user:
login_user(user, remember=True)
session["auth0_user_info"] = user_info
next_page = request.args.get("next")
return redirect(next_page) if next_page else redirect(url_for("index"))
except Exception as e:
logger.error(f"Auth0 callback error: {e}")
flash("Authentication failed. Please try again.", "error")
return redirect(url_for("login"))
@app.route("/auth0/logout")
@login_required
def auth0_logout():
"""Logout from Auth0 and local session."""
session.clear()
logout_user()
domain = current_app.config["AUTH0_DOMAIN"]
client_id = current_app.config["AUTH0_CLIENT_ID"]
return_to = url_for("index", _external=True)
logout_url = f"https://{domain}/v2/logout?" + urlencode(
{"returnTo": return_to, "client_id": client_id}, quote_via=quote_plus
)
return redirect(logout_url)
@app.route("/admin-setup", methods=["GET", "POST"])
def admin_setup():
"""Create first admin user."""
try:
user_count = User.query.count()
if user_count > 0:
flash("Admin user already exists.", "info")
return redirect(url_for("login"))
except Exception as e:
logger.warning(f"Database error checking existing users: {e}")
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
password = request.form.get("password")
password_confirm = request.form.get("password_confirm")
if not username or not email or not password:
flash("All fields are required", "error")
return render_template("admin_setup.html")
if password != password_confirm:
flash("Passwords do not match", "error")
return render_template("admin_setup.html")
if len(password) < MIN_PASSWORD_LENGTH:
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
return render_template("admin_setup.html")
user_id = user_service.create_user(username, email, password, is_admin=True)
if user_id:
flash("Admin account created successfully! Please log in.", "success")
return redirect(url_for("login"))
flash("Error creating admin account. Please try again.", "error")
return render_template("admin_setup.html")
@app.route("/signup", methods=["GET", "POST"])
def signup():
"""Signup page."""
if current_user.is_authenticated:
return redirect(url_for("index"))
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
password = request.form.get("password")
password_confirm = request.form.get("password_confirm")
if not user_service:
flash("User service not available", "error")
return render_template("signup.html")
if not username or not email or not password:
flash("All fields are required", "error")
return render_template("signup.html")
if password != password_confirm:
flash("Passwords do not match", "error")
return render_template("signup.html")
if len(password) < MIN_PASSWORD_LENGTH:
flash(f"Password must be at least {MIN_PASSWORD_LENGTH} characters", "error")
return render_template("signup.html")
if user_service.username_exists(username):
flash("Username already taken", "error")
return render_template("signup.html")
if user_service.email_exists(email):
flash("Email already registered", "error")
return render_template("signup.html")
user_id = user_service.create_user(username, email, password)
if user_id:
flash("Account created successfully! Please log in.", "success")
return redirect(url_for("login"))
flash("Error creating account. Please try again.", "error")
return render_template("signup.html")
@app.route("/logout")
@login_required
def logout():
"""Logout current user."""
logout_user()
flash("You have been logged out.", "info")
return redirect(url_for("index"))

96
routes/pages.py Normal file
View File

@@ -0,0 +1,96 @@
"""Page route registration for the legacy Jinja UI."""
import json
import logging
from flask import current_app, redirect, render_template, url_for
from flask_login import current_user, login_required
from services import get_display_name_for_source, load_platform_config, post_service
logger = logging.getLogger(__name__)
def _load_user_settings():
if not current_user.is_authenticated:
return {}
try:
return json.loads(current_user.settings) if current_user.settings else {}
except (json.JSONDecodeError, TypeError):
return {}
def register_page_routes(app):
"""Register legacy page routes while preserving endpoint names."""
@app.route("/")
def index():
"""Serve the main feed page."""
quick_stats = post_service.quick_stats()
if current_user.is_authenticated:
return render_template(
"dashboard.html",
user_settings=_load_user_settings(),
quick_stats=quick_stats,
)
if current_app.config.get("ALLOW_ANONYMOUS_ACCESS", False):
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,
)
return redirect(url_for("login"))
@app.route("/bookmarks")
@login_required
def bookmarks():
"""Bookmarks page."""
return render_template("bookmarks.html", user=current_user)
@app.route("/post/<post_id>")
def post_detail(post_id):
"""Serve individual post detail page with modern theme."""
try:
platform_config = load_platform_config()
cached_posts, cached_comments = post_service.load()
post_data = cached_posts.get(post_id)
if not post_data:
return render_template("404.html"), 404
post = dict(post_data)
post["source_display"] = get_display_name_for_source(
post.get("platform", ""),
post.get("source", ""),
platform_config,
)
comments_flat = cached_comments.get(post_id, [])
logger.info(f"Loading post {post_id}: found {len(comments_flat)} comments")
comments = post_service.build_comment_tree(comments_flat)
return render_template(
"post_detail.html",
post=post,
comments=comments,
user_settings=_load_user_settings(),
)
except Exception as e:
logger.error(f"Error loading post {post_id}: {e}")
return render_template("404.html"), 404

310
routes/settings.py Normal file
View File

@@ -0,0 +1,310 @@
"""Settings and profile route registration."""
import json
import logging
import os
from flask import current_app, flash, redirect, render_template, request, url_for
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from config import MAX_FILENAME_LENGTH, UPLOAD_FOLDER
from database import db
from extensions import get_filter_engine
from security import is_allowed_file, is_safe_filterset
from services import SettingsService, load_platform_config
logger = logging.getLogger(__name__)
DEFAULT_AVATARS = [
{"id": "default_1", "name": "Gradient Blue", "bg": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"},
{"id": "default_2", "name": "Gradient Green", "bg": "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)"},
{"id": "default_3", "name": "Gradient Orange", "bg": "linear-gradient(135deg, #fa709a 0%, #fee140 100%)"},
{"id": "default_4", "name": "Gradient Purple", "bg": "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)"},
{"id": "default_5", "name": "Brand Colors", "bg": "linear-gradient(135deg, #4db6ac 0%, #26a69a 100%)"},
{"id": "default_6", "name": "Sunset", "bg": "linear-gradient(135deg, #ff7e5f 0%, #feb47b 100%)"},
]
def register_settings_routes(app, user_service):
"""Register settings/profile routes while preserving endpoint names."""
@app.route("/settings")
@login_required
def settings():
"""Main settings page."""
user_settings = SettingsService.parse(current_user.settings)
try:
with open("filtersets.json", "r", encoding="utf-8") as f:
filter_sets = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, IOError):
filter_sets = {}
return render_template(
"settings.html",
user=current_user,
user_settings=user_settings,
filter_sets=filter_sets,
)
@app.route("/settings/profile", methods=["GET", "POST"])
@login_required
def settings_profile():
"""Profile settings page."""
if request.method == "POST":
username = request.form.get("username")
email = request.form.get("email")
default_avatar = request.form.get("default_avatar")
if not username or not email:
flash("Username and email are required", "error")
return render_template("settings_profile.html", user=current_user)
if username != current_user.username and user_service.username_exists(username):
flash("Username already taken", "error")
return render_template("settings_profile.html", user=current_user)
if email != current_user.email and user_service.email_exists(email):
flash("Email already registered", "error")
return render_template("settings_profile.html", user=current_user)
current_user.username = username
current_user.email = email
if default_avatar and default_avatar.startswith("default_"):
current_user.profile_picture_url = f"/static/default-avatars/{default_avatar}.png"
db.session.commit()
flash("Profile updated successfully", "success")
return redirect(url_for("settings"))
return render_template(
"settings_profile.html",
user=current_user,
default_avatars=DEFAULT_AVATARS,
)
@app.route("/settings/communities", methods=["GET", "POST"])
@login_required
def settings_communities():
"""Community/source selection settings."""
if request.method == "POST":
user_settings = SettingsService.parse(current_user.settings)
user_settings["communities"] = request.form.getlist("communities")
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Community preferences updated", "success")
return redirect(url_for("settings"))
user_settings = SettingsService.parse(current_user.settings)
selected_communities = user_settings.get("communities", [])
available_communities = []
try:
platform_config = load_platform_config() or {"platforms": {}, "collection_targets": []}
except Exception as e:
logger.error(f"Error loading platform config: {e}")
platform_config = {"platforms": {}, "collection_targets": []}
enabled_communities = set()
try:
for target in platform_config.get("collection_targets", []):
if "platform" in target and "community" in target:
enabled_communities.add((target["platform"], target["community"]))
except Exception as e:
logger.error(f"Error processing collection_targets: {e}")
try:
for platform_name, platform_info in platform_config.get("platforms", {}).items():
if not isinstance(platform_info, dict):
continue
communities = platform_info.get("communities", [])
if not isinstance(communities, list):
continue
for community_info in communities:
try:
if not isinstance(community_info, dict):
continue
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", "\U0001f4c4")),
"description": community_info.get("description", ""),
}
)
except Exception as e:
logger.error(f"Error processing community {community_info}: {e}")
continue
except Exception as e:
logger.error(f"Error building community list: {e}")
logger.info(f"Found {len(available_communities)} available communities")
return render_template(
"settings_communities.html",
user=current_user,
available_communities=available_communities,
selected_communities=selected_communities,
)
@app.route("/settings/filters", methods=["GET", "POST"])
@login_required
def settings_filters():
"""Filter settings page."""
if request.method == "POST":
selected_filter = request.form.get("filter_set", "no_filter")
user_settings = SettingsService.validate(current_user.settings)
if is_safe_filterset(selected_filter):
user_settings["filter_set"] = selected_filter
else:
flash("Invalid filter selection", "error")
return redirect(url_for("settings"))
try:
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Filter settings updated successfully", "success")
except Exception as e:
db.session.rollback()
logger.error(f"Error saving filter settings for user {current_user.id}: {e}")
flash("Error saving settings", "error")
return redirect(url_for("settings"))
user_settings = SettingsService.parse(current_user.settings)
current_filter = user_settings.get("filter_set", "no_filter")
filter_engine = get_filter_engine()
filter_sets = {
filterset_name: filter_engine.config.get_filterset(filterset_name)
for filterset_name in filter_engine.get_available_filtersets()
}
return render_template(
"settings_filters.html",
user=current_user,
filter_sets=filter_sets,
current_filter=current_filter,
)
@app.route("/settings/experience", methods=["GET", "POST"])
@login_required
def settings_experience():
"""Experience and behavioral settings page."""
if request.method == "POST":
user_settings = SettingsService.parse(current_user.settings)
user_settings["experience"] = {
"infinite_scroll": request.form.get("infinite_scroll") == "on",
"auto_refresh": request.form.get("auto_refresh") == "on",
"push_notifications": request.form.get("push_notifications") == "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)),
}
current_user.settings = json.dumps(user_settings)
db.session.commit()
flash("Experience settings updated successfully", "success")
return redirect(url_for("settings"))
return render_template(
"settings_experience.html",
user=current_user,
experience_settings=SettingsService.experience_settings(current_user.settings),
)
@app.route("/upload-avatar", methods=["POST"])
@login_required
def upload_avatar():
"""Upload profile picture."""
try:
logger.info(f"Avatar upload attempt by user {current_user.id} ({current_user.username})")
logger.debug(f"Request files: {list(request.files.keys())}")
logger.debug(f"Request form: {dict(request.form)}")
if not hasattr(current_user, "id") or not current_user.id:
logger.error("User missing ID attribute")
flash("Authentication error. Please log in again.", "error")
return redirect(url_for("login"))
if not hasattr(current_user, "username") or not current_user.username:
logger.error("User missing username attribute")
flash("User profile incomplete. Please update your profile.", "error")
return redirect(url_for("settings_profile"))
if "avatar" not in request.files:
logger.warning("No avatar file in request")
flash("No file selected", "error")
return redirect(url_for("settings_profile"))
file = request.files["avatar"]
if file.filename == "":
logger.warning("Empty filename provided")
flash("No file selected", "error")
return redirect(url_for("settings_profile"))
logger.info(f"Processing file: {file.filename}")
if not is_allowed_file(file.filename):
logger.warning(f"Invalid file type: {file.filename}")
flash("Invalid file type. Please upload PNG, JPG, or GIF", "error")
return redirect(url_for("settings_profile"))
max_content_length = current_app.config.get("MAX_CONTENT_LENGTH", 16 * 1024 * 1024)
if hasattr(file, "content_length") and file.content_length > max_content_length:
logger.warning(f"File too large: {file.content_length}")
flash("File too large. Maximum size is 16MB", "error")
return redirect(url_for("settings_profile"))
filename = secure_filename(file.filename)
if not filename or len(filename) > MAX_FILENAME_LENGTH:
logger.warning(f"Invalid filename after sanitization: {filename}")
flash("Invalid filename", "error")
return redirect(url_for("settings_profile"))
unique_filename = f"{current_user.id}_{filename}"
logger.info(f"Generated unique filename: {unique_filename}")
upload_dir = os.path.abspath(UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
upload_path = os.path.join(upload_dir, unique_filename)
if not os.path.abspath(upload_path).startswith(upload_dir):
logger.warning(f"Path traversal attempt in file upload: {upload_path}")
flash("Invalid file path", "error")
return redirect(url_for("settings_profile"))
file.save(upload_path)
logger.info(f"File saved successfully: {upload_path}")
old_avatar_url = current_user.profile_picture_url
current_user.profile_picture_url = f"/static/avatars/{unique_filename}"
db.session.commit()
logger.info(f"User profile updated successfully for {current_user.username}")
if old_avatar_url and old_avatar_url.startswith("/static/avatars/") and current_user.id in old_avatar_url:
try:
old_file_path = os.path.join(upload_dir, os.path.basename(old_avatar_url))
if os.path.exists(old_file_path):
os.remove(old_file_path)
logger.info(f"Cleaned up old avatar: {old_file_path}")
except Exception as e:
logger.warning(f"Could not clean up old avatar: {e}")
flash("Profile picture updated successfully", "success")
return redirect(url_for("settings_profile"))
except Exception as e:
logger.error(f"Unexpected error in avatar upload: {e}")
db.session.rollback()
flash("An unexpected error occurred. Please try again.", "error")
return redirect(url_for("settings_profile"))
@app.route("/profile")
@login_required
def profile():
"""User profile page."""
return render_template("profile.html", user=current_user)

View File

@@ -6,7 +6,7 @@ Starts the Flask web app with PostgreSQL/SQLAlchemy integration.
import os import os
import sys import sys
from app import app from app import create_app
def main(): def main():
@@ -52,6 +52,7 @@ def main():
# Run Flask app # Run Flask app
debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() == 'true' debug_mode = os.getenv('FLASK_DEBUG', 'True').lower() == 'true'
app = create_app()
app.run(host=host, port=port, debug=debug_mode) app.run(host=host, port=port, debug=debug_mode)

37
security.py Normal file
View File

@@ -0,0 +1,37 @@
"""Security helper functions shared across route modules."""
import re
from config import ALLOWED_EXTENSIONS
from extensions import get_filter_engine
def is_safe_filterset(filterset):
"""Validate a filterset name against the filter engine's known filtersets
and a safe-character whitelist. Fails closed if the filter engine has not
been initialized yet.
"""
if not filterset or not isinstance(filterset, str):
return False
try:
allowed = set(get_filter_engine().get_available_filtersets())
except Exception:
return False
return filterset in allowed and re.match(r"^[a-zA-Z0-9_-]+$", filterset) is not None
def is_safe_path(path):
"""Validate a relative file path against directory-traversal attempts."""
if not path or not isinstance(path, str):
return False
if ".." in path or path.startswith("/") or "\\" in path:
return False
return re.match(r"^[a-zA-Z0-9._/-]+$", path) is not None
def is_allowed_file(filename):
"""Check whether an uploaded filename has an allowed image extension."""
return (
"." in filename
and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
)

17
services/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""Service layer for BalanceBoard.
Services hold business logic shared across route modules, keeping the route
handlers thin. Posts/comments still come from disk JSON in this phase (moved
to Postgres in Phase 3); ``PostService`` owns that cache.
"""
from .post_service import PostService, load_platform_config, get_display_name_for_source, post_service
from .settings_service import SettingsService
__all__ = [
"PostService",
"post_service",
"load_platform_config",
"get_display_name_for_source",
"SettingsService",
]

252
services/post_service.py Normal file
View File

@@ -0,0 +1,252 @@
"""Post/comment data service.
Owns the short-lived in-memory cache of posts and comments. Phase 3 makes
Postgres the primary source of truth; the legacy ``data/*.json`` reader remains
as a fallback for local/dev environments before the backfill has run.
"""
import json
import logging
import time
from collections import defaultdict
from datetime import datetime, timedelta
from pathlib import Path
from sqlalchemy import func
from models import Comment, Post
logger = logging.getLogger(__name__)
_CACHE_DURATION = 300 # 5 minutes
def load_platform_config():
"""Load platform configuration from ``platform_config.json``.
Returns a safe default (empty platforms, no targets) on any error so
callers can iterate without extra guarding.
"""
try:
with open("platform_config.json", "r", encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, IOError) as e:
logger.warning(f"Could not load platform config: {e}")
return {"platforms": {}, "collection_targets": []}
def get_display_name_for_source(platform, source, platform_config):
"""Get a human display name for a (platform, source) pair."""
if not platform_config or "platforms" not in platform_config:
return source
platform_info = platform_config["platforms"].get(platform, {})
if platform_info.get("supports_communities"):
for community in platform_info.get("communities", []):
if community["id"] == source:
return community["display_name"]
prefix = platform_info.get("prefix", "")
return f"{prefix}{source}" if source else platform_info.get("name", platform)
return platform_info.get("name", platform)
class PostService:
"""Cache and serve posts/comments from Postgres, plus derived views."""
def __init__(self, cache_duration=_CACHE_DURATION):
self.post_cache = {}
self.comment_cache = defaultdict(list)
self.cache_timestamp = 0
self.cache_duration = cache_duration
self.cache_source = None
def load(self):
"""Return (post_cache, comment_cache), refreshing from Postgres if stale."""
current_time = time.time()
if current_time - self.cache_timestamp < self.cache_duration and self.post_cache:
return self.post_cache, self.comment_cache
self.post_cache.clear()
self.comment_cache.clear()
loaded_from_db = self._load_from_db()
if not loaded_from_db:
self._load_from_disk()
self.cache_source = "disk"
else:
self.cache_source = "db"
self.cache_timestamp = current_time
logger.info(
f"Cache refreshed from {self.cache_source}: {len(self.post_cache)} posts, "
f"{len(self.comment_cache)} comment groups"
)
return self.post_cache, self.comment_cache
def _load_from_db(self):
"""Populate caches from Postgres. Return False if unavailable or empty."""
try:
posts = Post.query.order_by(Post.timestamp.desc()).all()
if not posts:
return False
for post in posts:
self.post_cache[post.uuid] = self._post_to_dict(post)
comments = Comment.query.order_by(Comment.timestamp.asc()).all()
for comment in comments:
self.comment_cache[comment.post_uuid].append(self._comment_to_dict(comment))
return True
except Exception as e:
logger.warning(f"Postgres content load unavailable; falling back to disk: {e}")
self.post_cache.clear()
self.comment_cache.clear()
return False
def _load_from_disk(self):
posts_dir = Path("data/posts")
comments_dir = Path("data/comments")
if posts_dir.exists():
for post_file in posts_dir.glob("*.json"):
try:
with open(post_file, "r", encoding="utf-8") as f:
post_data = json.load(f)
post_uuid = post_data.get("uuid")
if post_uuid:
self.post_cache[post_uuid] = post_data
except (json.JSONDecodeError, IOError) as e:
logger.debug(f"Error reading post file {post_file}: {e}")
if comments_dir.exists():
for comment_file in comments_dir.glob("*.json"):
try:
with open(comment_file, "r", encoding="utf-8") as f:
comment_data = json.load(f)
post_uuid = comment_data.get("post_uuid")
if post_uuid:
self.comment_cache[post_uuid].append(comment_data)
except (json.JSONDecodeError, IOError) as e:
logger.debug(f"Error reading comment file {comment_file}: {e}")
@staticmethod
def _post_to_dict(post):
return {
"uuid": post.uuid,
"id": post.external_id or post.uuid,
"platform": post.platform,
"source": post.source,
"title": post.title,
"author": post.author,
"url": post.url,
"content": post.content,
"score": post.score,
"timestamp": post.timestamp,
"tags": post.tags or [],
"moderation_uuid": post.moderation_uuid,
}
@staticmethod
def _comment_to_dict(comment):
return {
"uuid": comment.uuid,
"post_uuid": comment.post_uuid,
"platform": comment.platform,
"parent_comment_uuid": comment.parent_comment_uuid,
"id": comment.comment_id or comment.uuid,
"comment_id": comment.comment_id,
"author": comment.author,
"content": comment.content,
"score": comment.score,
"timestamp": comment.timestamp,
"depth": comment.depth,
"moderation_uuid": comment.moderation_uuid,
}
def invalidate(self):
"""Force the next ``load()`` to refresh content."""
self.cache_timestamp = 0
def quick_stats(self):
"""Return {posts_today, total_posts} for the dashboard."""
cached_posts, _ = self.load()
now = datetime.utcnow()
today_timestamp = (now - timedelta(hours=24)).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)}
@staticmethod
def build_comment_tree(comments):
"""Build a hierarchical comment tree from a flat comment list."""
comment_dict = {c["uuid"]: {**c, "replies": []} for c in comments}
root_comments = []
for comment in comments:
parent_uuid = comment.get("parent_comment_uuid")
if parent_uuid and parent_uuid in comment_dict:
comment_dict[parent_uuid]["replies"].append(
comment_dict[comment["uuid"]]
)
else:
root_comments.append(comment_dict[comment["uuid"]])
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
def latest_content_mtime(self):
"""Latest content update timestamp for client auto-refresh polling."""
try:
latest_created = Post.query.with_entities(func.max(Post.created_at)).scalar()
if latest_created:
return latest_created.timestamp()
except Exception as e:
logger.debug(f"Could not read latest content timestamp from DB: {e}")
posts_dir = Path("data/posts")
if not posts_dir.exists():
return 0
latest = 0
for post_file in posts_dir.glob("*.json"):
mtime = post_file.stat().st_mtime
if mtime > latest:
latest = mtime
return latest
def source_counts(self):
"""Count posts per ``platform:source`` for the platforms API."""
try:
rows = (
Post.query.with_entities(Post.platform, Post.source, func.count(Post.uuid))
.group_by(Post.platform, Post.source)
.all()
)
if rows:
return {f"{platform}:{source}": count for platform, source, count in rows}
except Exception as e:
logger.debug(f"Could not read source counts from DB: {e}")
counts = {}
posts_dir = Path("data/posts")
if not posts_dir.exists():
return counts
for post_file in posts_dir.glob("*.json"):
try:
with open(post_file, "r", encoding="utf-8") as f:
post_data = json.load(f)
key = f"{post_data.get('platform', 'unknown')}:{post_data.get('source', '')}"
counts[key] = counts.get(key, 0) + 1
except (json.JSONDecodeError, IOError):
continue
return counts
post_service = PostService()

View File

@@ -0,0 +1,92 @@
"""User settings validation and defaults."""
import json
import logging
import re
from config import MAX_COMMUNITY_NAME_LENGTH
from security import is_safe_filterset
logger = logging.getLogger(__name__)
class SettingsService:
"""Parse and sanitize user settings JSON shared by page and API routes."""
EXPERIENCE_DEFAULTS = {
"infinite_scroll": False,
"auto_refresh": False,
"push_notifications": False,
"dark_patterns_opt_in": False,
"time_filter_enabled": False,
"time_filter_days": 7,
}
EXPERIENCE_BOOL_FIELDS = {
"infinite_scroll",
"auto_refresh",
"push_notifications",
"dark_patterns_opt_in",
"time_filter_enabled",
}
@classmethod
def parse(cls, settings_str):
"""Return settings JSON as a dict, or an empty dict if invalid."""
if not settings_str:
return {}
try:
settings = json.loads(settings_str)
except json.JSONDecodeError as e:
logger.warning(f"Invalid user settings JSON: {e}")
return {}
if not isinstance(settings, dict):
logger.warning("User settings must be a JSON object")
return {}
return settings
@classmethod
def validate(cls, settings_str):
"""Validate and sanitize persisted user settings JSON."""
settings = cls.parse(settings_str)
validated = {}
filter_set = settings.get("filter_set")
if isinstance(filter_set, str) and is_safe_filterset(filter_set):
validated["filter_set"] = filter_set
communities = settings.get("communities")
if isinstance(communities, list):
safe_communities = []
for community in communities:
if (
isinstance(community, str)
and len(community) <= MAX_COMMUNITY_NAME_LENGTH
and re.match(r"^[a-zA-Z0-9_-]+$", community)
):
safe_communities.append(community)
validated["communities"] = safe_communities
experience = settings.get("experience")
if isinstance(experience, dict):
safe_experience = {}
for field in cls.EXPERIENCE_BOOL_FIELDS:
if field in experience and isinstance(experience[field], bool):
safe_experience[field] = experience[field]
time_filter_days = experience.get("time_filter_days")
if isinstance(time_filter_days, int) and time_filter_days > 0:
safe_experience["time_filter_days"] = time_filter_days
validated["experience"] = safe_experience
return validated
@classmethod
def experience_settings(cls, settings_str):
"""Return experience settings with defaults filled in."""
settings = cls.parse(settings_str)
experience = settings.get("experience", {})
if not isinstance(experience, dict):
experience = {}
return {**cls.EXPERIENCE_DEFAULTS, **experience}

View File

@@ -127,13 +127,14 @@ def start_flask():
# Import and run Flask app # Import and run Flask app
try: try:
from app import app from app import create_app
print_color("✓ Flask app imported successfully", 'green') print_color("✓ Flask app imported successfully", 'green')
print_color("✓ Database initialized with SQLAlchemy", 'green') print_color("✓ Database initialized with SQLAlchemy", 'green')
print_color("✓ User authentication ready", 'green') print_color("✓ User authentication ready", 'green')
print() print()
# Run Flask # Run Flask
app = create_app()
app.run(host='0.0.0.0', port=FLASK_PORT, debug=True, use_reloader=False) app.run(host='0.0.0.0', port=FLASK_PORT, debug=True, use_reloader=False)
except Exception as e: except Exception as e:

View File

@@ -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 {

View File

@@ -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 {

587
templates/_admin_base.html Normal file
View File

@@ -0,0 +1,587 @@
<!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,
.btn-primary,
.btn-secondary {
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;
text-align: center;
border: 1px solid transparent;
}
.btn-primary {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.btn-primary:hover {
background: var(--primary-hover);
border-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--surface-elevation-1);
color: var(--text-primary);
border-color: var(--divider-color);
}
.btn-secondary:hover {
background: var(--surface-elevation-2);
border-color: var(--divider-color);
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.btn:disabled:hover {
background: var(--primary-color) !important;
border-color: var(--primary-color) !important;
}
.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(250px, 1fr));
gap: 16px;
margin-bottom: 24px;
max-width: 100%;
}
@media (max-width: 768px) {
.admin-stats {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.admin-stats {
grid-template-columns: repeat(4, 1fr);
}
}
.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;
max-width: 100%;
}
@media (max-width: 768px) {
.system-info {
grid-template-columns: 1fr;
}
}
@media (min-width: 1200px) {
.system-info {
grid-template-columns: repeat(3, 1fr);
}
}
/* ===== FORMS ===== */
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.form-control,
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
transition: border-color 0.2s ease;
}
.form-control:focus,
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 6px;
font-size: 0.9rem;
background-color: var(--surface-color);
cursor: pointer;
transition: border-color 0.2s ease;
}
.form-select:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
.form-label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: var(--text-primary);
}
.help-text {
margin-top: 4px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.add-source-form {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
border: 1px solid var(--divider-color);
}
.add-source-form h3 {
margin: 0 0 20px 0;
color: var(--text-primary);
font-size: 1.2rem;
}
/* ===== MODALS ===== */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
backdrop-filter: blur(2px);
}
.modal-overlay.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal {
background: var(--surface-color);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
border: 1px solid var(--divider-color);
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--divider-color);
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: 4px;
transition: all 0.2s ease;
}
.modal-close:hover {
background: var(--surface-elevation-1);
color: var(--text-primary);
}
.modal-body {
margin-bottom: 20px;
}
.modal-footer {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid var(--divider-color);
}
.modal-alert {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
color: #856404;
font-size: 0.9rem;
}
/* ===== 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
View 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>

View File

@@ -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>
@@ -506,16 +171,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="admin-section">
<h3 class="section-title">Content Actions</h3>
<form method="POST" action="{{ url_for('admin_regenerate_content') }}">
<button type="submit" class="btn btn-primary">Regenerate All Content</button>
<p style="margin-top: 8px; font-size: 0.85rem; color: var(--text-secondary);">
This will regenerate all HTML files with current templates and filters.
</p>
</form>
</div>
</div> </div>
<!-- System Tab --> <!-- System Tab -->
@@ -556,10 +211,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 +229,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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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
View 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/v1/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/v1/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 %}

View File

@@ -1,55 +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">
{% if anonymous %}
<div class="anonymous-actions">
<a href="{{ url_for('login') }}" class="login-btn">🔑 Login</a>
<a href="{{ url_for('register') }}" class="register-btn">📝 Sign Up</a>
</div>
{% else %}
<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>
{% endif %}
</div>
</div>
</nav>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="main-content"> <main class="main-content">
@@ -57,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>
@@ -83,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">
@@ -357,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;
@@ -447,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;
} }
@@ -723,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 }};
@@ -733,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();
}); });
@@ -742,34 +708,94 @@ document.addEventListener('DOMContentLoaded', function() {
// Load platform configuration and communities // Load platform configuration and communities
async function loadPlatformConfig() { async function loadPlatformConfig() {
try { try {
const response = await fetch('/api/platforms'); const response = await fetch('/api/v1/platforms');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json(); const data = await response.json();
platformConfig = data.platforms || {}; platformConfig = data.platforms || {};
communitiesData = data.communities || []; communitiesData = data.communities || [];
console.log('Loaded communities:', communitiesData);
renderCommunities(communitiesData); renderCommunities(communitiesData);
setupCommunityFiltering(); setupCommunityFiltering();
} catch (error) { } catch (error) {
console.error('Error loading platform configuration:', error); console.error('Error loading platform configuration:', error);
// Show fallback communities // Show fallback communities
const fallbackCommunities = [ const fallbackCommunities = [
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 0}, {platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
{platform: 'reddit', id: 'python', display_name: 'r/python', icon: '🐍', count: 0}, {platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
{platform: 'hackernews', id: 'hackernews', display_name: 'Hacker News', icon: '🧮', count: 0} {platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '', count: 0}
]; ];
communitiesData = fallbackCommunities;
renderCommunities(fallbackCommunities); renderCommunities(fallbackCommunities);
setupCommunityFiltering(); setupCommunityFiltering();
} }
} }
// Load available filters
async function loadFilters() {
try {
const response = await fetch('/api/v1/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');
if (!communityList) return; if (!communityList) return;
if (communities.length === 0) { console.log('Rendering communities:', communities);
communityList.innerHTML = '<div class="no-communities">No communities available</div>';
return; if (!communities || communities.length === 0) {
// Always show fallback communities if none are loaded
const fallbackCommunities = [
{platform: 'reddit', id: 'programming', display_name: 'r/programming', icon: '💻', count: 117},
{platform: 'hackernews', id: 'front_page', display_name: 'Hacker News', icon: '🧮', count: 117},
{platform: 'reddit', id: 'technology', display_name: 'r/technology', icon: '⚡', count: 0}
];
communities = fallbackCommunities;
} }
// Add "All Communities" option at the top // Add "All Communities" option at the top
@@ -795,7 +821,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();
@@ -803,8 +829,10 @@ 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/v1/posts?${params}`);
const data = await response.json(); const data = await response.json();
const newPosts = data.posts || []; const newPosts = data.posts || [];
paginationData = data.pagination || {}; paginationData = data.pagination || {};
@@ -995,25 +1023,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
@@ -1037,15 +1075,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) {
@@ -1120,7 +1195,7 @@ function setupAutoRefresh() {
if (currentPage === 1 && !currentCommunity && !currentPlatform) { if (currentPage === 1 && !currentCommunity && !currentPlatform) {
try { try {
// Check if new content is available by checking timestamp // Check if new content is available by checking timestamp
const response = await fetch('/api/content-timestamp'); const response = await fetch('/api/v1/content-timestamp');
const data = await response.json(); const data = await response.json();
const lastContentUpdate = data.timestamp; const lastContentUpdate = data.timestamp;

View File

@@ -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>
@@ -48,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">
@@ -56,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>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Set New Password - BalanceBoard{% endblock %} {% block title %}Set New Password - {{ 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;">Set a new password</p> <p style="color: var(--text-secondary); margin-top: 8px;">Set a new password</p>
</div> </div>

View File

@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Reset Password - BalanceBoard{% endblock %} {% block title %}Reset Password - {{ 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;">Reset your password</p> <p style="color: var(--text-secondary); margin-top: 8px;">Reset your password</p>
</div> </div>

View File

@@ -1,69 +1,29 @@
{% 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 --> {% 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">
{% 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>
{% 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="auth-buttons">
<a href="{{ url_for('login') }}" class="auth-btn">Login</a>
<a href="{{ url_for('signup') }}" class="auth-btn primary">Sign Up</a>
</div>
{% endif %}
</div>
</div>
</nav>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="main-content single-post"> <main class="main-content single-post">
<!-- Back Button --> <!-- Back Button -->
<div class="back-section"> <div class="back-section">
<button onclick="goBackToFeed()" class="back-btn">← Back to Feed</button> <a href="#" onclick="goBackToFeed(event)" class="back-btn">← Back to Feed</a>
</div> </div>
<!-- Post Content --> <!-- Post Content -->
<article class="post-detail"> <article class="post-detail">
<div class="post-header"> <div class="post-header">
{% if post.url and not post.url.startswith('/') %}
<a href="{{ post.url }}" target="_blank" class="platform-badge platform-{{ post.platform }}" title="View on {{ post.platform.title() }}">
{{ post.platform.title()[:1] }}
</a>
{% else %}
<div class="platform-badge platform-{{ post.platform }}"> <div class="platform-badge platform-{{ post.platform }}">
{{ post.platform.title()[:1] }} {{ post.platform.title()[:1] }}
</div> </div>
{% endif %}
<div class="post-meta"> <div class="post-meta">
<span class="post-author">{{ post.author }}</span> <span class="post-author">{{ post.author }}</span>
<span class="post-separator"></span> <span class="post-separator"></span>
@@ -71,7 +31,7 @@
<span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span> <span class="post-source">{{ post.source_display if post.source_display else ('r/' + post.source if post.platform == 'reddit' else post.source) }}</span>
<span class="post-separator"></span> <span class="post-separator"></span>
{% endif %} {% endif %}
<span class="post-time">{{ moment(post.timestamp).fromNow() if moment else 'Recently' }}</span> <span class="post-time" data-timestamp="{{ post.timestamp }}">{{ post.timestamp|timeago }}</span>
{% if post.url and not post.url.startswith('/') %} {% if post.url and not post.url.startswith('/') %}
<span class="external-link-indicator">🔗</span> <span class="external-link-indicator">🔗</span>
{% endif %} {% endif %}
@@ -88,7 +48,7 @@
{% if post.content %} {% if post.content %}
<div class="post-content"> <div class="post-content">
{{ post.content | safe | nl2br }} {{ post.content | nl2br }}
</div> </div>
{% endif %} {% endif %}
@@ -105,7 +65,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,24 +96,36 @@
<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>
<span class="comment-time">{{ moment(comment.timestamp).fromNow() if moment else 'Recently' }}</span> <span class="comment-time" data-timestamp="{{ comment.timestamp }}">{{ comment.timestamp|timeago }}</span>
</div> </div>
<div class="comment-content"> <div class="comment-content">
{{ comment.content | safe | nl2br }} {{ comment.content | nl2br }}
</div> </div>
<div class="comment-footer"> <div class="comment-footer">
<div class="comment-score"> <div class="comment-score">
<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 %}
@@ -190,6 +162,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.nav-left .logo-section:hover {
transform: scale(1.02);
text-decoration: none;
} }
.nav-logo { .nav-logo {
@@ -347,6 +327,35 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.anonymous-actions {
display: flex;
gap: 12px;
}
.login-btn, .register-btn {
padding: 8px 16px;
border-radius: 8px;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.login-btn {
color: #2c3e50;
border: 1px solid #e2e8f0;
}
.register-btn {
background: #4db6ac;
color: white;
}
.login-btn:hover, .register-btn:hover {
transform: translateY(-1px);
text-decoration: none;
}
/* Main Content */ /* Main Content */
.main-content.single-post { .main-content.single-post {
max-width: 1200px; max-width: 1200px;
@@ -554,12 +563,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;
@@ -636,13 +657,23 @@
</style> </style>
<script> <script>
function goBackToFeed() { function goBackToFeed(event) {
// Try to go back to the dashboard if possible event.preventDefault();
if (document.referrer && document.referrer.includes(window.location.origin)) {
// Try to go back in browser history first
if (window.history.length > 1 && document.referrer && document.referrer.includes(window.location.origin)) {
window.history.back(); window.history.back();
} else { } else {
// Fallback to dashboard // Fallback to dashboard - construct URL with current query parameters
window.location.href = '/'; const urlParams = new URLSearchParams(window.location.search);
const baseUrl = {{ url_for('index')|tojson }};
// Add query parameters if they exist
if (urlParams.toString()) {
window.location.href = baseUrl + '?' + urlParams.toString();
} else {
window.location.href = baseUrl;
}
} }
} }
@@ -654,6 +685,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!');
} }

View File

@@ -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>

View File

@@ -1,11 +1,11 @@
{% 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>
.settings-container { .settings-container {
max-width: 1000px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
} }
@@ -79,12 +79,25 @@
.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;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px; gap: 16px;
max-width: 100%;
}
@media (max-width: 768px) {
.community-grid {
grid-template-columns: 1fr;
}
}
@media (min-width: 1400px) {
.community-grid {
grid-template-columns: repeat(3, 1fr);
}
} }
.community-item { .community-item {
@@ -235,7 +248,14 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% include '_nav.html' %}
<div class="settings-container"> <div class="settings-container">
<nav style="margin-bottom: 24px;">
<a href="{{ url_for('settings') }}" style="color: var(--primary-color); text-decoration: none; font-weight: 500;">
← Back to Settings
</a>
</nav>
<div class="settings-header"> <div class="settings-header">
<h1>Community Settings</h1> <h1>Community Settings</h1>
<p>Select which communities, subreddits, and sources to include in your feed</p> <p>Select which communities, subreddits, and sources to include in your feed</p>
@@ -268,7 +288,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>

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

112
tests/conftest.py Normal file
View File

@@ -0,0 +1,112 @@
"""Shared pytest fixtures for BalanceBoard.
The app fixture runs against an in-memory SQLite database (no Postgres
required) by monkeypatching ``database.init_db``. Filter-engine and
polling-service singletons are stubbed so request handlers do not start
background threads or hit the real filter pipeline during API contract
tests.
"""
import pytest
from sqlalchemy.pool import StaticPool
def _sqlite_init_db(app):
"""Replace the Postgres init_db with an in-memory SQLite setup."""
from database import db
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
# A single in-memory DB shared across the test's connections.
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"poolclass": StaticPool}
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
with app.app_context():
db.create_all()
@pytest.fixture
def app(monkeypatch):
import database
from app import create_app
monkeypatch.setattr(database, "init_db", _sqlite_init_db)
application = create_app()
application.config.update(TESTING=True)
return application
@pytest.fixture
def client(app):
return app.test_client()
class _StubPolling:
"""No-op polling service so ``before_request`` does not start threads."""
def init_app(self, app):
pass
def start(self):
pass
class _StubFilterEngine:
"""Passthrough filter engine for API contract tests."""
class _Config:
def get_filterset(self, name):
return None
config = _Config()
def apply_filterset(self, posts, filterset_name="no_filter", use_cache=True):
for p in posts:
p.setdefault("_filter_score", 0.5)
p.setdefault("_filter_categories", [])
p.setdefault("_filter_tags", [])
return posts
def filter_comments(self, comments, filterset_name="no_filter"):
return comments
def get_available_filtersets(self):
return ["no_filter"]
@pytest.fixture
def stub_services(app, monkeypatch):
"""Patch the app's lazy service accessors so requests stay hermetic."""
import app as app_module
monkeypatch.setattr(app_module, "get_filter_engine", lambda: _StubFilterEngine())
monkeypatch.setattr(app_module, "get_polling_service", lambda: _StubPolling())
return app
class StubPostService:
"""In-memory post/comment store for /api/v1 contract tests."""
def __init__(self, posts=None, comments=None):
self._posts = posts or {}
self._comments = comments or {}
def load(self):
return self._posts, self._comments
@staticmethod
def build_comment_tree(comments):
comment_dict = {c["uuid"]: {**c, "replies": []} for c in comments}
roots = []
for c in comments:
parent = c.get("parent_comment_uuid")
if parent and parent in comment_dict:
comment_dict[parent]["replies"].append(comment_dict[c["uuid"]])
else:
roots.append(comment_dict[c["uuid"]])
return roots
def source_counts(self):
return {}
def latest_content_mtime(self):
return 0

102
tests/test_api_contracts.py Normal file
View File

@@ -0,0 +1,102 @@
"""/api/v1 contract tests with monkeypatched post_service + filter engine.
No live Postgres and no real filter pipeline: ``post_service`` is replaced with
an in-memory stub and ``get_filter_engine`` with a passthrough stub. These
assert the JSON shape the templates/SPA consume, so refactor regressions are
caught without a runtime stack.
"""
import pytest
from conftest import StubPostService, _StubFilterEngine
SAMPLE_POST = {
"uuid": "post-1",
"title": "Sample post",
"author": "alice",
"platform": "hackernews",
"source": "programming",
"score": 42,
"timestamp": 1700000000,
"url": "https://example.com/1",
"content": "Hello world",
"tags": ["tech"],
}
SAMPLE_COMMENTS = [
{"uuid": "c1", "post_uuid": "post-1", "content": "top", "score": 3,
"depth": 0, "parent_comment_uuid": None},
{"uuid": "c2", "post_uuid": "post-1", "content": "reply", "score": 1,
"depth": 1, "parent_comment_uuid": "c1"},
]
@pytest.fixture
def api_client(app, monkeypatch):
"""App + client with stubbed post_service and filter engine."""
import app as app_module
import blueprints.api as api_module
monkeypatch.setattr(app_module, "get_filter_engine", lambda: _StubFilterEngine())
monkeypatch.setattr(app_module, "get_polling_service", lambda: _StubPolling())
monkeypatch.setattr(api_module, "get_filter_engine", lambda: _StubFilterEngine())
monkeypatch.setattr(
api_module, "post_service",
StubPostService(posts={"post-1": SAMPLE_POST}, comments={"post-1": SAMPLE_COMMENTS}),
)
return app.test_client()
def test_posts_endpoint_returns_paginated_shape(api_client):
resp = api_client.get("/api/v1/posts")
assert resp.status_code == 200
data = resp.get_json()
assert set(data.keys()) >= {"posts", "pagination"}
assert set(data["pagination"].keys()) >= {
"current_page", "total_pages", "total_posts", "per_page", "has_next", "has_prev"
}
assert data["pagination"]["total_posts"] == 1
post = data["posts"][0]
assert post["id"] == "post-1"
assert post["title"] == "Sample post"
assert post["platform"] == "hackernews"
assert post["url"] == "/post/post-1"
assert "filter_score" in post
def test_post_detail_returns_post_and_comments(api_client):
resp = api_client.get("/api/v1/posts/post-1")
assert resp.status_code == 200
data = resp.get_json()
assert data["post"]["uuid"] == "post-1"
assert isinstance(data["comments"], list)
# The tree has one root with one nested reply.
assert data["comments"][0]["uuid"] == "c1"
assert data["comments"][0]["replies"][0]["uuid"] == "c2"
def test_post_detail_404_for_unknown(api_client):
resp = api_client.get("/api/v1/posts/does-not-exist")
assert resp.status_code == 404
def test_comments_endpoint_returns_tree(api_client):
resp = api_client.get("/api/v1/comments/post-1")
assert resp.status_code == 200
data = resp.get_json()
assert data["comments"][0]["uuid"] == "c1"
assert data["comments"][0]["replies"][0]["uuid"] == "c2"
def test_filters_endpoint_lists_filtersets(api_client):
resp = api_client.get("/api/v1/filters")
assert resp.status_code == 200
data = resp.get_json()
assert "filters" in data
# Stub engine advertises no_filter; the list may be empty or contain it.
assert isinstance(data["filters"], list)
# Imported via the api_client fixture's monkeypatch; keep the name available.
from conftest import _StubPolling # noqa: E402, F401

58
tests/test_app_factory.py Normal file
View File

@@ -0,0 +1,58 @@
"""App-factory tests: route registration and endpoint names are intact.
These guard the Phase 1 refactor (module-level ``app`` → ``create_app()`` with
routes split under ``routes/`` and ``blueprints/``). They assert that the
endpoints referenced by templates via ``url_for(...)`` still resolve in the
Flask url_map. Runs against in-memory SQLite (no Postgres).
"""
import pytest
# Endpoints that templates reference via url_for(...) and that must survive
# the factory refactor. Sourced from the Jinja templates.
TEMPLATE_ENDPOINTS = [
"login",
"signup",
"admin_setup",
"logout",
"serve_logo",
"serve_theme",
"static",
# API blueprint endpoints (mounted at /api/v1)
"api.posts",
"api.post_detail",
"api.comments",
"api.filters",
"api.bookmarks",
"api.platforms",
]
def test_create_app_returns_flask_app(app):
from flask import Flask
assert isinstance(app, Flask)
def test_api_blueprint_mounted_under_v1(app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert any(r.startswith("/api/v1/posts") for r in rules), rules
assert any(r.startswith("/api/v1/comments") for r in rules), rules
@pytest.mark.parametrize("endpoint", TEMPLATE_ENDPOINTS)
def test_template_endpoints_exist(app, endpoint):
# Flask stores endpoints as "<blueprint>.<view>" for blueprint views and
# bare names for app-level views. ``url_map`` has both.
all_endpoints = {r.endpoint for r in app.url_map.iter_rules()}
assert endpoint in all_endpoints, (
f"endpoint '{endpoint}' missing from url_map; have: {sorted(all_endpoints)[:20]}..."
)
def test_no_module_level_app_object():
"""Phase 1 removed the module-level ``app``; importing app.py must not
start a server or expose a global Flask app."""
import app as app_module
assert not hasattr(app_module, "app"), "app.py must not keep a module-level `app`"

View File

@@ -0,0 +1,103 @@
"""Offline filter-pipeline tests.
These do NOT require Flask or Postgres — only the filter_pipeline package (which
depends on stdlib + ``requests``). They exercise the registry-driven engine,
the offline plugin path, AI-disabled fail-open behavior, and comment filtering.
"""
import pytest
from filter_pipeline.engine import FilterEngine
from filter_pipeline.models import ProcessingStatus
from filter_pipeline.registry import (
get_registered_plugins,
get_registered_stages,
)
@pytest.fixture
def engine():
# Fresh engine (not the singleton) so test isolation holds.
return FilterEngine("filter_config.json", "filtersets.json")
def test_registry_discovers_builtin_stages_and_plugins(engine):
engine._init_stages()
stages = get_registered_stages()
for name in ["categorizer", "moderator", "filter", "ranker",
"plugins", "comment_filter"]:
assert name in stages, f"stage '{name}' not registered"
plugins = get_registered_plugins()
for name in ["keyword", "quality"]:
assert name in plugins, f"plugin '{name}' not registered"
def test_offline_filterset_runs_without_ai(engine):
# quality_filter uses pipeline_stages=['plugins','ranker'] only.
posts = [
{"uuid": "p1", "title": "A fine Python programming title",
"content": "c" * 200, "score": 10, "replies": 2,
"platform": "reddit", "source": "python", "timestamp": 1700000000},
{"uuid": "p2", "title": "bad", "content": "x", "score": 0, "replies": 0,
"platform": "reddit", "source": "python", "timestamp": 1700000000},
]
results = engine.process_batch(posts, "quality_filter")
assert results[0].passed is True
# Quality plugin rejects the 3-char title.
assert results[1].passed is False
assert any("QualityFilter" in t for t in results[1].tags)
def test_ai_filterset_does_not_blank_feed_when_ai_disabled(engine):
posts = [{"uuid": "p1", "title": "Hello world this is a fine title",
"content": "c" * 200, "score": 10, "replies": 2,
"platform": "hackernews", "source": "programming",
"timestamp": 1700000000}]
out = engine.apply_filterset(posts, "safe_content", use_cache=False)
assert len(out) == 1, "AI-disabled filterset must not blank the feed"
results = engine.process_batch(posts, "safe_content")
assert results[0].status == ProcessingStatus.FAILED
def test_no_filter_passes_everything(engine):
posts = [{"uuid": "p1", "title": "anything", "content": "", "score": 0,
"timestamp": 0}]
out = engine.apply_filterset(posts, "no_filter", use_cache=False)
assert len(out) == 1
def test_comment_filter_individual_mode(engine):
comments = [
{"uuid": "c1", "content": "this is long enough", "score": 5,
"depth": 0, "parent_comment_uuid": None},
{"uuid": "c2", "content": "hi", "score": 1, "depth": 1,
"parent_comment_uuid": "c1"},
]
kept = engine.filter_comments(comments, "quality_filter")
assert [c["uuid"] for c in kept] == ["c1"]
def test_comment_filter_no_filter_passes_all(engine):
comments = [{"uuid": "c1", "content": "hi", "score": 1, "depth": 0,
"parent_comment_uuid": None}]
assert len(engine.filter_comments(comments, "no_filter")) == 1
def test_comment_filter_unknown_filterset_fails_open(engine):
comments = [{"uuid": "c1", "content": "hi", "score": 1, "depth": 0,
"parent_comment_uuid": None}]
assert len(engine.filter_comments(comments, "no_such_filterset")) == 1
def test_comment_tree_pruning_drops_branch_without_moderation(engine):
# safe_content comment rules require moderation.flags.is_safe == True.
# With no moderation data attached the field is None -> rule fails closed,
# so tree pruning removes the parent and its child.
comments = [
{"uuid": "r", "content": "root", "score": 5, "depth": 0,
"parent_comment_uuid": None},
{"uuid": "c", "content": "child", "score": 1, "depth": 1,
"parent_comment_uuid": "r"},
]
kept = engine.filter_comments(comments, "safe_content")
assert kept == []

View File

@@ -0,0 +1,90 @@
"""Plugin/stage contract test (Phase 6).
A drop-in stage and plugin, defined ONLY in this test module via the public
``@register_stage`` / ``@register_plugin`` decorators, are picked up by the
engine with zero edits to core files. This is the pluggability guarantee: a
new filter behavior is a new module + a config entry, never an edit to
``engine.py``.
"""
from filter_pipeline.engine import FilterEngine
from filter_pipeline.models import FilterResult
from filter_pipeline.plugins.base import BaseFilterPlugin
from filter_pipeline.registry import (
get_plugin_class,
get_stage_class,
register_plugin,
register_stage,
)
from filter_pipeline.stages.base_stage import BaseStage
@register_stage("sentinel_dropin_stage")
class SentinelStage(BaseStage):
"""Drop-in stage that tags any result it sees."""
def get_name(self):
return "Sentinel"
def process(self, post, result):
result.tags.append("sentinel_ran")
return result
@register_plugin("sentinel_dropin_plugin")
class SentinelPlugin(BaseFilterPlugin):
"""Drop-in plugin: never rejects, returns a fixed score."""
def get_name(self):
return "SentinelPlugin"
def should_filter(self, post, context=None):
return False
def score(self, post, context=None):
return 0.9
def test_dropin_stage_is_registered():
assert get_stage_class("sentinel_dropin_stage") is SentinelStage
def test_dropin_plugin_is_registered():
assert get_plugin_class("sentinel_dropin_plugin") is SentinelPlugin
def test_engine_instantiates_dropin_stage():
eng = FilterEngine("filter_config.json", "filtersets.json")
eng._init_stages()
assert "sentinel_dropin_stage" in eng._stages
stage = eng._stages["sentinel_dropin_stage"]
# Running the stage through the contract it claims to implement works.
result = FilterResult(post_uuid="x", passed=True, score=0.5)
out = stage.process({"uuid": "x"}, result)
assert "sentinel_ran" in out.tags
def test_dropin_stage_can_be_selected_in_a_filterset(tmp_path):
"""A filterset that lists the drop-in stage actually runs it.
Builds a throwaway config + filterset on disk so no core file is edited.
"""
import json
cfg = tmp_path / "cfg.json"
cfg.write_text(json.dumps({
"ai": {"enabled": False},
"cache": {"enabled": False},
"pipeline": {"default_stages": ["sentinel_dropin_stage"], "enable_parallel": False},
"plugins": {"enabled": [], "configs": {}},
}))
fs = tmp_path / "fs.json"
fs.write_text(json.dumps({"custom": {"post_rules": {}, "comment_rules": {}}}))
eng = FilterEngine(str(cfg), str(fs))
eng._init_stages()
results = eng.process_batch(
[{"uuid": "p1", "title": "t", "content": "", "score": 0, "timestamp": 0}],
"custom",
)
assert "sentinel_ran" in results[0].tags

View File

@@ -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>

View File

@@ -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/v1/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/v1/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>

View File

@@ -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 */

View File

@@ -1,120 +0,0 @@
# Template Creation Prompt for AI
This document describes the data structures, helper functions, and conventions an AI needs to create or modify HTML templates for this social media archive system.
## Data Structures Available
### Post Data (when rendering posts)
- **Available in all post templates (card, list, detail):**
- platform: string (e.g., "reddit", "hackernews")
- id: string (unique post identifier)
- title: string
- author: string
- timestamp: integer (unix timestamp)
- score: integer (up/down vote score)
- replies: integer (number of comments)
- url: string (original post URL)
- content: string (optional post body text)
- source: string (optional subreddit/community)
- tags: array of strings (optional tags/flair)
- meta: object (optional platform-specific metadata)
- comments: array (optional nested comment tree - only in detail templates)
- post_url: string (generated: "{uuid}.html" - for local linking to detail pages)
### Comment Data (when rendering comments)
- **Available in comment templates:**
- uuid: string (unique comment identifier)
- id: string (platform-specific identifier)
- author: string (comment author username)
- content: string (comment text)
- timestamp: integer (unix timestamp)
- score: integer (comment score)
- platform: string
- depth: integer (nesting level)
- children: array (nested replies)
- children_section: string (pre-rendered HTML of nested children)
## Template Engine: Jinja2
Templates use Jinja2 syntax (`{{ }}` for variables, `{% %}` for control flow).
### Important Filters:
- `|safe`: Mark content as safe HTML (for already-escaped content)
- Example: `{{ renderMarkdown(content)|safe }}`
### Available Control Structures:
- `{% if variable %}...{% endif %}`
- `{% for item in array %}...{% endfor %}`
- `{% set variable = value %}` (create local variables)
## Helper Functions Available
Call these in templates using `{{ function(arg) }}`:
### Time/Date Formatting:
- `formatTime(timestamp)` -> "HH:MM"
- `formatTimeAgo(timestamp)` -> "2 hours ago"
- `formatDateTime(timestamp)` -> "January 15, 2024 at 14:30"
### Text Processing:
- `truncate(text, max_length)` -> truncated string with "..."
- `escapeHtml(text)` -> HTML-escaped version
### Content Rendering:
- `renderMarkdown(text)` -> Basic HTML from markdown (returns already-escaped HTML)
## Template Types
### Card Template (for index/listing pages)
- Used for summary view of posts
- Links should use `post_url` to point to local detail pages
- Keep concise - truncated content, basic info
### List Template (compact listing)
- Even more compact than cards
- Vote scores, basic metadata, title link
### Detail Template (full post view)
- Full content, meta information
- Source link uses `url` (external)
- Must include `{{comments_section|safe}}` for rendered comments
### Comment Template (nested comments)
- Recursive rendering with depth styling
- Children rendered as flattened HTML in `children_section`
## Convenience Data Added by System
In `generate_html.py`, `post_url` is added to each post before rendering: `{post['uuid']}.html`
This allows templates to link to local detail pages instead of external Reddit.
## CSS Classes Convention
Templates use semantic CSS classes:
- Post cards: `.post-card`, `.post-header`, `.post-meta`, etc.
- Comments: `.comment`, `.comment-header`, `.comment-body`, etc.
- Platform: `.platform-{platform}` for platform-specific styling
## Examples
### Conditional Rendering:
```
{% if content %}
<p class="content">{{ renderMarkdown(content)|safe }}</p>
{% endif %}
```
### Looping Tags:
```
{% for tag in tags if tag %}
<span class="tag">{{ tag }}</span>
{% endfor %}
```
### Styling by Depth (comments):
```
<div class="comment" style="margin-left: {{depth * 20}}px">
```
When creating new templates, follow these patterns and use the available data and helper functions appropriately.