415 lines
15 KiB
JavaScript
415 lines
15 KiB
JavaScript
const assert = require('node:assert/strict')
|
|
const crypto = require('node:crypto')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
|
|
const repoRoot = path.resolve(__dirname, '..', '..')
|
|
const tmpDir = path.join(repoRoot, '.tmp')
|
|
|
|
const evidence = {
|
|
home: {
|
|
path: path.join(tmpDir, 'rea-four-modules-library-stable-20261017.json'),
|
|
id: 'ev_82a9ad8ec38c807e465646d749e2cedbb150fec62e30234c97acd0bb309c349e'
|
|
},
|
|
local: {
|
|
path: path.join(tmpDir, 'rea-four-modules-library-local-20261017.json'),
|
|
id: 'ev_b7fe1b4adac2311b04dfa454126363ca382869d6a29664152d497a920c2e8cad'
|
|
}
|
|
}
|
|
|
|
const mediaEvidence = {
|
|
path: path.join(
|
|
tmpDir,
|
|
'rea-css-evidence',
|
|
'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e.json'
|
|
),
|
|
id: 'ev_d650991b41fe1831a4caf5dd225398e29db8e91205d00c2ac3047b00baa1998e'
|
|
}
|
|
|
|
const illustrations = [
|
|
{
|
|
component: 'RobotGuideIllustration',
|
|
importName: 'agentGuideIllustration',
|
|
assetFile: 'wb-home-guide-card-agent-illustration.svg',
|
|
selector: '.wb-home-guide-card-agent-pc .wb-home-guide-card-illustration-pc',
|
|
sourceUrl:
|
|
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-agent-illustration.1abe0c58.svg',
|
|
bytes: 2857,
|
|
sha256: '1b0984754f470c4529b09d2fa56a957f23df3bd7fc16cc27d74989b1112ff815'
|
|
},
|
|
{
|
|
component: 'HtmlGuideIllustration',
|
|
importName: 'htmlGuideIllustration',
|
|
assetFile: 'wb-home-guide-card-html-illustration.svg',
|
|
selector: '.wb-home-guide-card-html-pc .wb-home-guide-card-illustration-pc',
|
|
sourceUrl:
|
|
'https://static.workbuddy.cn/web/library/5609e40689e0ddd5b7e8ede0a4e43acd7b1ba74d/js/federation/releases/e7fafd8eaa71ac3f/guide-card-html-illustration.0c0d6493.svg',
|
|
bytes: 1413,
|
|
sha256: '1af18b1901999725af4114997bba4c779c6d04f09712c44d34add010433d1e47'
|
|
}
|
|
]
|
|
|
|
const icons = [
|
|
{
|
|
name: 'ClockIcon',
|
|
evidence: 'home',
|
|
rootIndex: 872,
|
|
parentClass: 'memory-nav-item-icon-pc'
|
|
},
|
|
{
|
|
name: 'FolderLineIcon',
|
|
evidence: 'home',
|
|
rootIndex: 878,
|
|
parentClass: 'memory-nav-item-icon-pc'
|
|
},
|
|
{
|
|
name: 'MoreIcon',
|
|
evidence: 'home',
|
|
rootIndex: 1066,
|
|
parentClass: 'wb-feed-more-btn-pc'
|
|
},
|
|
{
|
|
name: 'InfoIcon',
|
|
evidence: 'home',
|
|
rootIndex: 1290,
|
|
parentClass: 'wb-home-guide-title-pc'
|
|
},
|
|
{
|
|
name: 'LightbulbIcon',
|
|
evidence: 'home',
|
|
rootIndex: 1323,
|
|
parentClass: 'wb-home-guide-playbook-title-pc'
|
|
},
|
|
{
|
|
name: 'BackIcon',
|
|
evidence: 'local',
|
|
rootIndex: 848,
|
|
parentClass: 'kb-back-to-library__btn'
|
|
},
|
|
{
|
|
name: 'SortArrowsIcon',
|
|
evidence: 'local',
|
|
rootIndex: 878,
|
|
parentClass: 'my-files-flat-col-name my-files-flat-sortable',
|
|
variant: 'sort'
|
|
},
|
|
{
|
|
name: 'ShareIcon',
|
|
evidence: 'local',
|
|
rootIndex: 954,
|
|
parentClass: 'my-files-list-action-btn'
|
|
},
|
|
{
|
|
name: 'TableMoreIcon',
|
|
evidence: 'local',
|
|
rootIndex: 959,
|
|
parentClass: 'my-files-list-action-btn'
|
|
},
|
|
{
|
|
name: 'TaskIcon',
|
|
evidence: 'local',
|
|
rootIndex: 967,
|
|
parentClass: 'my-files-flat-col-name'
|
|
}
|
|
]
|
|
|
|
const notes = {
|
|
star_icon: {
|
|
status: 'unresolved',
|
|
reason:
|
|
'No star/favorite SVG exists in the captured library UI. WorkBuddy uses a CSS checkbox for the favorite filter and renders share plus more actions in file rows.',
|
|
evidence_ids: Object.values(evidence).map((item) => item.id),
|
|
searched_classes: [
|
|
'my-files-favorite-checkbox',
|
|
'my-files-favorite-checkbox__label',
|
|
'my-files-list-action-btn'
|
|
]
|
|
},
|
|
rejected_reusable_home_media: {
|
|
status: 'not_applicable',
|
|
reason:
|
|
'A previously archived image body exists, but it belongs to the older .wb-home-route__growth-buddy img selector and is not evidence for either current guide-card illustration.',
|
|
evidence_id: 'ev_474129797ce3ab73eaaab2cd41247653e7f128c3b89b598158b57b74e1f17e88',
|
|
selector: '.wb-home-route__growth-buddy img',
|
|
source_url:
|
|
'https://staging-download.codebuddy.cn/operation-platform/assets/_general/ffca4ecbc52c1e3e.png',
|
|
artifact_sha256: '2713ed5bbf1288d56ebeaca49bfb0d622c967914ab9c98955cc995932049e7e8',
|
|
bytes: 13426
|
|
}
|
|
}
|
|
|
|
const svgAttributeNames = new Map([
|
|
['class', 'className'],
|
|
['clip-path', 'clipPath'],
|
|
['clip-rule', 'clipRule'],
|
|
['fill-rule', 'fillRule'],
|
|
['stroke-dasharray', 'strokeDasharray'],
|
|
['stroke-dashoffset', 'strokeDashoffset'],
|
|
['stroke-linecap', 'strokeLinecap'],
|
|
['stroke-linejoin', 'strokeLinejoin'],
|
|
['stroke-miterlimit', 'strokeMiterlimit'],
|
|
['stroke-width', 'strokeWidth'],
|
|
['transform-origin', 'transformOrigin']
|
|
])
|
|
|
|
const svgTagNames = new Map([
|
|
['clippath', 'clipPath'],
|
|
['lineargradient', 'linearGradient'],
|
|
['radialgradient', 'radialGradient']
|
|
])
|
|
|
|
function sha256(value) {
|
|
return crypto.createHash('sha256').update(value).digest('hex')
|
|
}
|
|
|
|
function readEvidence(spec) {
|
|
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
|
|
assert.equal(value.evidence_id, spec.id)
|
|
assert.equal(value.operation, 'inspect_electron_page')
|
|
return value
|
|
}
|
|
|
|
function jsxAttributeName(name) {
|
|
return svgAttributeNames.get(name) ?? name
|
|
}
|
|
|
|
function jsxAttributes(attributes) {
|
|
return Object.entries(attributes)
|
|
.map(([name, value]) => `${jsxAttributeName(name)}=${JSON.stringify(value)}`)
|
|
.join(' ')
|
|
}
|
|
|
|
function recoverIcon(document, spec) {
|
|
const layoutNodes = new Map(document.nodes.map((node) => [node.node_index, node]))
|
|
const svgByIndex = new Map(document.svg_nodes.map((node) => [node.node_index, node]))
|
|
const childrenByParent = new Map()
|
|
for (const node of document.svg_nodes) {
|
|
const children = childrenByParent.get(node.parent_index) ?? []
|
|
children.push(node)
|
|
childrenByParent.set(node.parent_index, children)
|
|
}
|
|
|
|
const root = svgByIndex.get(spec.rootIndex)
|
|
assert.ok(root, `missing SVG root ${spec.rootIndex} for ${spec.name}`)
|
|
assert.equal(root.node_name, 'svg')
|
|
|
|
const parent = layoutNodes.get(root.parent_index)
|
|
assert.ok(parent, `missing SVG parent for ${spec.name}`)
|
|
assert.equal(parent.attributes?.class, spec.parentClass)
|
|
|
|
const subtree = []
|
|
const visit = (node) => {
|
|
subtree.push({
|
|
node_index: node.node_index,
|
|
parent_index: node.parent_index,
|
|
node_name: node.node_name,
|
|
attributes: node.attributes
|
|
})
|
|
for (const child of childrenByParent.get(node.node_index) ?? []) visit(child)
|
|
}
|
|
visit(root)
|
|
|
|
return {
|
|
root,
|
|
parent,
|
|
childrenByParent,
|
|
hash: sha256(JSON.stringify(subtree))
|
|
}
|
|
}
|
|
|
|
function renderNode(recovery, node, isRoot = false, spec = null) {
|
|
const tag = svgTagNames.get(node.node_name.toLowerCase()) ?? node.node_name
|
|
const attributes = { ...node.attributes }
|
|
|
|
let prefix = ''
|
|
if (isRoot) {
|
|
delete attributes.width
|
|
delete attributes.height
|
|
if (spec?.variant === 'sort') {
|
|
delete attributes.class
|
|
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames("my-files-flat-sort-icon", direction, className)} `
|
|
} else if ('class' in attributes) {
|
|
const sourceClass = attributes.class
|
|
delete attributes.class
|
|
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} className={mergeClassNames(${JSON.stringify(sourceClass)}, className)} `
|
|
} else {
|
|
prefix = `{...svgProps} width={width ?? ${JSON.stringify(recovery.root.attributes.width)}} height={height ?? ${JSON.stringify(recovery.root.attributes.height)}} `
|
|
}
|
|
}
|
|
|
|
const serialized = jsxAttributes(attributes)
|
|
const opener = `<${tag} ${prefix}${serialized}`
|
|
const children = recovery.childrenByParent.get(node.node_index) ?? []
|
|
if (!children.length) return `${opener} />`
|
|
return `${opener}>${children.map((child) => renderNode(recovery, child)).join('')}</${tag}>`
|
|
}
|
|
|
|
function functionSource(name, recovery, evidenceId, version, spec) {
|
|
const provenance = [
|
|
`WorkBuddy ${version}`,
|
|
`REA ${evidenceId}`,
|
|
`parent .${spec.parentClass.replaceAll(' ', '.')}`,
|
|
`root ${spec.rootIndex}`,
|
|
`subtree sha256 ${recovery.hash}`
|
|
].join('; ')
|
|
|
|
if (spec.variant === 'sort') {
|
|
return `// Exact SVG recovery: ${provenance}\nexport function ${name}({ direction, className, width, height, ...svgProps }: SortIconProps): React.JSX.Element {\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
|
|
}
|
|
|
|
return `// Exact SVG recovery: ${provenance}\nexport function ${name}(props: IconProps): React.JSX.Element {\n const { className, width, height, ...svgProps } = props\n return ${renderNode(recovery, recovery.root, true, spec)}\n}`
|
|
}
|
|
|
|
function mergeClassNames(...values) {
|
|
return values.filter(Boolean).join(' ') || undefined
|
|
}
|
|
|
|
function readMediaEvidence(spec) {
|
|
const value = JSON.parse(fs.readFileSync(spec.path, 'utf8').replace(/^\uFEFF/, ''))
|
|
assert.equal(value.evidence_id, spec.id)
|
|
const items = value.normalized_result?.visuals?.media_assets?.items
|
|
assert.ok(Array.isArray(items))
|
|
return items
|
|
}
|
|
|
|
function recoverIllustration(item, spec) {
|
|
assert.equal(item.selector, spec.selector)
|
|
assert.equal(item.status, 'exported')
|
|
assert.equal(item.source?.url, spec.sourceUrl)
|
|
assert.equal(item.source?.mime_type, 'image/svg+xml')
|
|
assert.equal(item.artifact?.mime_type, 'image/svg+xml')
|
|
assert.equal(item.artifact?.bytes, spec.bytes)
|
|
assert.equal(item.artifact?.sha256, spec.sha256)
|
|
|
|
const bytes = Buffer.from(item.artifact.data_base64, 'base64')
|
|
assert.equal(bytes.length, spec.bytes)
|
|
assert.equal(sha256(bytes), spec.sha256)
|
|
const svg = bytes.toString('utf8')
|
|
assert.match(svg.trimStart(), /^<svg\b/)
|
|
|
|
return {
|
|
bytes,
|
|
record: {
|
|
component: spec.component,
|
|
asset_file: `src/renderer/src/library/assets/${spec.assetFile}`,
|
|
evidence_id: mediaEvidence.id,
|
|
evidence_file: path.relative(repoRoot, mediaEvidence.path).replaceAll('\\', '/'),
|
|
selector: item.selector,
|
|
node_index: item.node_index,
|
|
node_class: item.selected_node?.attributes?.class,
|
|
source_url: item.source.url,
|
|
source_url_sha256: item.source.url_sha256,
|
|
status: item.status,
|
|
visibility: item.visibility,
|
|
mime_type: item.artifact.mime_type,
|
|
bytes: item.artifact.bytes,
|
|
sha256: item.artifact.sha256
|
|
}
|
|
}
|
|
}
|
|
|
|
function illustrationSource(records) {
|
|
const imports = records
|
|
.map(({ spec }) => `import ${spec.importName} from './assets/${spec.assetFile}'`)
|
|
.join('\n')
|
|
const components = records.map(({ record, spec }) => {
|
|
const provenance = [
|
|
`REA ${mediaEvidence.id}`,
|
|
`selector ${spec.selector}`,
|
|
`source ${spec.sourceUrl}`,
|
|
`${spec.bytes} bytes`,
|
|
`sha256 ${spec.sha256}`
|
|
].join('; ')
|
|
return `// Exact WorkBuddy asset recovery: ${provenance}\nexport function ${spec.component}(): React.JSX.Element {\n return (\n <img\n className="wb-home-guide-card-illustration-pc library-guide__illustration"\n src={${spec.importName}}\n alt=""\n />\n )\n}`
|
|
}).join('\n\n')
|
|
|
|
return `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\n${imports}\n\n${components}\n`
|
|
}
|
|
|
|
function main() {
|
|
const loaded = Object.fromEntries(
|
|
Object.entries(evidence).map(([name, spec]) => [name, readEvidence(spec)])
|
|
)
|
|
const iconRecords = []
|
|
const iconSources = []
|
|
|
|
for (const spec of icons) {
|
|
const value = loaded[spec.evidence]
|
|
const document = value.normalized_result.visuals.layout.documents[0]
|
|
const recovery = recoverIcon(document, spec)
|
|
const version = value.normalized_result.browser.user_agent.match(/WorkBuddy\/([^\s]+)/)?.[1] ?? 'unknown'
|
|
iconSources.push(functionSource(spec.name, recovery, value.evidence_id, version, spec))
|
|
iconRecords.push({
|
|
name: spec.name,
|
|
evidence_id: value.evidence_id,
|
|
evidence_file: path.relative(repoRoot, evidence[spec.evidence].path).replaceAll('\\', '/'),
|
|
root_node_index: spec.rootIndex,
|
|
parent_class: recovery.parent.attributes.class,
|
|
source_sha256: recovery.hash,
|
|
svg_attributes: recovery.root.attributes
|
|
})
|
|
}
|
|
|
|
const iconOutput = `// Generated by scripts/rea/recover-library-icons.cjs. Do not hand edit.\nimport type { SVGProps } from 'react'\n\ntype IconProps = SVGProps<SVGSVGElement>\ntype SortIconProps = IconProps & { direction?: 'asc' | 'desc' }\n\nfunction mergeClassNames(...values: Array<string | undefined>): string | undefined {\n return values.filter(Boolean).join(' ') || undefined\n}\n\n${iconSources.join('\n\n')}\n`
|
|
const iconDestination = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'SourceLibraryIcons.tsx')
|
|
const mediaItems = readMediaEvidence(mediaEvidence)
|
|
const illustrationRecoveries = illustrations.map((spec) => {
|
|
const item = mediaItems.find((candidate) => candidate.selector === spec.selector)
|
|
assert.ok(item, `missing media item ${spec.selector}`)
|
|
return { spec, ...recoverIllustration(item, spec) }
|
|
})
|
|
const illustrationOutput = illustrationSource(illustrationRecoveries)
|
|
const illustrationDestination = path.join(
|
|
repoRoot,
|
|
'src',
|
|
'renderer',
|
|
'src',
|
|
'library',
|
|
'SourceLibraryIllustrations.tsx'
|
|
)
|
|
const assetDirectory = path.join(repoRoot, 'src', 'renderer', 'src', 'library', 'assets')
|
|
const manifestPath = path.join(tmpDir, 'library-icon-recovery-manifest.json')
|
|
const manifest = {
|
|
icon_source_file: path.relative(repoRoot, iconDestination).replaceAll('\\', '/'),
|
|
icon_file_sha256: sha256(iconOutput),
|
|
illustration_source_file: path.relative(repoRoot, illustrationDestination).replaceAll('\\', '/'),
|
|
illustration_file_sha256: sha256(illustrationOutput),
|
|
icons: iconRecords,
|
|
illustrations: illustrationRecoveries.map(({ record }) => record),
|
|
notes
|
|
}
|
|
|
|
if (process.argv.includes('--check')) {
|
|
assert.equal(fs.readFileSync(iconDestination, 'utf8'), iconOutput)
|
|
assert.equal(fs.readFileSync(illustrationDestination, 'utf8'), illustrationOutput)
|
|
for (const { bytes, record } of illustrationRecoveries) {
|
|
assert.deepEqual(fs.readFileSync(path.join(repoRoot, record.asset_file)), bytes)
|
|
}
|
|
assert.deepEqual(JSON.parse(fs.readFileSync(manifestPath, 'utf8')), manifest)
|
|
} else {
|
|
fs.mkdirSync(assetDirectory, { recursive: true })
|
|
fs.writeFileSync(iconDestination, iconOutput)
|
|
fs.writeFileSync(illustrationDestination, illustrationOutput)
|
|
for (const { bytes, spec } of illustrationRecoveries) {
|
|
fs.writeFileSync(path.join(assetDirectory, spec.assetFile), bytes)
|
|
}
|
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
}
|
|
|
|
process.stdout.write(`${JSON.stringify({
|
|
status: 'PASS',
|
|
recovered_icons: iconRecords.length,
|
|
recovered_illustrations: illustrationRecoveries.length,
|
|
unresolved: Object.entries(notes)
|
|
.filter(([, note]) => note.status === 'unresolved')
|
|
.map(([name]) => name),
|
|
icon_file: iconDestination,
|
|
icon_file_sha256: manifest.icon_file_sha256,
|
|
illustration_file: illustrationDestination,
|
|
illustration_file_sha256: manifest.illustration_file_sha256,
|
|
manifest: manifestPath
|
|
}, null, 2)}\n`)
|
|
}
|
|
|
|
main()
|