/*
filethumb.js
Shared file icon and thumbnail logic for the ArozOS file system web apps.
Both the File Manager (file_explorer.html) and the File Selector
(file_selector.html) render the same visual language for a file, so the
glyph drawing, thumbnail streaming and default-opener lookup all live here
instead of being duplicated per app.
Everything is exposed on the global FileThumb object.
FileThumb.getExt("a.tar.gz") -> "gz"
FileThumb.smallGlyph(name, isDir) -> markup for list rows
FileThumb.largeGlyph(name, isDir) -> markup for grid tiles / previews
FileThumb.loadThumbnails({...}) -> streams thumbnails into the DOM
FileThumb.getDefaultOpener(".mp3", cb) -> registered WebApp for an extension
No emoji anywhere - every glyph is drawn as inline SVG (CLAUDE.md rule 6).
*/
(function (global) {
"use strict";
/* ------------------------------------------------------------------ */
/* File type classification */
/* ------------------------------------------------------------------ */
var EXT_CATEGORY = {
pdf: "pdf",
doc: "doc", docx: "doc", odt: "doc", rtf: "doc",
xls: "sheet", xlsx: "sheet", ods: "sheet", csv: "sheet",
ppt: "slide", pptx: "slide", odp: "slide",
txt: "text", md: "text", log: "text", ini: "text", cfg: "text",
zip: "archive", "7z": "archive", rar: "archive", tar: "archive",
gz: "archive", bz2: "archive", xz: "archive",
jpg: "image", jpeg: "image", png: "image", gif: "image", bmp: "image",
webp: "image", svg: "image", psd: "image", tiff: "image", ico: "image",
mp3: "audio", flac: "audio", wav: "audio", aac: "audio", ogg: "audio",
m4a: "audio", opus: "audio",
mp4: "video", webm: "video", mkv: "video", avi: "video", mov: "video",
flv: "video", wmv: "video",
js: "code", ts: "code", html: "code", htm: "code", css: "code",
json: "code", xml: "code", go: "code", py: "code", java: "code",
c: "code", cpp: "code", h: "code", php: "code", sh: "code",
agi: "code", sql: "code"
};
var CATEGORY_COLOR = {
pdf: "#e5484d",
doc: "#2b6cb0",
sheet: "#188a42",
slide: "#dd6b20",
text: "#4a90d9",
archive: "#3b82f6",
image: "#7c4dff",
audio: "#e8467c",
video: "#8b5cf6",
code: "#0f9d94",
unknown: "#7a8290"
};
function getExt(filename) {
if (filename == undefined || String(filename).indexOf(".") < 1) {
return "";
}
return String(filename).split(".").pop().toLowerCase();
}
function getCategory(ext) {
return EXT_CATEGORY[ext] || "unknown";
}
function getCategoryColor(ext) {
return CATEGORY_COLOR[getCategory(ext)];
}
function escapeHTML(text) {
return String(text)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
/*
Human readable type name for the "Type" column, e.g. "PDF File".
translate() is an optional (key, fallback) lookup so each app can feed in
its own applocale instance without this library depending on one.
*/
function getTypeName(filename, isDir, translate) {
var t = translate || function (key, fallback) { return fallback; };
if (isDir) {
return t("list/typefolder", "File folder");
}
var ext = getExt(filename);
if (ext == "") {
return t("list/typefile", "File");
}
return ext.toUpperCase() + " " + t("list/typefile", "File");
}
/* ------------------------------------------------------------------ */
/* Drawn glyphs */
/* ------------------------------------------------------------------ */
function folderGlyph() {
return '';
}
//Small icon for list rows: a solid rounded tile carrying the extension
function smallGlyph(filename, isDir) {
if (isDir) {
return '
' + folderGlyph() + '
';
}
var ext = getExt(filename);
var color = getCategoryColor(ext);
var label = ext.substring(0, 4).toUpperCase();
var fontSize = label.length >= 4 ? 5.6 : (label.length == 3 ? 7 : 8.5);
return '';
}
//Large icon for grid tiles and preview panes: a document sheet with a
//coloured extension badge in the lower left corner
function largeGlyph(filename, isDir) {
if (isDir) {
return '
' + folderGlyph() + '
';
}
var ext = getExt(filename);
var color = getCategoryColor(ext);
var label = ext.substring(0, 4).toUpperCase();
var badgeWidth = 9 + label.length * 4.6;
var fontSize = label.length >= 4 ? 5.4 : 6.4;
/*
The viewBox starts at -1.6, not 0, so the glyph sits centred.
The sheet spans x 5.2..41.8 (its stroke included) but the extension
badge deliberately overhangs its left edge down to x=3, with nothing
balancing it on the right - so the drawn content's centre is 22.4,
not the 24 a 0..48 window would assume. Shifting the window rather
than the paths keeps every coordinate below unchanged, and the
badge's width does not affect it: the sheet always defines the right
edge and the badge always the left.
*/
return '';
}
/*
Icon envelope
A drawn glyph has to sit in the same envelope as the themed PNG icons in
img/desktop/files_icon/, or a folder holding both shows one file type
noticeably larger than its neighbours. Those PNGs put their artwork in
68x82 of a 128x128 canvas, which is the ratio below.
The content boxes are the glyphs' painted bounds in their own viewBox
units, stroke widths included - measured, not read off the path data,
because the sheet's 1.6 stroke and the badge's overhang both extend past
the coordinates written in the markup. They do not depend on the badge
label: the sheet always defines the right edge and the badge the left.
*/
var GLYPH_ENVELOPE_W = 68 / 128;
var GLYPH_ENVELOPE_H = 82 / 128;
var GLYPH_CONTENT = {
file: { x0: 2.90, y0: 2.63, x1: 41.77, y1: 58.25 },
folder: { x0: 2.00, y0: 4.00, x1: 45.88, y1: 35.88 }
};
/*
Same drawn glyph as largeGlyph(), but as a data: URI so it can be used as
an . The File Manager renders its grid tiles with a single
whose src is later swapped for a real thumbnail, so it needs the fallback
icon in that form rather than as inline markup.
The viewBox is replaced with a square one padded so the artwork lands in
the envelope above. Only this data: URI form is adjusted - largeGlyph()
is also used inline by the File Selector, where the surrounding CSS box
already sets the size.
*/
function glyphDataURL(filename, isDir) {
var markup = isDir ? folderGlyph() : largeGlyph(filename, isDir);
//largeGlyph/folderGlyph may be wrapped in a sizing div - take the svg
var start = markup.indexOf('