mission-control/lib/attachments.ts
OpenClaw Bot c1c01bd21e feat: merge Gantt Board into Mission Control
- Add Projects page with Sprint Board and Backlog views
- Copy SprintBoard and BacklogView components to components/gantt/
- Copy useTaskStore for project/task/sprint management
- Add API routes for task persistence with SQLite
- Add UI components: dialog, select, table, textarea
- Add avatar and attachment utilities
- Update sidebar with Projects navigation link
- Remove static export config to support API routes
- Add dist to .gitignore
2026-02-20 18:49:52 -06:00

505 lines
17 KiB
TypeScript

export function inferAttachmentMimeType(fileName: string, declaredType?: string): string {
const safeDeclaredType = typeof declaredType === "string" ? declaredType.trim() : ""
if (safeDeclaredType && safeDeclaredType !== "application/octet-stream") {
return safeDeclaredType
}
const extension = fileName.toLowerCase().split(".").pop() || ""
const extensionToMime: Record<string, string> = {
md: "text/plain",
markdown: "text/plain",
txt: "text/plain",
log: "text/plain",
json: "application/json",
csv: "text/csv",
yml: "text/plain",
yaml: "text/plain",
html: "text/html",
htm: "text/html",
xml: "application/xml",
js: "text/javascript",
mjs: "text/javascript",
cjs: "text/javascript",
ts: "text/plain",
tsx: "text/plain",
jsx: "text/plain",
swift: "text/plain",
m: "text/plain",
mm: "text/plain",
pbxproj: "text/plain",
xcconfig: "text/plain",
strings: "text/plain",
plist: "application/xml",
stringsdict: "application/xml",
entitlements: "application/xml",
storyboard: "application/xml",
xib: "application/xml",
metal: "text/plain",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
}
return extensionToMime[extension] || "application/octet-stream"
}
export function isMarkdownAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
if (extension === "md" || extension === "markdown") return true
return (mimeType || "").toLowerCase() === "text/markdown"
}
export function isTextPreviewAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
const textLikeExtensions = new Set([
"txt",
"log",
"json",
"csv",
"xml",
"yml",
"yaml",
"ini",
"cfg",
"conf",
"env",
"toml",
"properties",
"sql",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"py",
"rb",
"php",
"go",
"rs",
"java",
"kt",
"swift",
"pbxproj",
"plist",
"strings",
"stringsdict",
"xcconfig",
"entitlements",
"storyboard",
"xib",
"m",
"mm",
"metal",
"c",
"h",
"cpp",
"hpp",
"cs",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"css",
"scss",
"sass",
"less",
"vue",
"svelte",
"html",
"htm",
])
const safeMimeType = (mimeType || "").toLowerCase()
if (textLikeExtensions.has(extension)) return true
if (safeMimeType.startsWith("text/")) return true
if (safeMimeType === "application/json" || safeMimeType === "application/xml") return true
return false
}
export function coerceDataUrlMimeType(dataUrl: string, mimeType: string): string {
if (!dataUrl.startsWith("data:")) return dataUrl
const commaIndex = dataUrl.indexOf(",")
if (commaIndex === -1) return dataUrl
const meta = dataUrl.slice(0, commaIndex)
const payload = dataUrl.slice(commaIndex + 1)
const hasBase64 = meta.includes(";base64")
const encodingSuffix = hasBase64 ? ";base64" : ""
return `data:${mimeType}${encodingSuffix},${payload}`
}
export async function blobFromDataUrl(dataUrl: string, mimeTypeOverride?: string): Promise<Blob> {
const response = await fetch(dataUrl)
const blob = await response.blob()
if (!mimeTypeOverride || blob.type === mimeTypeOverride) return blob
const buffer = await blob.arrayBuffer()
return new Blob([buffer], { type: mimeTypeOverride })
}
export async function textFromDataUrl(dataUrl: string): Promise<string> {
const response = await fetch(dataUrl)
return response.text()
}
function safeScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script")
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function getExtension(fileName: string): string {
return fileName.toLowerCase().split(".").pop() || ""
}
function isJsonAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "json" || (mimeType || "").toLowerCase() === "application/json"
}
function isCsvAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "csv" || (mimeType || "").toLowerCase() === "text/csv"
}
function isHtmlAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
return extension === "html" || extension === "htm" || safeMimeType === "text/html"
}
function inferCodeLanguage(fileName: string, mimeType?: string): string {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const extensionToLanguage: Record<string, string> = {
swift: "swift",
m: "objectivec",
mm: "objectivec",
h: "objectivec",
pbxproj: "ini",
xcconfig: "ini",
strings: "ini",
stringsdict: "xml",
plist: "xml",
entitlements: "xml",
storyboard: "xml",
xib: "xml",
metal: "cpp",
json: "json",
csv: "plaintext",
yml: "yaml",
yaml: "yaml",
xml: "xml",
html: "xml",
htm: "xml",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
jsx: "javascript",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "bash",
ps1: "powershell",
py: "python",
rb: "ruby",
php: "php",
go: "go",
rs: "rust",
java: "java",
kt: "kotlin",
c: "c",
cpp: "cpp",
hpp: "cpp",
cs: "csharp",
sql: "sql",
vue: "xml",
svelte: "xml",
md: "markdown",
markdown: "markdown",
txt: "plaintext",
log: "plaintext",
ini: "ini",
cfg: "ini",
conf: "ini",
env: "ini",
toml: "ini",
properties: "ini",
}
const fromExtension = extensionToLanguage[extension]
if (fromExtension) return fromExtension
if (safeMimeType === "application/json") return "json"
if (safeMimeType === "application/xml" || safeMimeType === "text/xml") return "xml"
if (safeMimeType === "text/html") return "xml"
if (safeMimeType === "text/css") return "css"
if (safeMimeType === "text/javascript") return "javascript"
return "plaintext"
}
function parseCsvLine(line: string): string[] {
const cells: string[] = []
let current = ""
let index = 0
let inQuotes = false
while (index < line.length) {
const char = line[index]
if (char === '"') {
const next = line[index + 1]
if (inQuotes && next === '"') {
current += '"'
index += 2
continue
}
inQuotes = !inQuotes
index += 1
continue
}
if (char === "," && !inQuotes) {
cells.push(current)
current = ""
index += 1
continue
}
current += char
index += 1
}
cells.push(current)
return cells
}
function csvTableHtml(content: string): string {
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(0, 200)
if (lines.length === 0) return '<p class="muted">No CSV rows to preview.</p>'
const rows = lines.map(parseCsvLine)
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
const normalizedRows = rows.map((row) => [...row, ...Array.from({ length: maxColumns - row.length }, () => "")])
const header = normalizedRows[0]
const body = normalizedRows.slice(1)
const headerCells = header.map((cell) => `<th>${escapeHtml(cell || "Column")}</th>`).join("")
const bodyRows = body
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`)
.join("")
return `<table><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`
}
function prettyJsonOrRaw(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}
export function createTextPreviewHtml(fileName: string, content: string, mimeType?: string): string {
const safeTitle = escapeHtml(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const isHtml = isHtmlAttachment(fileName, safeMimeType)
const isJson = isJsonAttachment(fileName, safeMimeType)
const isCsv = isCsvAttachment(fileName, safeMimeType)
const language = inferCodeLanguage(fileName, safeMimeType)
const displayedText = isJson ? prettyJsonOrRaw(content) : content
const sourceLiteral = safeScriptString(displayedText)
const htmlLiteral = safeScriptString(content)
const renderedCsv = isCsv ? csvTableHtml(content) : ""
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
.header { color: #94a3b8; font-size: 13px; margin-bottom: 14px; }
.panel { border: 1px solid #1e293b; background: #020617; border-radius: 10px; padding: 14px; margin-bottom: 14px; overflow: auto; }
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 8px; }
.copy-btn { border: 1px solid #334155; background: #111827; color: #e2e8f0; border-radius: 7px; font-size: 12px; padding: 4px 8px; cursor: pointer; }
.copy-btn:hover { border-color: #475569; }
.source-meta { color: #64748b; font-size: 12px; }
.code { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size: 13px; line-height: 1.55; }
.hljs { background: #020617 !important; border-radius: 8px; padding: 0 !important; }
.muted { color: #94a3b8; font-size: 13px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #334155; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #111827; color: #f8fafc; position: sticky; top: 0; }
.iframe-wrap { border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; background: #ffffff; }
iframe { display: block; width: 100%; min-height: 360px; border: 0; background: #fff; }
details { border: 1px solid #1e293b; border-radius: 10px; background: #020617; padding: 10px 12px; }
summary { cursor: pointer; color: #cbd5e1; font-size: 13px; }
</style>
</head>
<body>
<main class="container">
<div class="header">Attachment Preview: ${safeTitle}${safeMimeType ? ` · ${escapeHtml(safeMimeType)}` : ""}</div>
${isHtml ? `<div class="iframe-wrap"><iframe sandbox="" id="html-frame"></iframe></div>` : ""}
${isCsv ? `<div class="panel">${renderedCsv}</div>` : ""}
${isHtml ? '<details><summary>View source</summary>' : ""}
<div class="panel-toolbar">
<button id="copy-source" type="button" class="copy-btn">Copy source</button>
<span id="source-meta" class="source-meta"></span>
</div>
<div class="panel">
<pre class="code"><code id="source-code" class="language-${language}"></code></pre>
</div>
${isHtml ? '</details>' : ""}
</main>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const sourceText = ${sourceLiteral};
const rawHtml = ${htmlLiteral};
const sourceCodeElement = document.getElementById("source-code");
if (sourceCodeElement) sourceCodeElement.textContent = sourceText;
const sourceMetaElement = document.getElementById("source-meta");
if (sourceMetaElement) {
const lineCount = sourceText.length === 0 ? 0 : sourceText.split(/\\r?\\n/).length;
sourceMetaElement.textContent = "${escapeHtml(language)} · " + lineCount + " lines";
}
if (window.hljs && sourceCodeElement) {
try {
window.hljs.highlightElement(sourceCodeElement);
} catch (_error) {
// no-op fallback to plain text
}
}
const copyButton = document.getElementById("copy-source");
if (copyButton && navigator.clipboard && navigator.clipboard.writeText) {
copyButton.addEventListener("click", async () => {
const originalLabel = copyButton.textContent || "Copy source";
try {
await navigator.clipboard.writeText(sourceText);
copyButton.textContent = "Copied";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1200);
} catch (_error) {
copyButton.textContent = "Copy failed";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1500);
}
});
}
const htmlFrame = document.getElementById("html-frame");
if (htmlFrame) htmlFrame.srcdoc = rawHtml;
</script>
</body>
</html>`
}
export function createMarkdownPreviewHtml(fileName: string, markdown: string): string {
const safeTitle = fileName.replace(/[<>&"]/g, (char) => {
if (char === "<") return "&lt;"
if (char === ">") return "&gt;"
if (char === "&") return "&amp;"
return "&quot;"
})
const markdownLiteral = safeScriptString(markdown)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
.header { margin-bottom: 16px; color: #94a3b8; font-size: 14px; }
.markdown { line-height: 1.7; color: #e2e8f0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f8fafc; margin-top: 1.3em; margin-bottom: 0.4em; }
.markdown pre { background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; overflow: auto; }
.markdown code { background: #020617; border: 1px solid #1e293b; border-radius: 6px; padding: 0.1em 0.35em; }
.markdown pre code { background: transparent; border: 0; padding: 0; }
.markdown a { color: #60a5fa; }
.markdown blockquote { border-left: 3px solid #334155; margin: 0.8em 0; padding-left: 12px; color: #cbd5e1; }
.markdown table { border-collapse: collapse; width: 100%; }
.markdown th, .markdown td { border: 1px solid #334155; padding: 6px 8px; }
.markdown hr { border: 0; border-top: 1px solid #334155; margin: 1.5em 0; }
.fallback { white-space: pre-wrap; background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; color: #e2e8f0; }
</style>
</head>
<body>
<main class="container">
<div class="header">Markdown Preview: ${safeTitle}</div>
<article id="root" class="markdown"></article>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const rawMarkdown = ${markdownLiteral};
const markdown = rawMarkdown.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const root = document.getElementById("root");
const fallback = () => {
const pre = document.createElement("pre");
pre.className = "fallback";
pre.textContent = rawMarkdown;
root.innerHTML = "";
root.appendChild(pre);
};
try {
if (window.marked && typeof window.marked.parse === "function") {
root.innerHTML = window.marked.parse(markdown);
if (window.hljs) {
root.querySelectorAll("pre code").forEach((block) => {
try {
window.hljs.highlightElement(block);
} catch (_error) {
// no-op fallback to plain rendered code block
}
});
}
} else {
fallback();
}
} catch (_error) {
fallback();
}
</script>
</body>
</html>`;
}
export async function markdownPreviewObjectUrl(fileName: string, dataUrl: string): Promise<string> {
const markdown = await textFromDataUrl(dataUrl)
const html = createMarkdownPreviewHtml(fileName, markdown)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}
export async function textPreviewObjectUrl(fileName: string, dataUrl: string, mimeType?: string): Promise<string> {
const content = await textFromDataUrl(dataUrl)
const html = createTextPreviewHtml(fileName, content, mimeType)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}