Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .opencode/todo.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
# Mission Tasks
# Mission Tasks for Issue #609 - Newsletters & Articles: make it production-grade

## Task List
## Phase 1: Support Rich Text and Images in Articles
- [ ] Investigate how LinkedIn rich text editor works (contenteditable vs specific DOM structure)
- [ ] Add support for cover image URL in `PrepareCreateArticleInput` and `PrepareCreateNewsletterInput`
- [ ] Add support for HTML/Markdown body parsing and inserting rich text

[ ] *Start your mission by creating a task list
## Phase 2: Newsletter Metadata Editing
- [ ] Create `PrepareUpdateNewsletterInput` interface
- [ ] Implement `prepareUpdate` in `LinkedInNewslettersService`
- [ ] Implement `UpdateNewsletterActionExecutor`
- [ ] Register tool in MCP `linkedin.newsletter.prepare_update`
- [ ] Add CLI command for updating newsletter

## Phase 3: Newsletter Editions List and Stats
- [ ] Update `list` method in `LinkedInNewslettersService` to fetch stats (subscribers, views)
- [ ] Add new interface `ListNewsletterEditionsInput` and `listEditions` method to list individual editions for a newsletter
- [ ] Register `linkedin.newsletter.list_editions` tool in MCP
- [ ] Add CLI command for listing editions

## Phase 4: Share Newsletter
- [ ] Investigate how sharing works for newsletters (Share button -> modal -> post)
- [ ] Implement `prepareShare` in `LinkedInNewslettersService`
- [ ] Implement `ShareNewsletterActionExecutor`
- [ ] Register MCP tool and CLI command

