agent-reader 1.0.0

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.
@@ -0,0 +1,85 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const DEFAULT_ROOT = path.join(os.tmpdir(), 'agent-reader');
7
+
8
+ function sanitizeName(name) {
9
+ const value = String(name || 'output').trim();
10
+ return value.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'output';
11
+ }
12
+
13
+ async function ensureDir(dirPath) {
14
+ await fs.mkdir(dirPath, { recursive: true });
15
+ }
16
+
17
+ export function getOutputRoot(outDir) {
18
+ return outDir ? path.resolve(outDir) : DEFAULT_ROOT;
19
+ }
20
+
21
+ export async function createOutputDir(name = 'output', outDir) {
22
+ const rootDir = getOutputRoot(outDir);
23
+ await ensureDir(rootDir);
24
+
25
+ const shortId = randomUUID().slice(0, 8);
26
+ const dirName = `${sanitizeName(path.parse(name).name)}-${shortId}`;
27
+ const finalDir = path.join(rootDir, dirName);
28
+ await ensureDir(finalDir);
29
+
30
+ return finalDir;
31
+ }
32
+
33
+ async function getDirSize(targetPath) {
34
+ const stat = await fs.stat(targetPath);
35
+ if (!stat.isDirectory()) {
36
+ return stat.size;
37
+ }
38
+
39
+ let total = 0;
40
+ const entries = await fs.readdir(targetPath, { withFileTypes: true });
41
+ for (const entry of entries) {
42
+ const entryPath = path.join(targetPath, entry.name);
43
+ if (entry.isDirectory()) {
44
+ total += await getDirSize(entryPath);
45
+ } else if (entry.isFile()) {
46
+ const fileStat = await fs.stat(entryPath);
47
+ total += fileStat.size;
48
+ }
49
+ }
50
+
51
+ return total;
52
+ }
53
+
54
+ export async function cleanOldOutputs(maxAgeDays = 7, outDir) {
55
+ const rootDir = getOutputRoot(outDir);
56
+ await ensureDir(rootDir);
57
+
58
+ const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
59
+ const entries = await fs.readdir(rootDir, { withFileTypes: true });
60
+
61
+ let deletedCount = 0;
62
+ let reclaimedBytes = 0;
63
+
64
+ for (const entry of entries) {
65
+ if (!entry.isDirectory()) {
66
+ continue;
67
+ }
68
+
69
+ const fullPath = path.join(rootDir, entry.name);
70
+ const stat = await fs.stat(fullPath);
71
+ if (stat.mtimeMs > cutoffMs) {
72
+ continue;
73
+ }
74
+
75
+ reclaimedBytes += await getDirSize(fullPath);
76
+ await fs.rm(fullPath, { recursive: true, force: true });
77
+ deletedCount += 1;
78
+ }
79
+
80
+ return {
81
+ deletedCount,
82
+ reclaimedBytes,
83
+ rootDir,
84
+ };
85
+ }
@@ -0,0 +1,89 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const CONFIG_DIR = path.join(os.homedir(), '.agent-reader');
6
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'preferences.json');
7
+
8
+ const MODE_ALIASES = {
9
+ auto: 'auto',
10
+ web: 'web',
11
+ html: 'web',
12
+ browser: 'web',
13
+ word: 'word',
14
+ docx: 'word',
15
+ pdf: 'pdf',
16
+ ppt: 'ppt',
17
+ slides: 'ppt',
18
+ slideshow: 'ppt',
19
+ presentation: 'ppt',
20
+ };
21
+
22
+ const DEFAULT_PREFERENCES = {
23
+ default_open_mode: 'web',
24
+ default_theme: 'light',
25
+ };
26
+
27
+ function sanitizeTheme(theme) {
28
+ const value = typeof theme === 'string' ? theme.trim() : '';
29
+ return value || DEFAULT_PREFERENCES.default_theme;
30
+ }
31
+
32
+ export function normalizeOpenMode(mode, fallback = 'auto') {
33
+ const key = String(mode || '').trim().toLowerCase();
34
+ if (!key) {
35
+ return fallback;
36
+ }
37
+ return MODE_ALIASES[key] || fallback;
38
+ }
39
+
40
+ export function getPreferencesPath() {
41
+ return CONFIG_PATH;
42
+ }
43
+
44
+ export async function ensurePreferencesDir() {
45
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
46
+ }
47
+
48
+ export function sanitizePreferences(input = {}) {
49
+ return {
50
+ default_open_mode: normalizeOpenMode(input.default_open_mode, DEFAULT_PREFERENCES.default_open_mode),
51
+ default_theme: sanitizeTheme(input.default_theme),
52
+ };
53
+ }
54
+
55
+ export async function loadPreferences() {
56
+ await ensurePreferencesDir();
57
+
58
+ let parsed = {};
59
+ try {
60
+ const raw = await fs.readFile(CONFIG_PATH, 'utf8');
61
+ parsed = JSON.parse(raw);
62
+ } catch {
63
+ parsed = {};
64
+ }
65
+
66
+ return {
67
+ ...DEFAULT_PREFERENCES,
68
+ ...sanitizePreferences(parsed),
69
+ };
70
+ }
71
+
72
+ export async function savePreferences(preferences) {
73
+ const finalPreferences = {
74
+ ...DEFAULT_PREFERENCES,
75
+ ...sanitizePreferences(preferences),
76
+ };
77
+
78
+ await ensurePreferencesDir();
79
+ await fs.writeFile(CONFIG_PATH, `${JSON.stringify(finalPreferences, null, 2)}\n`, 'utf8');
80
+ return finalPreferences;
81
+ }
82
+
83
+ export async function updatePreferences(partial) {
84
+ const current = await loadPreferences();
85
+ return savePreferences({
86
+ ...current,
87
+ ...partial,
88
+ });
89
+ }
@@ -0,0 +1,295 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+ import { promises as fs } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { exportDOCXFromHTML, exportPDF } from '../core/exporter.js';
6
+
7
+ const MIME_TYPES = {
8
+ '.html': 'text/html; charset=utf-8',
9
+ '.css': 'text/css; charset=utf-8',
10
+ '.js': 'application/javascript; charset=utf-8',
11
+ '.json': 'application/json; charset=utf-8',
12
+ '.png': 'image/png',
13
+ '.jpg': 'image/jpeg',
14
+ '.jpeg': 'image/jpeg',
15
+ '.gif': 'image/gif',
16
+ '.svg': 'image/svg+xml',
17
+ '.webp': 'image/webp',
18
+ '.ico': 'image/x-icon',
19
+ '.pdf': 'application/pdf',
20
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
21
+ };
22
+
23
+ function getMimeType(filePath) {
24
+ const ext = path.extname(filePath).toLowerCase();
25
+ return MIME_TYPES[ext] || 'application/octet-stream';
26
+ }
27
+
28
+ function normalizePath(requestPath) {
29
+ const decoded = decodeURIComponent(requestPath);
30
+ return decoded.split('?')[0].split('#')[0];
31
+ }
32
+
33
+ function createHttpError(statusCode, message) {
34
+ const error = new Error(message);
35
+ error.statusCode = statusCode;
36
+ return error;
37
+ }
38
+
39
+ function isPathInside(rootDir, candidatePath) {
40
+ const normalizedRoot = rootDir.endsWith(path.sep) ? rootDir : `${rootDir}${path.sep}`;
41
+ return candidatePath === rootDir || candidatePath.startsWith(normalizedRoot);
42
+ }
43
+
44
+ async function resolvePathFromRequest(rootDir, requestPath) {
45
+ const relativePath = requestPath === '/' ? '/index.html' : requestPath;
46
+ const candidate = path.resolve(rootDir, `.${relativePath}`);
47
+
48
+ if (!isPathInside(rootDir, candidate)) {
49
+ throw createHttpError(403, 'Forbidden');
50
+ }
51
+
52
+ const stat = await fs.stat(candidate).catch(() => null);
53
+ if (!stat) {
54
+ throw createHttpError(404, 'Not Found');
55
+ }
56
+
57
+ if (stat.isDirectory()) {
58
+ const indexPath = path.join(candidate, 'index.html');
59
+ const indexStat = await fs.stat(indexPath).catch(() => null);
60
+ if (!indexStat) {
61
+ throw createHttpError(404, 'Not Found');
62
+ }
63
+ return indexPath;
64
+ }
65
+
66
+ return candidate;
67
+ }
68
+
69
+ function sendJson(res, statusCode, payload) {
70
+ res.writeHead(statusCode, {
71
+ 'Content-Type': 'application/json; charset=utf-8',
72
+ 'Cache-Control': 'no-store',
73
+ });
74
+ res.end(JSON.stringify(payload));
75
+ }
76
+
77
+ async function resolveExportSourcePath(req, rootDir, sourceParam) {
78
+ const fromQuery = sourceParam ? normalizePath(sourceParam) : null;
79
+
80
+ if (fromQuery) {
81
+ return resolvePathFromRequest(rootDir, fromQuery);
82
+ }
83
+
84
+ const referer = req.headers.referer;
85
+ if (typeof referer === 'string' && referer.length > 0) {
86
+ try {
87
+ const refererUrl = new URL(referer);
88
+ return resolvePathFromRequest(rootDir, normalizePath(refererUrl.pathname));
89
+ } catch {
90
+ throw createHttpError(400, 'invalid referer url');
91
+ }
92
+ }
93
+
94
+ throw createHttpError(400, 'missing source path');
95
+ }
96
+
97
+ async function handleExportRequest(req, res, rootDir, urlObject) {
98
+ if (req.method !== 'GET') {
99
+ sendJson(res, 405, { error: 'method not allowed' });
100
+ return;
101
+ }
102
+
103
+ const format = String(urlObject.searchParams.get('format') || '').toLowerCase();
104
+ if (!['pdf', 'docx'].includes(format)) {
105
+ sendJson(res, 400, { error: 'missing or invalid export format' });
106
+ return;
107
+ }
108
+
109
+ try {
110
+ const sourceParam = urlObject.searchParams.get('source');
111
+ const sourcePath = await resolveExportSourcePath(req, rootDir, sourceParam);
112
+ const sourceExt = path.extname(sourcePath).toLowerCase();
113
+ if (sourceExt !== '.html') {
114
+ throw createHttpError(400, 'source must be an html file');
115
+ }
116
+
117
+ const sourceHtml = await fs.readFile(sourcePath, 'utf8');
118
+ const sourceDir = path.dirname(sourcePath);
119
+ const rawName = path.parse(sourcePath).name || 'document';
120
+ const dirBaseName = path.basename(sourceDir) || 'document';
121
+ const cleanDirName = dirBaseName.replace(/-[a-f0-9]{8}$/, '') || dirBaseName;
122
+ const sourceName = rawName.startsWith('_') ? cleanDirName : rawName;
123
+
124
+ let outputPath;
125
+ let warnings = [];
126
+
127
+ if (format === 'pdf') {
128
+ const isLandscape = sourceHtml.includes('size: A4 landscape') || sourceHtml.includes('size:A4 landscape');
129
+ const result = await exportPDF(sourceHtml, {
130
+ outDir: sourceDir,
131
+ fileName: `${sourceName}.pdf`,
132
+ htmlPath: sourcePath,
133
+ landscape: isLandscape,
134
+ });
135
+ outputPath = result.pdfPath;
136
+ warnings = result.warnings || [];
137
+ } else {
138
+ const result = await exportDOCXFromHTML(sourceHtml, {
139
+ htmlPath: sourcePath,
140
+ baseDir: sourceDir,
141
+ outDir: sourceDir,
142
+ fileName: `${sourceName}.docx`,
143
+ });
144
+ outputPath = result.docxPath;
145
+ warnings = result.warnings || [];
146
+ }
147
+
148
+ const output = await fs.readFile(outputPath);
149
+ const warningHeader = warnings.length ? warnings.join(' | ') : '';
150
+
151
+ res.writeHead(200, {
152
+ 'Content-Type': getMimeType(outputPath),
153
+ 'Content-Disposition': `attachment; filename="${path.basename(outputPath)}"`,
154
+ 'Cache-Control': 'no-store',
155
+ ...(warningHeader ? { 'X-Agent-Reader-Warnings': warningHeader } : {}),
156
+ });
157
+ res.end(output);
158
+ } catch (error) {
159
+ const statusCode = Number(error?.statusCode) || 500;
160
+ sendJson(res, statusCode, { error: error.message || 'export failed' });
161
+ }
162
+ }
163
+
164
+ async function isPortInUse(host, port) {
165
+ return new Promise((resolve) => {
166
+ const socket = new net.Socket();
167
+ let settled = false;
168
+
169
+ const finish = (value) => {
170
+ if (settled) {
171
+ return;
172
+ }
173
+ settled = true;
174
+ socket.destroy();
175
+ resolve(value);
176
+ };
177
+
178
+ socket.setTimeout(200);
179
+ socket.once('connect', () => finish(true));
180
+ socket.once('timeout', () => finish(false));
181
+ socket.once('error', (error) => {
182
+ if (error.code === 'ECONNREFUSED' || error.code === 'EHOSTUNREACH') {
183
+ finish(false);
184
+ return;
185
+ }
186
+ finish(true);
187
+ });
188
+
189
+ socket.connect(port, host);
190
+ });
191
+ }
192
+
193
+ export async function startStaticServer(rootDir, { host = '127.0.0.1', port = 3000 } = {}) {
194
+ const absoluteRoot = path.resolve(rootDir);
195
+
196
+ const server = http.createServer(async (req, res) => {
197
+ const urlObject = new URL(req.url || '/', `http://${host}:${port}`);
198
+ try {
199
+ if (urlObject.pathname === '/api/export') {
200
+ await handleExportRequest(req, res, absoluteRoot, urlObject);
201
+ return;
202
+ }
203
+
204
+ const reqPath = normalizePath(urlObject.pathname);
205
+ const target = await resolvePathFromRequest(absoluteRoot, reqPath);
206
+
207
+ const content = await fs.readFile(target);
208
+ res.writeHead(200, {
209
+ 'Content-Type': getMimeType(target),
210
+ 'Cache-Control': 'no-store',
211
+ });
212
+ res.end(content);
213
+ } catch (error) {
214
+ const statusCode = Number(error?.statusCode) || 404;
215
+ const message = statusCode === 403 ? 'Forbidden' : 'Not Found';
216
+ res.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
217
+ res.end(message);
218
+ }
219
+ });
220
+
221
+ const actualPort = await new Promise((resolve, reject) => {
222
+ const tryPort = async (nextPort) => {
223
+ const occupied = await isPortInUse(host, nextPort);
224
+ if (occupied) {
225
+ await tryPort(nextPort + 1);
226
+ return;
227
+ }
228
+
229
+ const onError = (error) => {
230
+ server.off('error', onError);
231
+ if (error.code === 'EADDRINUSE') {
232
+ void tryPort(nextPort + 1);
233
+ } else {
234
+ reject(error);
235
+ }
236
+ };
237
+
238
+ server.once('error', onError);
239
+ server.listen({ port: nextPort, host, exclusive: true }, () => {
240
+ server.off('error', onError);
241
+ resolve(nextPort);
242
+ });
243
+ };
244
+
245
+ void tryPort(port);
246
+ });
247
+
248
+ return {
249
+ host,
250
+ port: actualPort,
251
+ url: `http://${host}:${actualPort}`,
252
+ close: () =>
253
+ new Promise((resolve, reject) => {
254
+ server.close((error) => {
255
+ if (error) {
256
+ reject(error);
257
+ return;
258
+ }
259
+ resolve();
260
+ });
261
+ }),
262
+ server,
263
+ };
264
+ }
265
+
266
+ export async function waitForServerExit(serverHandle, timeoutMs = 10 * 60 * 1000) {
267
+ await new Promise((resolve) => {
268
+ let finished = false;
269
+
270
+ const complete = async () => {
271
+ if (finished) {
272
+ return;
273
+ }
274
+ finished = true;
275
+ await serverHandle.close().catch(() => {});
276
+ process.off('SIGINT', onSigInt);
277
+ process.off('SIGTERM', onSigTerm);
278
+ resolve();
279
+ };
280
+
281
+ const onSigInt = async () => {
282
+ await complete();
283
+ };
284
+ const onSigTerm = async () => {
285
+ await complete();
286
+ };
287
+
288
+ process.once('SIGINT', onSigInt);
289
+ process.once('SIGTERM', onSigTerm);
290
+
291
+ setTimeout(() => {
292
+ void complete();
293
+ }, timeoutMs).unref();
294
+ });
295
+ }