@cyber-dash-tech/revela 0.3.0 → 0.4.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.
- package/README.md +200 -9
- package/README.zh-CN.md +162 -9
- package/designs/summit/DESIGN.md +54 -51
- package/lib/agents/research-prompt.ts +1 -1
- package/lib/document-materials/extract.ts +139 -2
- package/lib/media/batch-save.ts +146 -0
- package/lib/media/download.ts +68 -0
- package/lib/media/save.ts +273 -0
- package/lib/media/types.ts +54 -0
- package/lib/research/image-leads.ts +175 -0
- package/package.json +1 -1
- package/plugin.ts +6 -0
- package/skill/SKILL.md +7 -1
- package/tools/extract-document-materials.ts +2 -2
- package/tools/media-batch-save.ts +25 -0
- package/tools/media-save.ts +40 -0
- package/tools/research-images-list.ts +34 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { saveMediaAsset } from "./save"
|
|
2
|
+
import type { MediaPurpose, MediaSaveResult } from "./types"
|
|
3
|
+
|
|
4
|
+
export interface MediaBatchItem {
|
|
5
|
+
candidateId: string
|
|
6
|
+
description: string
|
|
7
|
+
url: string
|
|
8
|
+
alt?: string
|
|
9
|
+
use: "logo" | "portrait" | "screenshot"
|
|
10
|
+
sourceFile?: string
|
|
11
|
+
intendedSection?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MediaBatchSaveInput {
|
|
15
|
+
topic: string
|
|
16
|
+
items: MediaBatchItem[]
|
|
17
|
+
intendedSection?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MediaBatchSaveResultItem {
|
|
21
|
+
candidateId: string
|
|
22
|
+
assetId: string
|
|
23
|
+
status: string
|
|
24
|
+
path: string | null
|
|
25
|
+
error?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MediaBatchSaveResult {
|
|
29
|
+
ok: true
|
|
30
|
+
topic: string
|
|
31
|
+
manifestPath: string | null
|
|
32
|
+
saved: number
|
|
33
|
+
failed: number
|
|
34
|
+
results: MediaBatchSaveResultItem[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function candidateKey(candidateId: string): string {
|
|
38
|
+
return slugify(candidateId) || "item"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function slugify(value: string): string {
|
|
42
|
+
return value
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
45
|
+
.replace(/^-+|-+$/g, "")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function purposeForUse(use: MediaBatchItem["use"]): MediaPurpose {
|
|
49
|
+
return use
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function baseAssetId(item: MediaBatchItem): string {
|
|
53
|
+
const description = slugify(item.description)
|
|
54
|
+
const fallback = slugify(item.sourceFile ?? item.candidateId)
|
|
55
|
+
return description || fallback || "image"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildDeterministicAssetIds(items: MediaBatchItem[]): Map<string, string> {
|
|
59
|
+
const assetIds = new Map<string, string>()
|
|
60
|
+
for (const item of items) {
|
|
61
|
+
const base = baseAssetId(item)
|
|
62
|
+
assetIds.set(item.candidateId, `${base}-${candidateKey(item.candidateId)}`)
|
|
63
|
+
}
|
|
64
|
+
return assetIds
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function batchSaveMediaAssets(
|
|
68
|
+
input: MediaBatchSaveInput,
|
|
69
|
+
workspaceDir: string,
|
|
70
|
+
): Promise<MediaBatchSaveResult> {
|
|
71
|
+
const topic = slugify(input.topic)
|
|
72
|
+
const results: MediaBatchSaveResultItem[] = []
|
|
73
|
+
const urlToResult = new Map<string, MediaBatchSaveResultItem>()
|
|
74
|
+
const assetIds = buildDeterministicAssetIds(input.items)
|
|
75
|
+
let manifestPath: string | null = null
|
|
76
|
+
let saved = 0
|
|
77
|
+
let failed = 0
|
|
78
|
+
|
|
79
|
+
for (const item of input.items) {
|
|
80
|
+
const normalizedUrl = item.url.trim()
|
|
81
|
+
const existing = urlToResult.get(normalizedUrl)
|
|
82
|
+
if (existing) {
|
|
83
|
+
results.push({
|
|
84
|
+
candidateId: item.candidateId,
|
|
85
|
+
assetId: existing.assetId,
|
|
86
|
+
status: existing.status,
|
|
87
|
+
path: existing.path,
|
|
88
|
+
error: existing.error,
|
|
89
|
+
})
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const assetId = assetIds.get(item.candidateId) ?? `${baseAssetId(item)}-01`
|
|
94
|
+
const brief = item.sourceFile
|
|
95
|
+
? `${item.description} from ${item.sourceFile}`
|
|
96
|
+
: item.description
|
|
97
|
+
const saveResult: MediaSaveResult = await saveMediaAsset({
|
|
98
|
+
topic,
|
|
99
|
+
id: assetId,
|
|
100
|
+
type: "image",
|
|
101
|
+
purpose: purposeForUse(item.use),
|
|
102
|
+
brief,
|
|
103
|
+
status: "success",
|
|
104
|
+
intendedSection: item.intendedSection ?? input.intendedSection,
|
|
105
|
+
sourceUrl: normalizedUrl,
|
|
106
|
+
alt: item.alt,
|
|
107
|
+
}, workspaceDir)
|
|
108
|
+
|
|
109
|
+
const mapped: MediaBatchSaveResultItem = saveResult.ok
|
|
110
|
+
? {
|
|
111
|
+
candidateId: item.candidateId,
|
|
112
|
+
assetId,
|
|
113
|
+
status: saveResult.status,
|
|
114
|
+
path: saveResult.path,
|
|
115
|
+
}
|
|
116
|
+
: {
|
|
117
|
+
candidateId: item.candidateId,
|
|
118
|
+
assetId,
|
|
119
|
+
status: "error",
|
|
120
|
+
path: null,
|
|
121
|
+
error: saveResult.error,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (saveResult.ok) manifestPath = saveResult.manifestPath
|
|
125
|
+
if (mapped.path) saved += 1
|
|
126
|
+
else failed += 1
|
|
127
|
+
|
|
128
|
+
urlToResult.set(normalizedUrl, {
|
|
129
|
+
candidateId: item.candidateId,
|
|
130
|
+
assetId,
|
|
131
|
+
status: mapped.status,
|
|
132
|
+
path: mapped.path,
|
|
133
|
+
error: mapped.error,
|
|
134
|
+
})
|
|
135
|
+
results.push(mapped)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
topic,
|
|
141
|
+
manifestPath,
|
|
142
|
+
saved,
|
|
143
|
+
failed,
|
|
144
|
+
results,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { extname } from "path"
|
|
2
|
+
|
|
3
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
4
|
+
"image/png": ".png",
|
|
5
|
+
"image/jpeg": ".jpg",
|
|
6
|
+
"image/webp": ".webp",
|
|
7
|
+
"image/gif": ".gif",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ALLOWED_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif"])
|
|
11
|
+
|
|
12
|
+
function normalizeExtension(ext: string): string {
|
|
13
|
+
const value = ext.toLowerCase()
|
|
14
|
+
if (value === ".jpeg") return ".jpg"
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function inferImageExtension(contentType: string | null, sourceName = ""): string | null {
|
|
19
|
+
const mimeBase = contentType?.split(";")[0].trim().toLowerCase() ?? ""
|
|
20
|
+
if (mimeBase) {
|
|
21
|
+
const byMime = MIME_TO_EXT[mimeBase]
|
|
22
|
+
if (byMime) return byMime
|
|
23
|
+
if (!mimeBase.startsWith("image/")) return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ext = normalizeExtension(extname(sourceName))
|
|
27
|
+
if (ALLOWED_EXTENSIONS.has(ext)) return ext
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function downloadImageFromUrl(
|
|
32
|
+
url: string,
|
|
33
|
+
): Promise<{ buffer: Buffer; contentType: string | null; extension: string }> {
|
|
34
|
+
let parsed: URL
|
|
35
|
+
try {
|
|
36
|
+
parsed = new URL(url)
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("INVALID_URL")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
42
|
+
throw new Error("INVALID_URL")
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const response = await fetch(parsed, {
|
|
46
|
+
headers: {
|
|
47
|
+
"User-Agent":
|
|
48
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
49
|
+
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`DOWNLOAD_FAILED:${response.status}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const contentType = response.headers.get("content-type")
|
|
58
|
+
const extension = inferImageExtension(contentType, parsed.pathname)
|
|
59
|
+
if (!extension) {
|
|
60
|
+
throw new Error("UNSUPPORTED_IMAGE_TYPE")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
buffer: Buffer.from(await response.arrayBuffer()),
|
|
65
|
+
contentType,
|
|
66
|
+
extension,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "fs"
|
|
9
|
+
import { dirname, join, relative, resolve, sep } from "path"
|
|
10
|
+
import { downloadImageFromUrl, inferImageExtension } from "./download"
|
|
11
|
+
import type {
|
|
12
|
+
MediaAssetRecord,
|
|
13
|
+
MediaManifest,
|
|
14
|
+
MediaSaveInput,
|
|
15
|
+
MediaSaveResult,
|
|
16
|
+
MediaStatus,
|
|
17
|
+
} from "./types"
|
|
18
|
+
|
|
19
|
+
function nowIso(): string {
|
|
20
|
+
return new Date().toISOString()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function slugify(value: string): string {
|
|
24
|
+
return value
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
27
|
+
.replace(/^-+|-+$/g, "")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function ensureInsideWorkspace(pathValue: string, workspaceDir: string): string {
|
|
31
|
+
const abs = resolve(workspaceDir, pathValue)
|
|
32
|
+
const root = resolve(workspaceDir)
|
|
33
|
+
if (abs !== root && !abs.startsWith(root + sep)) {
|
|
34
|
+
throw new Error("sourcePath must be within workspace")
|
|
35
|
+
}
|
|
36
|
+
return abs
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readManifest(manifestPath: string, topic: string): MediaManifest {
|
|
40
|
+
if (!existsSync(manifestPath)) {
|
|
41
|
+
return { topic, updatedAt: nowIso(), assets: [] }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf-8")) as Partial<MediaManifest>
|
|
45
|
+
return {
|
|
46
|
+
topic,
|
|
47
|
+
updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : nowIso(),
|
|
48
|
+
assets: Array.isArray(parsed.assets) ? parsed.assets as MediaAssetRecord[] : [],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildFailureRecord(
|
|
53
|
+
input: MediaSaveInput,
|
|
54
|
+
topic: string,
|
|
55
|
+
status: Exclude<MediaStatus, "success">,
|
|
56
|
+
failureReason: string,
|
|
57
|
+
sourcePath?: string,
|
|
58
|
+
): MediaAssetRecord {
|
|
59
|
+
return {
|
|
60
|
+
id: slugify(input.id),
|
|
61
|
+
type: "image",
|
|
62
|
+
purpose: input.purpose,
|
|
63
|
+
brief: input.brief,
|
|
64
|
+
status,
|
|
65
|
+
path: null,
|
|
66
|
+
sourceUrl: input.sourceUrl,
|
|
67
|
+
sourcePath,
|
|
68
|
+
intendedSection: input.intendedSection,
|
|
69
|
+
alt: input.alt,
|
|
70
|
+
notes: input.notes,
|
|
71
|
+
failureReason,
|
|
72
|
+
savedAt: nowIso(),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function upsertAsset(manifest: MediaManifest, record: MediaAssetRecord): { manifest: MediaManifest; previous?: MediaAssetRecord } {
|
|
77
|
+
const assets = [...manifest.assets]
|
|
78
|
+
const index = assets.findIndex((asset) => asset.id === record.id)
|
|
79
|
+
const previous = index === -1 ? undefined : assets[index]
|
|
80
|
+
if (index === -1) assets.push(record)
|
|
81
|
+
else assets[index] = record
|
|
82
|
+
return {
|
|
83
|
+
previous,
|
|
84
|
+
manifest: {
|
|
85
|
+
topic: manifest.topic,
|
|
86
|
+
updatedAt: nowIso(),
|
|
87
|
+
assets,
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeManifest(manifestPath: string, manifest: MediaManifest): void {
|
|
93
|
+
mkdirSync(dirname(manifestPath), { recursive: true })
|
|
94
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveFailureResult(
|
|
98
|
+
input: MediaSaveInput,
|
|
99
|
+
workspaceDir: string,
|
|
100
|
+
topic: string,
|
|
101
|
+
manifestPath: string,
|
|
102
|
+
status: Exclude<MediaStatus, "success">,
|
|
103
|
+
failureReason: string,
|
|
104
|
+
sourcePath?: string,
|
|
105
|
+
): MediaSaveResult {
|
|
106
|
+
const manifest = readManifest(manifestPath, topic)
|
|
107
|
+
const existing = manifest.assets.find((asset) => asset.id === slugify(input.id))
|
|
108
|
+
const record = existing?.path
|
|
109
|
+
? {
|
|
110
|
+
...existing,
|
|
111
|
+
sourceUrl: input.sourceUrl ?? existing.sourceUrl,
|
|
112
|
+
sourcePath: sourcePath ?? existing.sourcePath,
|
|
113
|
+
alt: input.alt ?? existing.alt,
|
|
114
|
+
notes: input.notes ?? existing.notes,
|
|
115
|
+
failureReason,
|
|
116
|
+
}
|
|
117
|
+
: buildFailureRecord(input, topic, status, failureReason, sourcePath)
|
|
118
|
+
const { manifest: nextManifest, previous } = upsertAsset(manifest, record)
|
|
119
|
+
writeManifest(manifestPath, nextManifest)
|
|
120
|
+
|
|
121
|
+
if (!existing?.path && previous?.path) {
|
|
122
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ok: true,
|
|
127
|
+
assetId: record.id,
|
|
128
|
+
status,
|
|
129
|
+
path: null,
|
|
130
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
131
|
+
updated: true,
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function saveMediaAsset(input: MediaSaveInput, workspaceDir: string): Promise<MediaSaveResult> {
|
|
136
|
+
try {
|
|
137
|
+
const topic = slugify(input.topic)
|
|
138
|
+
const assetId = slugify(input.id)
|
|
139
|
+
if (!topic) return { ok: false, error: "topic is required" }
|
|
140
|
+
if (!assetId) return { ok: false, error: "id is required" }
|
|
141
|
+
if (input.type !== "image") return { ok: false, error: "type must be 'image'" }
|
|
142
|
+
|
|
143
|
+
const topicDir = join(workspaceDir, "assets", topic)
|
|
144
|
+
const mediaDir = join(topicDir, "media")
|
|
145
|
+
const manifestPath = join(topicDir, "media-manifest.json")
|
|
146
|
+
mkdirSync(mediaDir, { recursive: true })
|
|
147
|
+
|
|
148
|
+
const relativeSourcePath = input.sourcePath
|
|
149
|
+
? relative(workspaceDir, ensureInsideWorkspace(input.sourcePath, workspaceDir))
|
|
150
|
+
: undefined
|
|
151
|
+
|
|
152
|
+
if (input.status !== "success") {
|
|
153
|
+
if (!input.failureReason?.trim()) {
|
|
154
|
+
return { ok: false, error: "failureReason is required when status is not 'success'" }
|
|
155
|
+
}
|
|
156
|
+
return saveFailureResult(
|
|
157
|
+
input,
|
|
158
|
+
workspaceDir,
|
|
159
|
+
topic,
|
|
160
|
+
manifestPath,
|
|
161
|
+
input.status,
|
|
162
|
+
input.failureReason.trim(),
|
|
163
|
+
relativeSourcePath,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!input.sourcePath && !input.sourceUrl) {
|
|
168
|
+
return { ok: false, error: "sourcePath or sourceUrl is required when status is 'success'" }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let buffer: Buffer
|
|
172
|
+
let extension: string | null = null
|
|
173
|
+
|
|
174
|
+
if (input.sourcePath) {
|
|
175
|
+
const absPath = ensureInsideWorkspace(input.sourcePath, workspaceDir)
|
|
176
|
+
if (!existsSync(absPath)) {
|
|
177
|
+
return { ok: false, error: `sourcePath not found: ${input.sourcePath}` }
|
|
178
|
+
}
|
|
179
|
+
extension = inferImageExtension(null, absPath)
|
|
180
|
+
if (!extension) {
|
|
181
|
+
return { ok: false, error: `unsupported local image type: ${input.sourcePath}` }
|
|
182
|
+
}
|
|
183
|
+
const destinationPath = join(mediaDir, `${assetId}${extension}`)
|
|
184
|
+
const manifest = readManifest(manifestPath, topic)
|
|
185
|
+
const previous = manifest.assets.find((asset) => asset.id === assetId)
|
|
186
|
+
copyFileSync(absPath, destinationPath)
|
|
187
|
+
const record: MediaAssetRecord = {
|
|
188
|
+
id: assetId,
|
|
189
|
+
type: "image",
|
|
190
|
+
purpose: input.purpose,
|
|
191
|
+
brief: input.brief,
|
|
192
|
+
status: "success",
|
|
193
|
+
path: relative(workspaceDir, destinationPath),
|
|
194
|
+
sourceUrl: input.sourceUrl,
|
|
195
|
+
sourcePath: relativeSourcePath,
|
|
196
|
+
intendedSection: input.intendedSection,
|
|
197
|
+
alt: input.alt,
|
|
198
|
+
notes: input.notes,
|
|
199
|
+
savedAt: nowIso(),
|
|
200
|
+
}
|
|
201
|
+
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
202
|
+
writeManifest(manifestPath, nextManifest)
|
|
203
|
+
if (previous?.path && previous.path !== record.path) {
|
|
204
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
ok: true,
|
|
208
|
+
assetId,
|
|
209
|
+
status: "success",
|
|
210
|
+
path: record.path,
|
|
211
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
212
|
+
updated: true,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const downloaded = await downloadImageFromUrl(input.sourceUrl!)
|
|
218
|
+
buffer = downloaded.buffer
|
|
219
|
+
extension = downloaded.extension
|
|
220
|
+
} catch (error) {
|
|
221
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
222
|
+
if (message === "INVALID_URL") {
|
|
223
|
+
return saveFailureResult(input, workspaceDir, topic, manifestPath, "invalid-url", "Invalid image URL")
|
|
224
|
+
}
|
|
225
|
+
return saveFailureResult(
|
|
226
|
+
input,
|
|
227
|
+
workspaceDir,
|
|
228
|
+
topic,
|
|
229
|
+
manifestPath,
|
|
230
|
+
"cannot-download",
|
|
231
|
+
`Failed to download image: ${message}`,
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const destinationPath = join(mediaDir, `${assetId}${extension}`)
|
|
236
|
+
const manifest = readManifest(manifestPath, topic)
|
|
237
|
+
const previous = manifest.assets.find((asset) => asset.id === assetId)
|
|
238
|
+
writeFileSync(destinationPath, new Uint8Array(buffer))
|
|
239
|
+
const record: MediaAssetRecord = {
|
|
240
|
+
id: assetId,
|
|
241
|
+
type: "image",
|
|
242
|
+
purpose: input.purpose,
|
|
243
|
+
brief: input.brief,
|
|
244
|
+
status: "success",
|
|
245
|
+
path: relative(workspaceDir, destinationPath),
|
|
246
|
+
sourceUrl: input.sourceUrl,
|
|
247
|
+
sourcePath: relativeSourcePath,
|
|
248
|
+
intendedSection: input.intendedSection,
|
|
249
|
+
alt: input.alt,
|
|
250
|
+
notes: input.notes,
|
|
251
|
+
savedAt: nowIso(),
|
|
252
|
+
}
|
|
253
|
+
const { manifest: nextManifest } = upsertAsset(manifest, record)
|
|
254
|
+
writeManifest(manifestPath, nextManifest)
|
|
255
|
+
if (previous?.path && previous.path !== record.path) {
|
|
256
|
+
rmSync(join(workspaceDir, previous.path), { force: true })
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
assetId,
|
|
262
|
+
status: "success",
|
|
263
|
+
path: record.path,
|
|
264
|
+
manifestPath: relative(workspaceDir, manifestPath),
|
|
265
|
+
updated: true,
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
error: error instanceof Error ? error.message : String(error),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type MediaPurpose = "hero" | "illustration" | "portrait" | "logo" | "screenshot"
|
|
2
|
+
|
|
3
|
+
export type MediaStatus = "success" | "cannot-download" | "invalid-url" | "cannot-generate"
|
|
4
|
+
|
|
5
|
+
export interface MediaSaveInput {
|
|
6
|
+
topic: string
|
|
7
|
+
id: string
|
|
8
|
+
type: "image"
|
|
9
|
+
purpose: MediaPurpose
|
|
10
|
+
brief: string
|
|
11
|
+
status: MediaStatus
|
|
12
|
+
intendedSection?: string
|
|
13
|
+
sourcePath?: string
|
|
14
|
+
sourceUrl?: string
|
|
15
|
+
alt?: string
|
|
16
|
+
notes?: string
|
|
17
|
+
failureReason?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MediaAssetRecord {
|
|
21
|
+
id: string
|
|
22
|
+
type: "image"
|
|
23
|
+
purpose: MediaPurpose
|
|
24
|
+
brief: string
|
|
25
|
+
status: MediaStatus
|
|
26
|
+
path: string | null
|
|
27
|
+
sourceUrl?: string
|
|
28
|
+
sourcePath?: string
|
|
29
|
+
intendedSection?: string
|
|
30
|
+
alt?: string
|
|
31
|
+
notes?: string
|
|
32
|
+
failureReason?: string
|
|
33
|
+
savedAt: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MediaManifest {
|
|
37
|
+
topic: string
|
|
38
|
+
updatedAt: string
|
|
39
|
+
assets: MediaAssetRecord[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type MediaSaveResult =
|
|
43
|
+
| {
|
|
44
|
+
ok: true
|
|
45
|
+
assetId: string
|
|
46
|
+
status: MediaStatus
|
|
47
|
+
path: string | null
|
|
48
|
+
manifestPath: string
|
|
49
|
+
updated: boolean
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
ok: false
|
|
53
|
+
error: string
|
|
54
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "fs"
|
|
2
|
+
import { basename, join } from "path"
|
|
3
|
+
|
|
4
|
+
export type ResearchImageUse = "logo" | "portrait" | "screenshot" | "unknown"
|
|
5
|
+
|
|
6
|
+
export interface ResearchImageLead {
|
|
7
|
+
candidateId: string
|
|
8
|
+
topic: string
|
|
9
|
+
axis: string
|
|
10
|
+
sourceFile: string
|
|
11
|
+
description: string
|
|
12
|
+
url: string
|
|
13
|
+
alt: string
|
|
14
|
+
use: ResearchImageUse
|
|
15
|
+
line: number
|
|
16
|
+
valid: boolean
|
|
17
|
+
warnings: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ListResearchImageLeadOptions {
|
|
21
|
+
uses?: string[]
|
|
22
|
+
axis?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResearchImageLeadListResult {
|
|
26
|
+
topic: string
|
|
27
|
+
items: ResearchImageLead[]
|
|
28
|
+
warnings: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const LEAD_LINE_RE = /^-\s*(.+?):\s*(\S+)(?:\s*\|\s*Alt:\s*(.*?))?(?:\s*\|\s*Use:\s*(.*?))?\s*$/
|
|
32
|
+
|
|
33
|
+
function slugify(value: string): string {
|
|
34
|
+
return value
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
37
|
+
.replace(/^-+|-+$/g, "")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeUse(value: string | undefined): ResearchImageUse {
|
|
41
|
+
const normalized = (value ?? "").trim().toLowerCase()
|
|
42
|
+
if (normalized === "logo" || normalized === "portrait" || normalized === "screenshot") {
|
|
43
|
+
return normalized
|
|
44
|
+
}
|
|
45
|
+
return "unknown"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeAxisFilter(values: string[] | undefined): string[] | undefined {
|
|
49
|
+
if (!values?.length) return undefined
|
|
50
|
+
const normalized = values
|
|
51
|
+
.map((value) => slugify(value))
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
return normalized.length ? normalized : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeUseFilter(values: string[] | undefined): ResearchImageUse[] | undefined {
|
|
57
|
+
if (!values?.length) return undefined
|
|
58
|
+
const normalized = values
|
|
59
|
+
.map((value) => normalizeUse(value))
|
|
60
|
+
return normalized.length ? normalized : undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isHttpUrl(value: string): boolean {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(value)
|
|
66
|
+
return url.protocol === "http:" || url.protocol === "https:"
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function shouldIncludeLead(lead: ResearchImageLead, options?: ListResearchImageLeadOptions): boolean {
|
|
73
|
+
const uses = normalizeUseFilter(options?.uses)
|
|
74
|
+
const axis = normalizeAxisFilter(options?.axis)
|
|
75
|
+
if (uses?.length && !uses.includes(lead.use)) return false
|
|
76
|
+
if (axis?.length && !axis.includes(lead.axis)) return false
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseImageSection(
|
|
81
|
+
topic: string,
|
|
82
|
+
axis: string,
|
|
83
|
+
sourceFile: string,
|
|
84
|
+
content: string,
|
|
85
|
+
): { items: ResearchImageLead[]; warnings: string[] } {
|
|
86
|
+
const lines = content.split(/\r?\n/)
|
|
87
|
+
const items: ResearchImageLead[] = []
|
|
88
|
+
const warnings: string[] = []
|
|
89
|
+
let inImagesSection = false
|
|
90
|
+
let candidateIndex = 0
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < lines.length; index++) {
|
|
93
|
+
const line = lines[index]
|
|
94
|
+
const trimmed = line.trim()
|
|
95
|
+
|
|
96
|
+
if (/^##\s+Images\s*$/i.test(trimmed)) {
|
|
97
|
+
inImagesSection = true
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (inImagesSection && /^##\s+/.test(trimmed)) {
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!inImagesSection || !trimmed.startsWith("-")) continue
|
|
106
|
+
if (trimmed === "- None") continue
|
|
107
|
+
|
|
108
|
+
const match = trimmed.match(LEAD_LINE_RE)
|
|
109
|
+
if (!match) {
|
|
110
|
+
warnings.push(`${sourceFile}:${index + 1} could not parse image lead`)
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
candidateIndex += 1
|
|
115
|
+
const description = match[1].trim()
|
|
116
|
+
const url = match[2].trim()
|
|
117
|
+
const alt = (match[3] ?? "").trim()
|
|
118
|
+
const use = normalizeUse(match[4])
|
|
119
|
+
const itemWarnings: string[] = []
|
|
120
|
+
const valid = isHttpUrl(url)
|
|
121
|
+
|
|
122
|
+
if (!valid) itemWarnings.push("invalid-url")
|
|
123
|
+
if (use === "unknown") itemWarnings.push("unknown-use")
|
|
124
|
+
|
|
125
|
+
items.push({
|
|
126
|
+
candidateId: `${axis}:${candidateIndex}`,
|
|
127
|
+
topic,
|
|
128
|
+
axis,
|
|
129
|
+
sourceFile,
|
|
130
|
+
description,
|
|
131
|
+
url,
|
|
132
|
+
alt,
|
|
133
|
+
use,
|
|
134
|
+
line: index + 1,
|
|
135
|
+
valid,
|
|
136
|
+
warnings: itemWarnings,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { items, warnings }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function listResearchImageLeads(
|
|
144
|
+
topic: string,
|
|
145
|
+
workspaceDir: string,
|
|
146
|
+
options?: ListResearchImageLeadOptions,
|
|
147
|
+
): ResearchImageLeadListResult {
|
|
148
|
+
const topicSlug = slugify(topic)
|
|
149
|
+
const researchDir = join(workspaceDir, "researches", topicSlug)
|
|
150
|
+
if (!existsSync(researchDir)) {
|
|
151
|
+
return { topic: topicSlug, items: [], warnings: [] }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const items: ResearchImageLead[] = []
|
|
155
|
+
const warnings: string[] = []
|
|
156
|
+
const entries = readdirSync(researchDir)
|
|
157
|
+
.filter((entry) => entry.endsWith(".md"))
|
|
158
|
+
.sort()
|
|
159
|
+
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const filePath = join(researchDir, entry)
|
|
162
|
+
const axis = basename(entry, ".md")
|
|
163
|
+
const sourceFile = `researches/${topicSlug}/${entry}`
|
|
164
|
+
const content = readFileSync(filePath, "utf-8")
|
|
165
|
+
const parsed = parseImageSection(topicSlug, axis, sourceFile, content)
|
|
166
|
+
|
|
167
|
+
for (const item of parsed.items) {
|
|
168
|
+
if (shouldIncludeLead(item, options)) items.push(item)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
warnings.push(...parsed.warnings)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { topic: topicSlug, items, warnings }
|
|
175
|
+
}
|