openwriter 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/index-BLte65kx.js +209 -0
- package/dist/client/assets/index-BQTpvuwO.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/plugins/authors-voice/dist/index.d.ts +41 -0
- package/dist/plugins/authors-voice/dist/index.js +204 -0
- package/dist/plugins/authors-voice/package.json +23 -0
- package/dist/plugins/image-gen/dist/index.d.ts +35 -0
- package/dist/plugins/image-gen/dist/index.js +88 -0
- package/dist/plugins/image-gen/package.json +26 -0
- package/dist/plugins/publish/dist/helpers.d.ts +54 -0
- package/dist/plugins/publish/dist/helpers.js +185 -0
- package/dist/plugins/publish/dist/index.d.ts +3 -0
- package/dist/plugins/publish/dist/index.js +697 -0
- package/dist/plugins/publish/dist/newsletter-tools.d.ts +2 -0
- package/dist/plugins/publish/dist/newsletter-tools.js +364 -0
- package/dist/plugins/publish/package.json +31 -0
- package/dist/plugins/x-api/dist/index.d.ts +27 -0
- package/dist/plugins/x-api/dist/index.js +217 -0
- package/dist/plugins/x-api/package.json +26 -0
- package/dist/server/documents.js +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/plugin-discovery.js +35 -8
- package/dist/server/plugin-manager.js +2 -2
- package/dist/server/scheduler-routes.js +191 -0
- package/package.json +3 -2
- package/skill/SKILL.md +3 -2
- package/dist/client/assets/index-cxT2LD1Q.js +0 -209
- package/dist/client/assets/index-rHyhyRQQ.css +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Generation plugin for OpenWriter.
|
|
3
|
+
* Right-click an empty paragraph → "Generate image" → AI creates an image inline.
|
|
4
|
+
* Uses Google Gemini (Nano Banana 2) for generation, saves to /_images/.
|
|
5
|
+
*/
|
|
6
|
+
import type { Express } from 'express';
|
|
7
|
+
interface PluginConfigField {
|
|
8
|
+
type: 'string' | 'number' | 'boolean';
|
|
9
|
+
required?: boolean;
|
|
10
|
+
env?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
}
|
|
13
|
+
interface PluginRouteContext {
|
|
14
|
+
app: Express;
|
|
15
|
+
config: Record<string, string>;
|
|
16
|
+
dataDir: string;
|
|
17
|
+
}
|
|
18
|
+
interface PluginContextMenuItem {
|
|
19
|
+
label: string;
|
|
20
|
+
shortcut?: string;
|
|
21
|
+
action: string;
|
|
22
|
+
condition?: 'has-selection' | 'empty-node' | 'always';
|
|
23
|
+
promptForInput?: boolean;
|
|
24
|
+
}
|
|
25
|
+
interface OpenWriterPlugin {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
category?: 'writing' | 'social-media' | 'image-generation';
|
|
30
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
31
|
+
registerRoutes?(ctx: PluginRouteContext): void | Promise<void>;
|
|
32
|
+
contextMenuItems?(): PluginContextMenuItem[];
|
|
33
|
+
}
|
|
34
|
+
declare const plugin: OpenWriterPlugin;
|
|
35
|
+
export default plugin;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Generation plugin for OpenWriter.
|
|
3
|
+
* Right-click an empty paragraph → "Generate image" → AI creates an image inline.
|
|
4
|
+
* Uses Google Gemini (Nano Banana 2) for generation, saves to /_images/.
|
|
5
|
+
*/
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
const plugin = {
|
|
10
|
+
name: '@openwriter/plugin-image-gen',
|
|
11
|
+
version: '0.1.0',
|
|
12
|
+
description: 'Generate images with AI — right-click empty paragraphs',
|
|
13
|
+
category: 'image-generation',
|
|
14
|
+
configSchema: {
|
|
15
|
+
'gemini-api-key': {
|
|
16
|
+
type: 'string',
|
|
17
|
+
env: 'GEMINI_API_KEY',
|
|
18
|
+
required: true,
|
|
19
|
+
description: 'Google Gemini API key for image generation',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
registerRoutes(ctx) {
|
|
23
|
+
ctx.app.post('/api/image-gen/generate', async (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const { prompt } = req.body;
|
|
26
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
27
|
+
res.status(400).json({ success: false, error: 'prompt is required' });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (prompt.length > 1000) {
|
|
31
|
+
res.status(400).json({ success: false, error: 'prompt must be under 1000 characters' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const apiKey = ctx.config['gemini-api-key'] || process.env.GEMINI_API_KEY || '';
|
|
35
|
+
if (!apiKey) {
|
|
36
|
+
res.status(400).json({ success: false, error: 'GEMINI_API_KEY not configured' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Dynamic import — @google/genai is ESM
|
|
40
|
+
const { GoogleGenAI } = await import('@google/genai');
|
|
41
|
+
const ai = new GoogleGenAI({ apiKey });
|
|
42
|
+
console.log(`[ImageGen] Generating image: "${prompt.slice(0, 80)}..."`);
|
|
43
|
+
const response = await ai.models.generateContent({
|
|
44
|
+
model: 'gemini-3.1-flash-image-preview',
|
|
45
|
+
contents: `Generate a 16:9 aspect ratio image: ${prompt}`,
|
|
46
|
+
config: {
|
|
47
|
+
responseModalities: ['IMAGE'],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
51
|
+
if (!parts || parts.length === 0) {
|
|
52
|
+
res.status(422).json({ success: false, error: 'No image generated — content may have been filtered' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const imagePart = parts.find((p) => p.inlineData);
|
|
56
|
+
const imageBytes = imagePart?.inlineData?.data;
|
|
57
|
+
if (!imageBytes) {
|
|
58
|
+
res.status(422).json({ success: false, error: 'No image data in response' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Save to dataDir/_images/
|
|
62
|
+
const imagesDir = join(ctx.dataDir, '_images');
|
|
63
|
+
if (!existsSync(imagesDir))
|
|
64
|
+
mkdirSync(imagesDir, { recursive: true });
|
|
65
|
+
const filename = `${randomUUID().slice(0, 8)}.png`;
|
|
66
|
+
const filepath = join(imagesDir, filename);
|
|
67
|
+
writeFileSync(filepath, Buffer.from(imageBytes, 'base64'));
|
|
68
|
+
console.log(`[ImageGen] Saved: ${filepath}`);
|
|
69
|
+
res.json({ success: true, src: `/_images/${filename}` });
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error('[ImageGen] Generation failed:', err?.message || err);
|
|
73
|
+
res.status(500).json({ success: false, error: err?.message || 'Image generation failed' });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
contextMenuItems() {
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
label: 'Generate image',
|
|
81
|
+
action: 'img:generate',
|
|
82
|
+
condition: 'always',
|
|
83
|
+
promptForInput: true,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
export default plugin;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwriter/plugin-image-gen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate images with AI — right-click empty paragraphs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@google/genai": "^1.42.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/express": "^5.0.0",
|
|
16
|
+
"typescript": "^5.6.0"
|
|
17
|
+
},
|
|
18
|
+
"openwriter": {
|
|
19
|
+
"displayName": "Image Generator",
|
|
20
|
+
"category": "image-generation"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist/",
|
|
24
|
+
"package.json"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
export interface ServerModules {
|
|
3
|
+
tiptapToMarkdown: (doc: any, title: string, metadata?: Record<string, any>) => string;
|
|
4
|
+
getDocument: () => any;
|
|
5
|
+
getTitle: () => string;
|
|
6
|
+
getMetadata: () => Record<string, any>;
|
|
7
|
+
getActiveProfile: () => string;
|
|
8
|
+
getDataDir: () => string;
|
|
9
|
+
getDocId: () => string;
|
|
10
|
+
platformFetch: (path: string, options?: RequestInit) => Promise<Response>;
|
|
11
|
+
}
|
|
12
|
+
export declare function getServerModules(): Promise<ServerModules>;
|
|
13
|
+
export interface PluginConfigField {
|
|
14
|
+
type: 'string' | 'number' | 'boolean';
|
|
15
|
+
required?: boolean;
|
|
16
|
+
env?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PluginMcpTool {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
inputSchema: Record<string, unknown>;
|
|
23
|
+
handler: (params: Record<string, unknown>) => Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export interface OpenWriterPlugin {
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
category?: 'writing' | 'social-media' | 'image-generation' | 'publishing' | 'productivity' | 'analytics';
|
|
30
|
+
configSchema?: Record<string, PluginConfigField>;
|
|
31
|
+
mcpTools?(config: Record<string, string>): PluginMcpTool[];
|
|
32
|
+
}
|
|
33
|
+
export declare const md: MarkdownIt;
|
|
34
|
+
/** Strip YAML frontmatter and TipTap empty markers from markdown output */
|
|
35
|
+
export declare function stripFrontmatter(markdown: string): string;
|
|
36
|
+
/** Scan HTML for /_images/ references, read local files, return base64 array for R2 upload */
|
|
37
|
+
export declare function extractLocalImages(html: string): Promise<Array<{
|
|
38
|
+
path: string;
|
|
39
|
+
data: string;
|
|
40
|
+
content_type: string;
|
|
41
|
+
}>>;
|
|
42
|
+
/** Convert current document's TipTap JSON to body HTML + plain text */
|
|
43
|
+
export declare function documentToEmail(): Promise<{
|
|
44
|
+
html: string;
|
|
45
|
+
text: string;
|
|
46
|
+
subject: string;
|
|
47
|
+
json: any;
|
|
48
|
+
}>;
|
|
49
|
+
/** Strip markdown syntax to produce clean plain text for email */
|
|
50
|
+
export declare function markdownToPlainText(markdown: string): string;
|
|
51
|
+
/** Strip inline markdown marks from a string */
|
|
52
|
+
export declare function stripInline(text: string): string;
|
|
53
|
+
/** Make an authenticated request to the Publish API via platform proxy */
|
|
54
|
+
export declare function publishFetch(_config: Record<string, string>, path: string, options?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import markdownItIns from 'markdown-it-ins';
|
|
3
|
+
import markdownItMark from 'markdown-it-mark';
|
|
4
|
+
import markdownItSub from 'markdown-it-sub';
|
|
5
|
+
import markdownItSup from 'markdown-it-sup';
|
|
6
|
+
import { readFileSync, existsSync } from 'fs';
|
|
7
|
+
import { join, extname } from 'path';
|
|
8
|
+
// Lazy-load server modules at runtime (same process, resolved from monorepo root)
|
|
9
|
+
const baseDir = new URL('../../../packages/openwriter/dist/server/', import.meta.url).href;
|
|
10
|
+
let _cached = null;
|
|
11
|
+
export async function getServerModules() {
|
|
12
|
+
if (_cached)
|
|
13
|
+
return _cached;
|
|
14
|
+
const [markdown, state, helpers, connections] = await Promise.all([
|
|
15
|
+
import(baseDir + 'markdown.js'),
|
|
16
|
+
import(baseDir + 'state.js'),
|
|
17
|
+
import(baseDir + 'helpers.js'),
|
|
18
|
+
import(baseDir + 'connections.js'),
|
|
19
|
+
]);
|
|
20
|
+
_cached = {
|
|
21
|
+
tiptapToMarkdown: markdown.tiptapToMarkdown,
|
|
22
|
+
getDocument: state.getDocument,
|
|
23
|
+
getTitle: state.getTitle,
|
|
24
|
+
getMetadata: state.getMetadata,
|
|
25
|
+
getActiveProfile: helpers.getActiveProfile,
|
|
26
|
+
getDataDir: helpers.getDataDir,
|
|
27
|
+
getDocId: state.getDocId,
|
|
28
|
+
platformFetch: connections.platformFetch,
|
|
29
|
+
};
|
|
30
|
+
return _cached;
|
|
31
|
+
}
|
|
32
|
+
// markdown-it instance matching export-routes.ts configuration
|
|
33
|
+
export const md = new MarkdownIt({ linkify: false, html: true });
|
|
34
|
+
md.enable('strikethrough');
|
|
35
|
+
md.use(markdownItIns);
|
|
36
|
+
md.use(markdownItMark);
|
|
37
|
+
md.use(markdownItSub);
|
|
38
|
+
md.use(markdownItSup);
|
|
39
|
+
/** Strip YAML frontmatter and TipTap empty markers from markdown output */
|
|
40
|
+
export function stripFrontmatter(markdown) {
|
|
41
|
+
let result = markdown;
|
|
42
|
+
const fmMatch = result.match(/^---\n[\s\S]*?\n---\n\n/);
|
|
43
|
+
if (fmMatch)
|
|
44
|
+
result = result.slice(fmMatch[0].length);
|
|
45
|
+
result = result.replace(/^\s*<!--\s*-->\s*$/gm, '');
|
|
46
|
+
return result.trim();
|
|
47
|
+
}
|
|
48
|
+
/** Scan HTML for /_images/ references, read local files, return base64 array for R2 upload */
|
|
49
|
+
export async function extractLocalImages(html) {
|
|
50
|
+
const server = await getServerModules();
|
|
51
|
+
const dataDir = server.getDataDir();
|
|
52
|
+
const images = [];
|
|
53
|
+
const regex = /\/_images\/[^\s"'<>]+/g;
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
let match;
|
|
56
|
+
while ((match = regex.exec(html)) !== null) {
|
|
57
|
+
const imgPath = match[0];
|
|
58
|
+
if (seen.has(imgPath))
|
|
59
|
+
continue;
|
|
60
|
+
seen.add(imgPath);
|
|
61
|
+
const localFile = join(dataDir, imgPath);
|
|
62
|
+
if (!existsSync(localFile))
|
|
63
|
+
continue;
|
|
64
|
+
const data = readFileSync(localFile).toString('base64');
|
|
65
|
+
const ext = extname(imgPath).toLowerCase();
|
|
66
|
+
const mimeMap = {
|
|
67
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
68
|
+
'.png': 'image/png', '.gif': 'image/gif',
|
|
69
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
70
|
+
};
|
|
71
|
+
images.push({ path: imgPath, data, content_type: mimeMap[ext] || 'image/png' });
|
|
72
|
+
}
|
|
73
|
+
return images;
|
|
74
|
+
}
|
|
75
|
+
/** Convert current document's TipTap JSON to body HTML + plain text */
|
|
76
|
+
export async function documentToEmail() {
|
|
77
|
+
const server = await getServerModules();
|
|
78
|
+
const doc = server.getDocument();
|
|
79
|
+
const title = server.getTitle();
|
|
80
|
+
const metadata = server.getMetadata();
|
|
81
|
+
const raw = server.tiptapToMarkdown(doc, title, metadata);
|
|
82
|
+
const clean = stripFrontmatter(raw).trim();
|
|
83
|
+
const html = md.render(clean);
|
|
84
|
+
const text = markdownToPlainText(clean);
|
|
85
|
+
return { html, text, subject: title, json: doc };
|
|
86
|
+
}
|
|
87
|
+
/** Strip markdown syntax to produce clean plain text for email */
|
|
88
|
+
export function markdownToPlainText(markdown) {
|
|
89
|
+
const lines = markdown.split('\n');
|
|
90
|
+
const out = [];
|
|
91
|
+
let inCodeBlock = false;
|
|
92
|
+
let codeLines = [];
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (/^```/.test(line)) {
|
|
95
|
+
if (inCodeBlock) {
|
|
96
|
+
for (const cl of codeLines)
|
|
97
|
+
out.push(' ' + cl);
|
|
98
|
+
codeLines = [];
|
|
99
|
+
inCodeBlock = false;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
inCodeBlock = true;
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (inCodeBlock) {
|
|
107
|
+
codeLines.push(line);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (/^\s*<!--.*-->\s*$/.test(line))
|
|
111
|
+
continue;
|
|
112
|
+
if (/^!\[.*\]\(.*\)\s*$/.test(line))
|
|
113
|
+
continue;
|
|
114
|
+
if (/^\|[\s:|-]+\|\s*$/.test(line))
|
|
115
|
+
continue;
|
|
116
|
+
if (/^\|(.+)\|\s*$/.test(line)) {
|
|
117
|
+
const cells = line
|
|
118
|
+
.slice(1, -1)
|
|
119
|
+
.split('|')
|
|
120
|
+
.map((c) => stripInline(c.trim()));
|
|
121
|
+
out.push(cells.join(' | '));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (/^[-*_]{3,}\s*$/.test(line)) {
|
|
125
|
+
out.push('---');
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const headerMatch = line.match(/^(#{1,6})\s+(.*)/);
|
|
129
|
+
if (headerMatch) {
|
|
130
|
+
out.push(stripInline(headerMatch[2]));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const bqMatch = line.match(/^(>\s?)+(.*)$/);
|
|
134
|
+
if (bqMatch) {
|
|
135
|
+
out.push(stripInline(bqMatch[2]));
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const taskMatch = line.match(/^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)/);
|
|
139
|
+
if (taskMatch) {
|
|
140
|
+
const indent = taskMatch[1];
|
|
141
|
+
const check = taskMatch[2] === ' ' ? '[ ]' : '[x]';
|
|
142
|
+
out.push(indent + check + ' ' + stripInline(taskMatch[3]));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const ulMatch = line.match(/^(\s*)[-*+]\s+(.*)/);
|
|
146
|
+
if (ulMatch) {
|
|
147
|
+
out.push(ulMatch[1] + '- ' + stripInline(ulMatch[2]));
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)/);
|
|
151
|
+
if (olMatch) {
|
|
152
|
+
out.push(olMatch[1] + olMatch[2] + '. ' + stripInline(olMatch[3]));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
out.push(stripInline(line));
|
|
156
|
+
}
|
|
157
|
+
if (inCodeBlock) {
|
|
158
|
+
for (const cl of codeLines)
|
|
159
|
+
out.push(' ' + cl);
|
|
160
|
+
}
|
|
161
|
+
return out
|
|
162
|
+
.join('\n')
|
|
163
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
164
|
+
.trim();
|
|
165
|
+
}
|
|
166
|
+
/** Strip inline markdown marks from a string */
|
|
167
|
+
export function stripInline(text) {
|
|
168
|
+
return text
|
|
169
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
|
170
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
|
|
171
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
172
|
+
.replace(/(\*{3}|_{3})(.+?)\1/g, '$2')
|
|
173
|
+
.replace(/(\*{2}|_{2})(.+?)\1/g, '$2')
|
|
174
|
+
.replace(/(\*|_)(.+?)\1/g, '$2')
|
|
175
|
+
.replace(/~~(.+?)~~/g, '$1')
|
|
176
|
+
.replace(/\+\+(.+?)\+\+/g, '$1')
|
|
177
|
+
.replace(/==(.+?)==/g, '$1')
|
|
178
|
+
.replace(/~([^~]+)~/g, '$1')
|
|
179
|
+
.replace(/\^([^^]+)\^/g, '$1');
|
|
180
|
+
}
|
|
181
|
+
/** Make an authenticated request to the Publish API via platform proxy */
|
|
182
|
+
export async function publishFetch(_config, path, options = {}) {
|
|
183
|
+
const server = await getServerModules();
|
|
184
|
+
return server.platformFetch(path, options);
|
|
185
|
+
}
|