## Phase 5: Testing and Polish
- [ ] Add unit tests for new methods in `linkedinPublishing.test.ts`
- [ ] Run e2e tests
- [ ] Address edge cases: draft vs published, editing after publish, newsletter with zero editions
6 changes: 6 additions & 0 deletions packages/core/src/__tests__/e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ import {
LINKEDIN_NEWSLETTER_LIST_TOOL,
LINKEDIN_NEWSLETTER_PREPARE_CREATE_TOOL,
LINKEDIN_NEWSLETTER_PREPARE_PUBLISH_ISSUE_TOOL,
LINKEDIN_NEWSLETTER_LIST_EDITIONS_TOOL,
LINKEDIN_NEWSLETTER_PREPARE_SEND_TOOL,
LINKEDIN_NEWSLETTER_PREPARE_UPDATE_TOOL,
LINKEDIN_NOTIFICATIONS_DISMISS_TOOL,
LINKEDIN_NOTIFICATIONS_LIST_TOOL,
LINKEDIN_NOTIFICATIONS_MARK_READ_TOOL,
Expand Down Expand Up @@ -1079,6 +1082,9 @@ export const MCP_TOOL_NAMES = {
newsletterPrepareCreate: LINKEDIN_NEWSLETTER_PREPARE_CREATE_TOOL,
newsletterPreparePublishIssue:
LINKEDIN_NEWSLETTER_PREPARE_PUBLISH_ISSUE_TOOL,
newsletterListEditions: LINKEDIN_NEWSLETTER_LIST_EDITIONS_TOOL,
newsletterPrepareSend: LINKEDIN_NEWSLETTER_PREPARE_SEND_TOOL,
newsletterPrepareUpdate: LINKEDIN_NEWSLETTER_PREPARE_UPDATE_TOOL,
privacyGetSettings: LINKEDIN_PRIVACY_GET_SETTINGS_TOOL,
privacyPrepareUpdateSetting: LINKEDIN_PRIVACY_PREPARE_UPDATE_SETTING_TOOL,
followupsPrepareAfterAccept: LINKEDIN_NETWORK_PREPARE_FOLLOWUP_AFTER_ACCEPT_TOOL,
Expand Down
163 changes: 0 additions & 163 deletions packages/core/src/__tests__/e2eRunner.test.ts

This file was deleted.

4 changes: 4 additions & 0 deletions packages/core/src/__tests__/linkedinPublishing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ARTICLE_TITLE_MAX_LENGTH,
CREATE_ARTICLE_ACTION_TYPE,
CREATE_NEWSLETTER_ACTION_TYPE,
UPDATE_NEWSLETTER_ACTION_TYPE,
LINKEDIN_NEWSLETTER_CADENCE_TYPES,
LinkedInArticlesService,
LinkedInNewslettersService,
Expand All @@ -13,6 +14,7 @@ import {
NEWSLETTER_TITLE_MAX_LENGTH,
PUBLISH_ARTICLE_ACTION_TYPE,
PUBLISH_NEWSLETTER_ISSUE_ACTION_TYPE,
SEND_NEWSLETTER_ACTION_TYPE,
createPublishingActionExecutors,
} from "../linkedinPublishing.js";
import { createBlockedRateLimiterStub } from "./rateLimiterTestUtils.js";
Expand Down Expand Up @@ -79,7 +81,9 @@ describe("createPublishingActionExecutors", () => {
CREATE_ARTICLE_ACTION_TYPE,
PUBLISH_ARTICLE_ACTION_TYPE,
CREATE_NEWSLETTER_ACTION_TYPE,
UPDATE_NEWSLETTER_ACTION_TYPE,
PUBLISH_NEWSLETTER_ISSUE_ACTION_TYPE,
SEND_NEWSLETTER_ACTION_TYPE,
]);
expect(typeof executors[CREATE_ARTICLE_ACTION_TYPE]?.execute).toBe(
"function",
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/linkedinArticleEditorHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/* global document, InputEvent */
import type { Page } from 'playwright-core';

export interface TextFormatting {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
link?: string;
code?: boolean;
}

export interface Paragraph {
text: string;
formatting?: TextFormatting;
}

export interface ArticleData {
title: string;
coverImage?: string;
sections: Array<{
heading?: string;
content: string;
images?: string[];
}>;
tags?: string[];
description?: string;
}

export async function insertCoverImage(page: Page, imagePath: string): Promise<void> {
const fileInput = await page.$('input[type="file"][accept*="image"]');
if (!fileInput) {
throw new Error('Image upload input not found. Ensure you are on the article editor page.');
}
await fileInput.setInputFiles(imagePath);
await page.evaluate(() => {
const input = document.querySelector('input[type="file"][accept*="image"]');
if (input) {
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('input', { bubbles: true }));
}
});
await page.waitForTimeout(2000);
}

export async function insertInlineImage(page: Page, imageUrl: string, alt: string = 'Article image'): Promise<void> {
const editor = await page.$('[contenteditable="true"]:not([aria-label*="title"])');
if (!editor) {
throw new Error('Article content editor not found.');
}
try {
await page.evaluate(([url]) => {
document.execCommand('insertImage', false, url || "");
}, [imageUrl]);
} catch {
await page.evaluate(([url, altText]) => {
const editor = document.querySelector('[contenteditable="true"]:not([aria-label*="title"])');
if (editor) {
const img = document.createElement('img');
img.src = url || "";
img.alt = altText || "";
img.style.maxWidth = '100%';
img.style.height = 'auto';
editor.appendChild(img);
editor.dispatchEvent(new Event('input', { bubbles: true }));
}
}, [imageUrl, alt]);
}
}

export async function fillRichText(page: Page, htmlContent: string): Promise<void> {
await page.evaluate(([html]) => {
const editor = document.querySelector('[contenteditable="true"]:not([aria-label*="title"])');
if (editor) {
editor.innerHTML = html || "";
editor.dispatchEvent(new Event('input', { bubbles: true }));
editor.dispatchEvent(new Event('change', { bubbles: true }));
editor.dispatchEvent(new InputEvent('input', { bubbles: true }));
}
}, [htmlContent]);
}
Loading
Loading