update at 2026-03-17 10:37:27
This commit is contained in:
@@ -5,7 +5,7 @@ ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
CALENDAR_DIR="$ROOT_DIR/calendar"
|
||||
DIST_DIR="$CALENDAR_DIR/dist"
|
||||
PORT=${PORT:-4173}
|
||||
URL=${1:-"http://127.0.0.1:$PORT/?mode=background"}
|
||||
URL=${1:-"http://127.0.0.1:$PORT/?mode=background&theme=default&orientation=portrait"}
|
||||
OUT_PNG=${2:-"$DIST_DIR/kindlebg.png"}
|
||||
OUT_REGION=${3:-"$DIST_DIR/clock-region.json"}
|
||||
|
||||
|
||||
@@ -27,16 +27,28 @@ enum ExportError: Error, CustomStringConvertible {
|
||||
}
|
||||
|
||||
final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
private enum ExportOrientation {
|
||||
case portrait
|
||||
case landscape
|
||||
}
|
||||
|
||||
private struct ExportLayout {
|
||||
let orientation: ExportOrientation
|
||||
let viewportSize: CGSize
|
||||
let outputSize: CGSize
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let pngOutputURL: URL
|
||||
private let regionOutputURL: URL
|
||||
private let completion: (Result<Void, Error>) -> Void
|
||||
// 直接按 Kindle Voyage 的系统屏保尺寸导出,避免额外旋转和补边。
|
||||
private let targetSize = CGSize(width: 1072, height: 1448)
|
||||
// 网页渲染尺寸和 Kindle framebuffer 输出尺寸不是一回事。
|
||||
// landscape 需要先按 1448x1072 渲染,再旋转成 1072x1448 输出给设备。
|
||||
private let layout: ExportLayout
|
||||
|
||||
private lazy var window: NSWindow = {
|
||||
let window = NSWindow(
|
||||
contentRect: CGRect(origin: .zero, size: targetSize),
|
||||
contentRect: CGRect(origin: .zero, size: layout.viewportSize),
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
@@ -48,7 +60,7 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
|
||||
private lazy var webView: WKWebView = {
|
||||
let config = WKWebViewConfiguration()
|
||||
let view = WKWebView(frame: CGRect(origin: .zero, size: targetSize), configuration: config)
|
||||
let view = WKWebView(frame: CGRect(origin: .zero, size: layout.viewportSize), configuration: config)
|
||||
view.navigationDelegate = self
|
||||
return view
|
||||
}()
|
||||
@@ -58,9 +70,29 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
self.pngOutputURL = pngOutputURL
|
||||
self.regionOutputURL = regionOutputURL
|
||||
self.completion = completion
|
||||
self.layout = SnapshotExporter.resolveLayout(url: url)
|
||||
super.init()
|
||||
}
|
||||
|
||||
private static func resolveLayout(url: URL) -> ExportLayout {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
let orientation = components?.queryItems?.first(where: { $0.name == "orientation" })?.value
|
||||
|
||||
if orientation == "landscape" {
|
||||
return ExportLayout(
|
||||
orientation: .landscape,
|
||||
viewportSize: CGSize(width: 1448, height: 1072),
|
||||
outputSize: CGSize(width: 1072, height: 1448)
|
||||
)
|
||||
}
|
||||
|
||||
return ExportLayout(
|
||||
orientation: .portrait,
|
||||
viewportSize: CGSize(width: 1072, height: 1448),
|
||||
outputSize: CGSize(width: 1072, height: 1448)
|
||||
)
|
||||
}
|
||||
|
||||
func start() {
|
||||
window.contentView = webView
|
||||
window.orderBack(nil)
|
||||
@@ -79,12 +111,22 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
(() => {
|
||||
const node = document.querySelector('[data-clock-region="true"]');
|
||||
if (!node) return null;
|
||||
const dashboard = document.querySelector('.dashboard-frame');
|
||||
const rect = node.getBoundingClientRect();
|
||||
const frameRect = dashboard ? dashboard.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
||||
return {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
clock: {
|
||||
x: Math.round(rect.left),
|
||||
y: Math.round(rect.top),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
},
|
||||
frame: {
|
||||
x: Math.round(frameRect.left),
|
||||
y: Math.round(frameRect.top),
|
||||
width: Math.round(frameRect.width),
|
||||
height: Math.round(frameRect.height)
|
||||
}
|
||||
};
|
||||
})();
|
||||
"""
|
||||
@@ -95,14 +137,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
guard let region = value as? [String: NSNumber] else {
|
||||
guard
|
||||
let result = value as? [String: Any],
|
||||
let region = result["clock"] as? [String: NSNumber],
|
||||
let frame = result["frame"] as? [String: NSNumber]
|
||||
else {
|
||||
self.finish(.failure(ExportError.clockRegionMissing))
|
||||
return
|
||||
}
|
||||
|
||||
let snapshotConfig = WKSnapshotConfiguration()
|
||||
snapshotConfig.rect = CGRect(origin: .zero, size: self.targetSize)
|
||||
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.targetSize.width))
|
||||
snapshotConfig.rect = CGRect(origin: .zero, size: self.layout.viewportSize)
|
||||
snapshotConfig.snapshotWidth = NSNumber(value: Float(self.layout.viewportSize.width))
|
||||
|
||||
self.webView.takeSnapshot(with: snapshotConfig) { image, snapshotError in
|
||||
if let snapshotError {
|
||||
@@ -116,8 +162,8 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
|
||||
do {
|
||||
try self.savePNG(image: image, to: self.pngOutputURL)
|
||||
try self.saveRegion(region: region)
|
||||
try self.savePNG(image: image, to: self.pngOutputURL, frameOffset: frame)
|
||||
try self.saveRegion(region: region, frameOffset: frame)
|
||||
self.finish(.success(()))
|
||||
} catch {
|
||||
self.finish(.failure(error))
|
||||
@@ -126,18 +172,18 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func savePNG(image: NSImage, to url: URL) throws {
|
||||
let normalizedImage = NSImage(size: NSSize(width: targetSize.width, height: targetSize.height))
|
||||
private func savePNG(image: NSImage, to url: URL, frameOffset: [String: NSNumber]) throws {
|
||||
let normalizedImage = NSImage(size: NSSize(width: layout.viewportSize.width, height: layout.viewportSize.height))
|
||||
normalizedImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: targetSize))
|
||||
image.draw(in: NSRect(origin: .zero, size: layout.viewportSize))
|
||||
normalizedImage.unlockFocus()
|
||||
|
||||
guard let sourceCGImage = normalizedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ExportError.pngEncodingFailed(url.path)
|
||||
}
|
||||
|
||||
let width = Int(targetSize.width)
|
||||
let height = Int(targetSize.height)
|
||||
let width = Int(layout.outputSize.width)
|
||||
let height = Int(layout.outputSize.height)
|
||||
let colorSpace = CGColorSpaceCreateDeviceGray()
|
||||
|
||||
// 输出 8-bit 灰度 PNG,但页面本身仍按纯白底和纯黑字设计,避免额外灰阶装饰。
|
||||
@@ -156,7 +202,38 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
context.setFillColor(gray: 1, alpha: 1)
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.interpolationQuality = .high
|
||||
context.draw(sourceCGImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let offsetX = CGFloat(frameOffset["x"]?.doubleValue ?? 0)
|
||||
let offsetY = CGFloat(frameOffset["y"]?.doubleValue ?? 0)
|
||||
|
||||
// Kindle framebuffer 固定是纵向;landscape 导出时这里把横向截图旋转成设备图。
|
||||
switch layout.orientation {
|
||||
case .portrait:
|
||||
context.draw(
|
||||
sourceCGImage,
|
||||
in: CGRect(
|
||||
x: -offsetX,
|
||||
y: -offsetY,
|
||||
width: layout.viewportSize.width,
|
||||
height: layout.viewportSize.height
|
||||
)
|
||||
)
|
||||
case .landscape:
|
||||
context.saveGState()
|
||||
// 横向网页需要先顺时针旋转 90 度写入纵向 framebuffer,
|
||||
// 这样 Kindle 在 logo_right 摆放时,最终看到的页面方向才是正的。
|
||||
context.translateBy(x: 0, y: layout.outputSize.height)
|
||||
context.rotate(by: -.pi / 2)
|
||||
context.draw(
|
||||
sourceCGImage,
|
||||
in: CGRect(
|
||||
x: -offsetX,
|
||||
y: -offsetY,
|
||||
width: layout.viewportSize.width,
|
||||
height: layout.viewportSize.height
|
||||
)
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
guard let grayscaleImage = context.makeImage() else {
|
||||
throw ExportError.pngEncodingFailed(url.path)
|
||||
@@ -180,18 +257,50 @@ final class SnapshotExporter: NSObject, WKNavigationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveRegion(region: [String: NSNumber]) throws {
|
||||
private func saveRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) throws {
|
||||
let transformedRegion = transformRegion(region: region, frameOffset: frameOffset)
|
||||
let jsonObject: [String: Int] = [
|
||||
"x": region["x"]?.intValue ?? 0,
|
||||
"y": region["y"]?.intValue ?? 0,
|
||||
"width": region["width"]?.intValue ?? 0,
|
||||
"height": region["height"]?.intValue ?? 0,
|
||||
"x": transformedRegion["x"]?.intValue ?? 0,
|
||||
"y": transformedRegion["y"]?.intValue ?? 0,
|
||||
"width": transformedRegion["width"]?.intValue ?? 0,
|
||||
"height": transformedRegion["height"]?.intValue ?? 0,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject, options: [.prettyPrinted, .sortedKeys])
|
||||
try FileManager.default.createDirectory(at: regionOutputURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try data.write(to: regionOutputURL)
|
||||
}
|
||||
|
||||
private func transformRegion(region: [String: NSNumber], frameOffset: [String: NSNumber]) -> [String: NSNumber] {
|
||||
let normalizedX = CGFloat(region["x"]?.doubleValue ?? 0) - CGFloat(frameOffset["x"]?.doubleValue ?? 0)
|
||||
let normalizedY = CGFloat(region["y"]?.doubleValue ?? 0) - CGFloat(frameOffset["y"]?.doubleValue ?? 0)
|
||||
let normalizedRegion: [String: NSNumber] = [
|
||||
"x": NSNumber(value: Int(round(normalizedX))),
|
||||
"y": NSNumber(value: Int(round(normalizedY))),
|
||||
"width": region["width"] ?? 0,
|
||||
"height": region["height"] ?? 0,
|
||||
]
|
||||
|
||||
guard layout.orientation == .landscape else {
|
||||
return normalizedRegion
|
||||
}
|
||||
|
||||
let x = CGFloat(normalizedRegion["x"]?.doubleValue ?? 0)
|
||||
let y = CGFloat(normalizedRegion["y"]?.doubleValue ?? 0)
|
||||
let width = CGFloat(normalizedRegion["width"]?.doubleValue ?? 0)
|
||||
let height = CGFloat(normalizedRegion["height"]?.doubleValue ?? 0)
|
||||
|
||||
// “logo 在右侧”对应设备图坐标系下,横向网页需要顺时针旋转 90 度写入 framebuffer。
|
||||
let transformedX = layout.viewportSize.height - (y + height)
|
||||
let transformedY = x
|
||||
|
||||
return [
|
||||
"x": NSNumber(value: Int(round(transformedX))),
|
||||
"y": NSNumber(value: Int(round(transformedY))),
|
||||
"width": NSNumber(value: Int(round(height))),
|
||||
"height": NSNumber(value: Int(round(width))),
|
||||
]
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<Void, Error>) {
|
||||
completion(result)
|
||||
NSApplication.shared.terminate(nil)
|
||||
|
||||
175
calendar/scripts/export-theme-backgrounds.sh
Normal file
175
calendar/scripts/export-theme-backgrounds.sh
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
CALENDAR_DIR="$ROOT_DIR/calendar"
|
||||
DIST_DIR="$CALENDAR_DIR/dist"
|
||||
PORT=${PORT:-4173}
|
||||
SWIFT_SCRIPT="$CALENDAR_DIR/scripts/export-kindle-background.swift"
|
||||
THEMES_SOURCE="$CALENDAR_DIR/config/themes.json"
|
||||
|
||||
THEME_FILTER=""
|
||||
ORIENTATION_FILTER=""
|
||||
|
||||
print_usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
sh scripts/export-theme-backgrounds.sh [选项]
|
||||
|
||||
选项:
|
||||
--theme <theme-id> 只导出指定主题
|
||||
--orientation <value> 只导出指定方向;必须和 --theme 一起使用
|
||||
-h, --help 查看帮助
|
||||
|
||||
示例:
|
||||
sh scripts/export-theme-backgrounds.sh
|
||||
sh scripts/export-theme-backgrounds.sh --theme simple
|
||||
sh scripts/export-theme-backgrounds.sh --theme simple --orientation portrait
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--theme)
|
||||
shift
|
||||
THEME_FILTER=${1:?"missing theme id"}
|
||||
;;
|
||||
--orientation)
|
||||
shift
|
||||
ORIENTATION_FILTER=${1:?"missing orientation"}
|
||||
;;
|
||||
-h|--help)
|
||||
print_usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1" >&2
|
||||
echo >&2
|
||||
print_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -n "$ORIENTATION_FILTER" ] && [ -z "$THEME_FILTER" ]; then
|
||||
echo "--orientation 必须和 --theme 一起使用。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
selection_output=$(
|
||||
node --input-type=module -e "
|
||||
import fs from 'node:fs';
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
|
||||
const requestedTheme = process.argv[2];
|
||||
const requestedOrientation = process.argv[3];
|
||||
const themes = data.themes ?? [];
|
||||
const themeMap = new Map(themes.map((theme) => [theme.id, theme]));
|
||||
|
||||
if (requestedTheme && !themeMap.has(requestedTheme)) {
|
||||
console.error(\`未知主题: \${requestedTheme}\`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (requestedOrientation && !requestedTheme) {
|
||||
console.error('--orientation 必须和 --theme 一起使用。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filteredThemes = requestedTheme ? themes.filter((theme) => theme.id === requestedTheme) : themes;
|
||||
const items = [];
|
||||
|
||||
for (const theme of filteredThemes) {
|
||||
const orientations = Object.keys(theme.variants ?? {});
|
||||
if (requestedOrientation) {
|
||||
if (!orientations.includes(requestedOrientation)) {
|
||||
console.error(
|
||||
\`主题 \${theme.id} 不支持方向 \${requestedOrientation},可用方向: \${orientations.join(', ')}\`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredOrientations = requestedOrientation ? [requestedOrientation] : orientations;
|
||||
for (const orientation of filteredOrientations) {
|
||||
const variant = theme.variants[orientation];
|
||||
items.push([theme.id, orientation, variant.backgroundPath].join('\t'));
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
console.error('没有可导出的主题背景。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(\`DEFAULT_THEME_ID=\${data.defaultThemeId}\`);
|
||||
console.log(\`DEFAULT_ORIENTATION=\${data.defaultOrientation}\`);
|
||||
for (const item of items) {
|
||||
console.log(\`ITEM=\${item}\`);
|
||||
}
|
||||
" "$THEMES_SOURCE" "$THEME_FILTER" "$ORIENTATION_FILTER"
|
||||
)
|
||||
|
||||
DEFAULT_THEME_ID=""
|
||||
DEFAULT_ORIENTATION=""
|
||||
EXPORT_ITEMS=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
DEFAULT_THEME_ID=*)
|
||||
DEFAULT_THEME_ID=${line#DEFAULT_THEME_ID=}
|
||||
;;
|
||||
DEFAULT_ORIENTATION=*)
|
||||
DEFAULT_ORIENTATION=${line#DEFAULT_ORIENTATION=}
|
||||
;;
|
||||
ITEM=*)
|
||||
if [ -n "$EXPORT_ITEMS" ]; then
|
||||
EXPORT_ITEMS="${EXPORT_ITEMS}
|
||||
${line#ITEM=}"
|
||||
else
|
||||
EXPORT_ITEMS=${line#ITEM=}
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done <<EOF
|
||||
$selection_output
|
||||
EOF
|
||||
|
||||
if [ -z "$DEFAULT_THEME_ID" ] || [ -z "$DEFAULT_ORIENTATION" ] || [ -z "$EXPORT_ITEMS" ]; then
|
||||
echo "无法解析导出目标。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$CALENDAR_DIR"
|
||||
npm run build >/dev/null
|
||||
|
||||
python3 -m http.server "$PORT" -d "$DIST_DIR" >/tmp/kindle-calendar-http.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT INT TERM
|
||||
|
||||
sleep 1
|
||||
|
||||
printf '%s\n' "$EXPORT_ITEMS" | while IFS="$(printf '\t')" read -r theme_id orientation background_path; do
|
||||
out_png="$DIST_DIR/$background_path"
|
||||
out_region="${out_png%.png}.clock-region.json"
|
||||
url="http://127.0.0.1:$PORT/?mode=background&theme=$theme_id&orientation=$orientation"
|
||||
/usr/bin/swift "$SWIFT_SCRIPT" "$url" "$out_png" "$out_region" >/dev/null
|
||||
|
||||
# 根目录的 kindlebg.png / clock-region.json 只给默认主题兜底使用。
|
||||
# 定向导出其它主题时不覆盖它,避免把默认主题的运行时入口意外改掉。
|
||||
if [ "$theme_id" = "$DEFAULT_THEME_ID" ] && [ "$orientation" = "$DEFAULT_ORIENTATION" ]; then
|
||||
cp "$out_png" "$DIST_DIR/kindlebg.png"
|
||||
cp "$out_region" "$DIST_DIR/clock-region.json"
|
||||
fi
|
||||
|
||||
printf 'Exported %s %s -> %s\n' "$theme_id" "$orientation" "$out_png"
|
||||
done
|
||||
|
||||
node "$CALENDAR_DIR/scripts/generate-dashboard-manifest.mjs" >/dev/null
|
||||
|
||||
if [ -f "$DIST_DIR/clock-region.json" ]; then
|
||||
printf 'Default region saved to %s\n' "$DIST_DIR/clock-region.json"
|
||||
else
|
||||
printf 'Skipped default region update (default theme not exported this run)\n'
|
||||
fi
|
||||
@@ -6,13 +6,28 @@ const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(currentDir, '../dist');
|
||||
const manifestPath = path.join(distDir, 'dashboard-manifest.json');
|
||||
const clockRegionPath = path.join(distDir, 'clock-region.json');
|
||||
const themesSourcePath = path.resolve(currentDir, '../config/themes.json');
|
||||
const themesDistPath = path.join(distDir, 'themes.json');
|
||||
const themesDir = path.join(distDir, 'themes');
|
||||
const dashboardBaseUrl = 'https://shell.biboer.cn:20001';
|
||||
|
||||
const defaultClockRegion = {
|
||||
x: 313,
|
||||
y: 0,
|
||||
width: 220,
|
||||
height: 220,
|
||||
};
|
||||
const themesSource = JSON.parse(fs.readFileSync(themesSourcePath, 'utf8'));
|
||||
const generatedAt = new Date().toISOString();
|
||||
const defaultVariant = themesSource.themes.find((theme) => theme.id === themesSource.defaultThemeId)?.variants?.[themesSource.defaultOrientation];
|
||||
const defaultDeviceClock = defaultVariant ? toDeviceClock(defaultVariant, themesSource.defaultOrientation) : null;
|
||||
const defaultClockRegion = defaultVariant
|
||||
? {
|
||||
x: defaultDeviceClock.x,
|
||||
y: defaultDeviceClock.y,
|
||||
width: defaultDeviceClock.width,
|
||||
height: defaultDeviceClock.height,
|
||||
}
|
||||
: {
|
||||
x: 313,
|
||||
y: 0,
|
||||
width: 220,
|
||||
height: 220,
|
||||
};
|
||||
|
||||
const clockRegion = fs.existsSync(clockRegionPath)
|
||||
? {
|
||||
@@ -22,10 +37,15 @@ const clockRegion = fs.existsSync(clockRegionPath)
|
||||
: defaultClockRegion;
|
||||
|
||||
const manifest = {
|
||||
theme: {
|
||||
id: themesSource.defaultThemeId,
|
||||
orientation: themesSource.defaultOrientation,
|
||||
themesUrl: `${dashboardBaseUrl}/themes.json`,
|
||||
},
|
||||
background: {
|
||||
path: 'kindlebg.png',
|
||||
url: 'https://shell.biboer.cn:20001/kindlebg.png',
|
||||
updatedAt: new Date().toISOString(),
|
||||
url: `${dashboardBaseUrl}/kindlebg.png`,
|
||||
updatedAt: generatedAt,
|
||||
refreshIntervalMinutes: 120,
|
||||
},
|
||||
clockRegion,
|
||||
@@ -45,6 +65,67 @@ const manifest = {
|
||||
},
|
||||
};
|
||||
|
||||
const themesIndex = {
|
||||
updatedAt: generatedAt,
|
||||
defaultThemeId: themesSource.defaultThemeId,
|
||||
defaultOrientation: themesSource.defaultOrientation,
|
||||
themes: themesSource.themes.map((theme) => ({
|
||||
id: theme.id,
|
||||
label: theme.label,
|
||||
configUrl: `${dashboardBaseUrl}/themes/${theme.id}.json`,
|
||||
orientations: Object.keys(theme.variants),
|
||||
})),
|
||||
};
|
||||
|
||||
function toDeviceClock(variant, orientation) {
|
||||
if (orientation !== 'landscape') {
|
||||
return {
|
||||
...variant.clock,
|
||||
rotationDegrees: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...variant.clock,
|
||||
x: variant.viewport.height - (variant.clock.y + variant.clock.height),
|
||||
y: variant.clock.x,
|
||||
width: variant.clock.height,
|
||||
height: variant.clock.width,
|
||||
rotationDegrees: 90,
|
||||
};
|
||||
}
|
||||
|
||||
function buildThemeConfig(theme) {
|
||||
return {
|
||||
id: theme.id,
|
||||
label: theme.label,
|
||||
updatedAt: generatedAt,
|
||||
variants: Object.fromEntries(
|
||||
Object.entries(theme.variants).map(([orientation, variant]) => [
|
||||
orientation,
|
||||
{
|
||||
devicePlacement: variant.devicePlacement,
|
||||
background: {
|
||||
path: variant.backgroundPath,
|
||||
url: `${dashboardBaseUrl}/${variant.backgroundPath}`,
|
||||
refreshIntervalMinutes: 120,
|
||||
},
|
||||
clock: toDeviceClock(variant, orientation),
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.mkdirSync(themesDir, { recursive: true });
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
|
||||
fs.writeFileSync(themesDistPath, `${JSON.stringify(themesIndex, null, 2)}\n`, 'utf8');
|
||||
|
||||
for (const theme of themesSource.themes) {
|
||||
const themePath = path.join(themesDir, `${theme.id}.json`);
|
||||
fs.writeFileSync(themePath, `${JSON.stringify(buildThemeConfig(theme), null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
console.log(`Wrote ${manifestPath}`);
|
||||
console.log(`Wrote ${themesDistPath}`);
|
||||
|
||||
Reference in New Issue
Block a user