diff --git a/backend/ai.py b/backend/ai.py index cb7a562..406eba1 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -22,69 +22,76 @@ INITIAL_BACKOFF_SECONDS = 35 # Free tier asks to retry after ~35s +def _difficulty_badge(difficulty: str) -> str: + badges = {"Easy": "🟢 Easy", "Medium": "🟡 Medium", "Hard": "🔴 Hard"} + return badges.get(difficulty, f"⚪ {difficulty}") + + def _build_prompt(problem, current_time: str) -> str: """ - Build the prompt string to send to Gemini AI. + Builds a structured prompt for Gemini AI using LeetCode problem details, + solution code, author information, and optional custom instructions. Args: - problem: LeetCode problem object containing title, description, code and author - current_time: Current timestamp string + problem: Object containing the LeetCode problem title, description, + code, author, difficulty, and custom prompt. + current_time (str): Timestamp used in the generated blog footer. Returns: str: Formatted prompt string for Gemini AI """ + badge = _difficulty_badge(getattr(problem, "difficulty", None) or "Unknown") custom_instructions = "" default_prompt = f""" - You are a professional technical writer and competitive programmer. - - Generate a highly engaging, beginner-friendly Dev.to blog post about a LeetCode problem. - - Author Account: {problem.author} - Publishing Time: {current_time} - Title: {problem.title} - - Problem Description: - {problem.description} - - Solution Code: - {problem.code} - - Strictly follow this structure: - 1. Title (Use an engaging # Title instead of YAML) - 2. Problem Explanation (explain it simply, as if to a beginner) - 3. Intuition (the "aha!" moment) - 4. Approach (step-by-step logic) - 5. Code (formatted clearly inside markdown code blocks, specify language if obvious) - 6. Time & Space Complexity Analysis - 7. Key Takeaways - 8. Submission Details (MUST include the Author Account [{problem.author}] and the Time Published [{current_time}] in a concluding footnote) - - CRITICAL INSTRUCTIONS: - - DO NOT wrap the output in ```markdown or ``` tags. Return raw markdown text. - - DO NOT output YAML frontmatter (no --- blocks). - - TABLE FORMATTING (STRICT RULES): - - If you use a Markdown table, it MUST be perfectly formatted to render correctly. - - Each row (header, separator, or data) MUST start with `|` and end with `|`. - - A table row MUST be on exactly ONE single line. DO NOT use line breaks inside rows. - - The header row, separator row (e.g., `|---|---|`), and all data rows MUST have the EXACT same number of columns. - - CELL CONTENT: If a cell contains a bitwise OR operator `|` or any pipe character, you MUST escape it as `\\|` (e.g., `(a \\| b)`). Failing to escape pipes inside cells will break the table structure. - - Ensure the separator line is continuous (no line breaks) and uses at least 3 dashes per column. - - Always provide an EMPTY LINE before and after the table to ensure correct rendering. - """ +You are a professional technical writer and competitive programmer. + +Generate a highly engaging, beginner-friendly Dev.to blog post about a LeetCode problem. + +Author Account: {problem.author} +Publishing Time: {current_time} +Title: {problem.title} +Difficulty: {badge} + +Problem Description: +{problem.description} + +Solution Code: +{problem.code} + +Strictly follow this structure: +1. Title (Use an engaging # Title instead of YAML) +2. Difficulty Badge — render it prominently right below the title as: **Difficulty:** {badge} +3. Problem Explanation (explain it simply, as if to a beginner) +4. Intuition (the "aha!" moment) +5. Approach (step-by-step logic) +6. Code (formatted clearly inside markdown code blocks, specify language if obvious) +7. Time & Space Complexity Analysis +8. Key Takeaways +9. Submission Details (MUST include the Author Account [{problem.author}] and the Time Published [{current_time}] in a concluding footnote) + +CRITICAL INSTRUCTIONS: +- DO NOT wrap the output in ```markdown or ``` tags. Return raw markdown text. +- DO NOT output YAML frontmatter (no --- blocks). +- TABLE FORMATTING (STRICT RULES): + - If you use a Markdown table, it MUST be perfectly formatted to render correctly. + - Each row (header, separator, or data) MUST start with `|` and end with `|`. + - A table row MUST be on exactly ONE single line. DO NOT use line breaks inside rows. + - The header row, separator row (e.g., `|---|---|`), and all data rows MUST have the EXACT same number of columns. + - CELL CONTENT: If a cell contains a bitwise OR operator `|` or any pipe character, you MUST escape it as `\\|` (e.g., `(a \\| b)`). Failing to escape pipes inside cells will break the table structure. + - Ensure the separator line is continuous (no line breaks) and uses at least 3 dashes per column. + - Always provide an EMPTY LINE before and after the table to ensure correct rendering. +""" if hasattr(problem, "custom_prompt") and problem.custom_prompt: - cleaned_custom_prompt = problem.custom_prompt.strip() - if cleaned_custom_prompt: + cleaned = problem.custom_prompt.strip() + if cleaned: custom_instructions = f""" - Additional User Prompt Preferences: - {cleaned_custom_prompt} - """ +Additional User Prompt Preferences: +{cleaned} +""" - return f""" - {default_prompt} - {custom_instructions} - """ + return f"{default_prompt}{custom_instructions}" def _clean_response(text: str) -> str: diff --git a/backend/alerts/init__.py b/backend/alerts/init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/alerts/scheduler.py b/backend/alerts/scheduler.py deleted file mode 100644 index 33e661e..0000000 --- a/backend/alerts/scheduler.py +++ /dev/null @@ -1,14 +0,0 @@ -from time import timezone - -from alerts.progress_checker import check_unsolved_users -from services.reminder_scheduler import BackgroundScheduler, start_scheduler - -scheduler = BackgroundScheduler() - -# Check daily at 11:00 PM IST -scheduler.add_job( - check_unsolved_users, "cron", hour=23, minute=0, timezone=timezone("Asia/Kolkata") -) - -scheduler.start() -start_scheduler() diff --git a/backend/devto.py b/backend/devto.py index 0cca656..2861c80 100644 --- a/backend/devto.py +++ b/backend/devto.py @@ -1,11 +1,9 @@ import asyncio import os -import time from dataclasses import dataclass from typing import Any import httpx -import requests from dotenv import load_dotenv load_dotenv() @@ -42,7 +40,7 @@ class PublisherError(Exception): class BasePublisher: platform = "base" - def publish( + async def publish( self, title: str, content: str, @@ -54,7 +52,7 @@ def publish( raise NotImplementedError @staticmethod - def _post_with_retries( + async def _post_with_retries( url: str, *, headers: dict[str, str], @@ -62,26 +60,31 @@ def _post_with_retries( platform: str, retries: int = 2, ) -> dict[str, Any]: - for attempt in range(retries + 1): - try: - response = requests.post(url, headers=headers, json=payload, timeout=20) - if response.status_code in (200, 201): - return response.json() - if attempt == retries: - raise PublisherError( - f"{platform} API Error {response.status_code}: {response.text}" + async with httpx.AsyncClient() as client: + for attempt in range(retries + 1): + try: + response = await client.post( + url, headers=headers, json=payload, timeout=20.0 ) - except requests.RequestException as exc: - if attempt == retries: - raise PublisherError(f"{platform} network error: {exc}") from exc - time.sleep(1) - raise PublisherError(f"{platform} API request failed.") + if response.status_code in (200, 201): + return response.json() + if attempt == retries: + raise PublisherError( + f"{platform} API Error {response.status_code}: {response.text}" + ) + except httpx.RequestError as exc: + if attempt == retries: + raise PublisherError( + f"{platform} network error: {exc}" + ) from exc + await asyncio.sleep(1) + raise PublisherError(f"{platform} API request failed.") class DevToPublisher(BasePublisher): platform = "devto" - def publish( + async def publish( self, title: str, content: str, @@ -96,7 +99,7 @@ def publish( "Dev.to API key missing. Add it in Settings > Integrations." ) - response = self._post_with_retries( + response = await self._post_with_retries( "https://dev.to/api/articles", headers={ "api-key": api_key, @@ -196,7 +199,7 @@ async def publish( class MediumPublisher(BasePublisher): platform = "medium" - def publish( + async def publish( self, title: str, content: str, @@ -213,7 +216,7 @@ def publish( "Medium publishing requires MEDIUM_TOKEN and MEDIUM_USER_ID." ) - response = self._post_with_retries( + response = await self._post_with_retries( f"https://api.medium.com/v1/users/{user_id}/posts", headers={ "Authorization": f"Bearer {token}", @@ -241,7 +244,7 @@ def publish( class WebhookPublisher(BasePublisher): platform = "webhook" - def publish( + async def publish( self, title: str, content: str, @@ -254,7 +257,7 @@ def publish( if not webhook_url: raise PublisherError("Personal blog publishing requires BLOG_WEBHOOK_URL.") - response = self._post_with_retries( + response = await self._post_with_retries( webhook_url, headers={"Content-Type": "application/json"}, payload={ @@ -343,14 +346,10 @@ async def publish_to_platforms( return [result.as_dict() for result in results] -def post_to_platform(title: str, content: str) -> dict[str, Any]: +async def post_to_platform(title: str, content: str) -> dict[str, Any]: """Backward-compatible Dev.to-only wrapper used by older integrations.""" - result = DevToPublisher().publish( - title, - content, - tags=DEFAULT_TAGS, - published=True, - ) - if result.status != "success": - raise Exception(result.message or "Dev.to publishing failed.") - return result.response or result.as_dict() + results = await publish_to_platforms(title, content, platforms=["devto"]) + first = results[0] + if first["status"] != "success": + raise Exception(first.get("message", "Dev.to publishing failed.")) + return first.get("response", first) diff --git a/backend/main.py b/backend/main.py index 3d21f8c..c550589 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,6 +12,7 @@ import uvicorn from dotenv import load_dotenv from fastapi import Depends, FastAPI, Header, HTTPException, Query, status +from fastapi.concurrency import run_in_threadpool from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -21,7 +22,7 @@ from ai_core.blog_generator import generate_blog from devto import publish_to_platforms from models.reminder import PublishRecord -from services.reminder_scheduler import start_scheduler +from services.scheduler_service import start_scheduler from social import share_to_platforms load_dotenv() @@ -38,6 +39,13 @@ async def lifespan(app: FastAPI): except Exception as e: print(f"Reminder scheduler failed to start: {e}") yield + try: + from services.scheduler_service import scheduler + if scheduler.running: + scheduler.shutdown() + print("Reminder scheduler shut down successfully.") + except Exception as e: + print(f"Failed to shut down scheduler: {e}") app = FastAPI(title="LeetLog AI", version="1.0.0", lifespan=lifespan) @@ -357,6 +365,7 @@ async def update_integration_settings( + # ----------------------------- # Health Check # ----------------------------- @@ -403,7 +412,7 @@ async def create_blog( user_settings = await _settings_for_user(current_user["id"]) if current_user else {} try: - blog_content = generate_blog(problem, credentials=user_settings) + blog_content = await run_in_threadpool(generate_blog, problem, credentials=user_settings) except Exception as e: return {"status": "error", "message": f"AI provider failure: {str(e)}"} @@ -595,8 +604,7 @@ def test_whatsapp(): try: import os - from alerts.twilio_service import send_whatsapp_message - + from services.twilio_service import send_whatsapp_message phone = os.getenv("TEST_PHONE_NUMBER") if not phone: return { @@ -621,8 +629,8 @@ def test_call(): try: import os - from alerts.elevenlabs_service import generate_audio, generate_message - from alerts.twilio_service import make_call + from services.elevenlabs_service import generate_audio, generate_message + from services.twilio_service import make_call message = generate_message("Vansh") diff --git a/backend/requirements.txt b/backend/requirements.txt index 288345f..8a996cf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,8 +2,9 @@ fastapi uvicorn[standard] pydantic google-genai -httpx +openai requests +httpx python-dotenv twilio elevenlabs @@ -12,8 +13,7 @@ celery[redis] motor pytz pymongo -tweepy -openai +tweepy==4.14.0 # --- Test & Dev Dependencies --- pytest diff --git a/backend/ruff.toml b/backend/ruff.toml index 21b06a3..62a741f 100644 --- a/backend/ruff.toml +++ b/backend/ruff.toml @@ -6,4 +6,4 @@ select = ["E", "F", "W", "I"] ignore = ["E501"] [lint.isort] -known-first-party = ["ai", "devto", "services", "alerts", "models", "utils"] +known-first-party = ["ai", "devto", "services", "models"] diff --git a/backend/services/call_service.py b/backend/services/call_service.py deleted file mode 100644 index c702c74..0000000 --- a/backend/services/call_service.py +++ /dev/null @@ -1,27 +0,0 @@ -import os - -from twilio.rest import Client - -client = Client(os.getenv("TWILIO_ACCOUNT_SID"), os.getenv("TWILIO_AUTH_TOKEN")) - - -def make_call(to_number: str, audio_url: str = None, text_to_say: str = None): - twilio_number = os.getenv("TWILIO_PHONE_NUMBER", "") - from_number = twilio_number.replace("whatsapp:", "") if twilio_number else "" - - if not from_number: - raise ValueError("TWILIO_PHONE_NUMBER is not set in environment variables.") - - if audio_url: - twiml = f"{audio_url}" - elif text_to_say: - twiml = f"{text_to_say}" - else: - raise ValueError("Either audio_url or text_to_say must be provided.") - - call = client.calls.create( - to=to_number, - from_=from_number, - twiml=twiml, - ) - return call.sid diff --git a/backend/alerts/elevenlabs_service.py b/backend/services/elevenlabs_service.py similarity index 100% rename from backend/alerts/elevenlabs_service.py rename to backend/services/elevenlabs_service.py diff --git a/backend/alerts/progress_checker.py b/backend/services/progress_service.py similarity index 97% rename from backend/alerts/progress_checker.py rename to backend/services/progress_service.py index 9c7b01a..06c9699 100644 --- a/backend/alerts/progress_checker.py +++ b/backend/services/progress_service.py @@ -7,8 +7,8 @@ import pytz import requests -from alerts.elevenlabs_service import generate_message -from alerts.twilio_service import send_whatsapp_message +from services.elevenlabs_service import generate_message +from services.twilio_service import send_whatsapp_message mongo_client = motor.motor_asyncio.AsyncIOMotorClient(os.getenv("MONGODB_URI")) db = mongo_client.leetcodeai @@ -124,8 +124,8 @@ async def _send_alert(user: dict) -> None: await asyncio.to_thread(send_whatsapp_message, phone, message) try: - from alerts.elevenlabs_service import generate_audio - from alerts.twilio_service import make_call + from services.elevenlabs_service import generate_audio + from services.twilio_service import make_call try: audio_file = await asyncio.to_thread(generate_audio, message) diff --git a/backend/services/reminder_scheduler.py b/backend/services/reminder_scheduler.py deleted file mode 100644 index e518690..0000000 --- a/backend/services/reminder_scheduler.py +++ /dev/null @@ -1,25 +0,0 @@ -import os - -from apscheduler.schedulers.background import BackgroundScheduler - -from alerts.progress_checker import check_unsolved_users - -SCHEDULER_INTERVAL_MINUTES = int(os.getenv("REMINDER_SCHEDULER_INTERVAL_MINUTES", "15")) - -scheduler = BackgroundScheduler(timezone="UTC") - - -def start_scheduler(): - if scheduler.running: - return - - scheduler.add_job( - check_unsolved_users, - "interval", - minutes=SCHEDULER_INTERVAL_MINUTES, - id="enqueue_due_reminder_checks", - replace_existing=True, - coalesce=True, - max_instances=1, - ) - scheduler.start() diff --git a/backend/services/scheduler_service.py b/backend/services/scheduler_service.py new file mode 100644 index 0000000..623d3f2 --- /dev/null +++ b/backend/services/scheduler_service.py @@ -0,0 +1,13 @@ +from apscheduler.schedulers.background import BackgroundScheduler +from pytz import timezone + +from services.progress_service import check_unsolved_users + +scheduler = BackgroundScheduler() + +# Check daily at 11:00 PM IST +scheduler.add_job(check_unsolved_users, "cron", hour=23, minute=0, timezone=timezone("Asia/Kolkata")) + +def start_scheduler(): + if not scheduler.running: + scheduler.start() diff --git a/backend/alerts/twilio_service.py b/backend/services/twilio_service.py similarity index 100% rename from backend/alerts/twilio_service.py rename to backend/services/twilio_service.py diff --git a/backend/services/voice_service.py b/backend/services/voice_service.py deleted file mode 100644 index 96f1730..0000000 --- a/backend/services/voice_service.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -from elevenlabs.client import ElevenLabs - -client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY")) - - -def generate_voice_message(text: str): - audio = client.text_to_speech.convert(voice_id="EXAVITQu4vr4xnSDxMaL", text=text) - - return audio diff --git a/backend/tasks/reminder_tasks.py b/backend/tasks/reminder_tasks.py index 3c0464a..4ee4ddd 100644 --- a/backend/tasks/reminder_tasks.py +++ b/backend/tasks/reminder_tasks.py @@ -1,7 +1,7 @@ import asyncio -from alerts.progress_checker import check_user_progress_and_alert from celery_app import celery_app +from services.progress_service import check_user_progress_and_alert @celery_app.task( diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index ee15b7f..c92bbdc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -106,11 +106,10 @@ def _matches(record, query): class FakeProblemInfoCollection: - def __init__(self): + def __init__(self) -> None: self.find_one = AsyncMock(return_value=None) self.update_one = AsyncMock() - - + self.count_documents = AsyncMock(return_value=0) class FakeDatabase: def __init__(self) -> None: self.preferences = FakeCollection() @@ -161,10 +160,10 @@ def app_module(monkeypatch: pytest.MonkeyPatch): for module_name in [ "main", - "alerts.scheduler", - "alerts.progress_checker", - "alerts.elevenlabs_service", - "services.reminder_scheduler", + "services.scheduler_service", + "services.progress_service", + "services.twilio_service", + "services.elevenlabs_service", ]: sys.modules.pop(module_name, None) @@ -237,15 +236,16 @@ def mock_gemini_client(mocker): @pytest.fixture def mock_devto_request(mocker): - devto_module = importlib.import_module("devto") response = Mock(name="devto_response") response.status_code = 201 response.json.return_value = {"id": 123, "url": "https://dev.to/mock-post"} - request_mock = mocker.patch.object( - devto_module.requests, - "post", - autospec=True, - return_value=response, + + async def fake_post(*args, **kwargs): + return response + + request_mock = mocker.patch( + "httpx.AsyncClient.post", + side_effect=fake_post, ) return {"request": request_mock, "response": response} diff --git a/backend/tests/test_devto.py b/backend/tests/test_devto.py index 8c1dd7e..e51ee18 100644 --- a/backend/tests/test_devto.py +++ b/backend/tests/test_devto.py @@ -14,33 +14,33 @@ class TestPostToPlatform: - def test_successful_publish_returns_dict(self, mock_devto_request): + async def test_successful_publish_returns_dict(self, mock_devto_request): """Successful publish returns parsed JSON dict.""" from devto import post_to_platform - result = post_to_platform("Two Sum", "# Blog content") + result = await post_to_platform("Two Sum", "# Blog content") assert isinstance(result, dict) assert result["id"] == 123 - def test_post_sends_correct_title(self, mock_devto_request): + async def test_post_sends_correct_title(self, mock_devto_request): """The title is included in the request body.""" from devto import post_to_platform - post_to_platform("Two Sum", "# Blog content") + await post_to_platform("Two Sum", "# Blog content") call_kwargs = mock_devto_request["request"].call_args[1] assert call_kwargs["json"]["article"]["title"] == "LeetCode Solution: Two Sum" - def test_post_sends_correct_content(self, mock_devto_request): + async def test_post_sends_correct_content(self, mock_devto_request): """The markdown content is included in the request body.""" from devto import post_to_platform - post_to_platform("Two Sum", "# Blog content here") + await post_to_platform("Two Sum", "# Blog content here") call_kwargs = mock_devto_request["request"].call_args[1] assert ( "# Blog content here" in (call_kwargs["json"]["article"]["body_markdown"]) ) - def test_devto_api_error_raises(self, mock_devto_request): + async def test_devto_api_error_raises(self, mock_devto_request): """Non-2xx response raises an exception.""" from devto import post_to_platform @@ -48,7 +48,7 @@ def test_devto_api_error_raises(self, mock_devto_request): mock_devto_request["response"].text = "Internal Server Error" with pytest.raises(Exception): - post_to_platform("Two Sum", "# Blog content") + await post_to_platform("Two Sum", "# Blog content") class TestNormalizePlatforms: diff --git a/backend/tests/test_reminder_scheduler.py b/backend/tests/test_reminder_scheduler.py index ea976cb..9d16d34 100644 --- a/backend/tests/test_reminder_scheduler.py +++ b/backend/tests/test_reminder_scheduler.py @@ -4,7 +4,7 @@ def test_due_timezones_includes_local_11pm_zone(): - from alerts.progress_checker import due_timezones + from services.progress_service import due_timezones zones = due_timezones(datetime(2026, 1, 1, 17, 30, tzinfo=timezone.utc)) @@ -13,7 +13,7 @@ def test_due_timezones_includes_local_11pm_zone(): @pytest.mark.asyncio async def test_find_due_reminder_users_filters_by_timezone(app_module): - from alerts import progress_checker + from services import progress_service app_module.db.preferences.records.extend( [ @@ -31,9 +31,9 @@ async def test_find_due_reminder_users_filters_by_timezone(app_module): }, ] ) - progress_checker.db = app_module.db + progress_service.db = app_module.db - users = await progress_checker.find_due_reminder_users( + users = await progress_service.find_due_reminder_users( datetime(2026, 1, 1, 17, 30, tzinfo=timezone.utc) ) @@ -42,7 +42,7 @@ async def test_find_due_reminder_users_filters_by_timezone(app_module): @pytest.mark.asyncio async def test_enqueue_due_reminders_dedupes_jobs(app_module, mocker): - from alerts import progress_checker + from services import progress_service app_module.db.preferences.records.append( { @@ -52,7 +52,7 @@ async def test_enqueue_due_reminders_dedupes_jobs(app_module, mocker): "whatsapp_number": "+911234567890", } ) - progress_checker.db = app_module.db + progress_service.db = app_module.db task = mocker.patch( "tasks.reminder_tasks.check_user_progress_and_alert_task.delay", @@ -60,8 +60,8 @@ async def test_enqueue_due_reminders_dedupes_jobs(app_module, mocker): ) now = datetime(2026, 1, 1, 17, 30, tzinfo=timezone.utc) - first = await progress_checker.enqueue_due_reminders(now) - second = await progress_checker.enqueue_due_reminders(now) + first = await progress_service.enqueue_due_reminders(now) + second = await progress_service.enqueue_due_reminders(now) assert first["queued"] == 1 assert second["queued"] == 0 diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 8d17b4b..673d394 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -165,7 +165,7 @@ def test_generate_blog_receives_difficulty( "author": "testuser", "difficulty": "Easy", } - client.post("/generate-blog", json=payload) + client.post("/generate-blog", json=payload, headers=TEST_HEADERS) problem = mock_generate_blog.call_args.args[0] assert problem.difficulty == "Easy" diff --git a/backend/utils/progress_checker.py b/backend/utils/progress_checker.py deleted file mode 100644 index 8ddaee8..0000000 --- a/backend/utils/progress_checker.py +++ /dev/null @@ -1,7 +0,0 @@ -def has_completed_daily_problem(user_id: str): - """ - Temporary mock implementation. - Later this can connect to database/user activity. - """ - - return False diff --git a/extension/background.js b/extension/background.js index f8b5c89..642ee24 100644 --- a/extension/background.js +++ b/extension/background.js @@ -53,7 +53,21 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (data.status === 'success' || data.status === 'partial_success') { const platforms = data.data?.platforms || []; const postedPlatforms = platforms - .filter(r => r.status === 'success').map(r => r.platform); + .filter(result => result.status === 'success') + .map(result => result.platform) + .join(', '); + const devtoResult = platforms.find(r => r.platform === 'devto' && r.status === 'success'); + chrome.storage.local.get({ publishHistory: [] }, (res) => { + const entry = { + title: title, + url: devtoResult?.url || null, + publishedAt: client_time || new Date().toISOString(), + platforms: postedPlatforms ? postedPlatforms.split(', ').filter(p => p) : [] + }; + const history = res.publishHistory; + history.unshift(entry); + chrome.storage.local.set({ publishHistory: history.slice(0, 10) }); + }); const failedPlatforms = platforms .filter(r => r.status === 'error').map(r => r.platform); diff --git a/extension/popup.html b/extension/popup.html index e11dec4..b531761 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -151,7 +151,25 @@ margin-top: 10px; padding-top: 10px; } - /* ── Email setup screen ── */ + .history-item { + display: block; + padding: 7px 8px; + margin-bottom: 5px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + color: var(--text); + text-decoration: none; + background: #fafafa; + transition: background 0.15s; + cursor: pointer; + } + .history-item:hover { background: #fff3d6; border-color: var(--primary); } + .history-item-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .history-item-meta { font-size: 10px; color: var(--text-small); margin-top: 2px; } + .history-empty { font-size: 12px; color: var(--text-small); padding: 8px 0; text-align: center; } + + /* ── Email setup screen ── */ #emailSetup { display: none; flex-direction: column; @@ -315,7 +333,11 @@

Ready to blog! 🚀
- +
+ 📋 Recent Posts +
+
+ diff --git a/extension/popup.js b/extension/popup.js index d1dca16..752beac 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -249,12 +249,38 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById("exportSection").style.display = "block"; document.getElementById("previewSection").style.display = "block"; document.getElementById("blogEditor").value = generatedBlog; + statusEl.innerText = "Publishing automation active"; } else { statusEl.innerText = "Ready to generate blog"; statusEl.className = ""; } } ); + + // Load and render recent posts history + chrome.storage.local.get({ publishHistory: [] }, ({ publishHistory }) => { + const listEl = document.getElementById('historyList'); + if (!listEl) return; + if (!publishHistory.length) { + listEl.innerHTML = '
No posts yet. Generate your first blog! ✍️
'; + return; + } + listEl.innerHTML = publishHistory.map(entry => { + const date = new Date(entry.publishedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); + const platforms = (entry.platforms || []).join(', ') || 'unknown'; + return `
+
${entry.title}
+
${date} · ${platforms}
+
`; + }).join(''); + + listEl.querySelectorAll('.history-item').forEach(item => { + item.addEventListener('click', () => { + const url = item.dataset.url; + if (url) chrome.tabs.create({ url }); + }); + }); + }); }); // Generate button