#!/usr/bin/env node /** * به‌روزرسانی یک‌بارهٔ پالت `style.json` نقشهٔ آفلاین روی MinIO. * * ترتیب کار (مهم): اول نسخهٔ فعلی خوانده و «پشتیبان» می‌شود — هم روی دیسک * (style.backup.json) و هم روی MinIO (maps/style.backup.json) — و تنها پس از * موفقیت پشتیبان، پالت تازه نوشته و آپلود می‌شود. * * چرا از S3 API و نه فایل روی دیسک؟ شیءهای MinIO با فرمت داخلی xl.meta ذخیره * می‌شوند، نه JSON خام؛ پس تنها راه درست به‌روزرسانی، PUT روی همان API است. * * اجرا (از پوشهٔ frontend) — هر کار با پرچم مشخص، پیش‌فرض هیچ تغییری نمی‌دهد: * node --experimental-strip-types scripts/update-map-palette.ts --fix-glyphs * node --experimental-strip-types scripts/update-map-palette.ts --palette * node --experimental-strip-types scripts/update-map-palette.ts --restore * ... [--dry-run] برای دیدن گزارش بدون نوشتن روی MinIO * * در همهٔ حالت‌ها (جز restore) ترتیب امن رعایت می‌شود: خواندن و اعتبارسنجی → * پشتیبان روی دیسک و MinIO → تغییر → اعتبارسنجی دوباره → آپلود. * * کلیدهای MinIO از فایل .env ریشهٔ پروژه خوانده می‌شوند (MINIO_ROOT_USER / * MINIO_ROOT_PASSWORD) و در نبودشان از متغیرهای محیطی همان نام‌ها. */ import { createHash, createHmac } from 'node:crypto' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' /* ------------------------------------------------------------------ */ /* پالت تازه */ /* ------------------------------------------------------------------ */ /** نگاشت دسته‌ها به رنگ‌های تازهٔ برند — تنها منبع حقیقت این اسکریپت */ const PALETTE = { background: '#181512', urban: '#27221c', highway: '#6a5544', street: '#4a3a2d', alley: '#352c25', water: '#172c32', parks: '#20312b', nature: '#314238', mainText: '#ead9c6', subText: '#a99584', borders: '#3e362e', } as const type Category = keyof typeof PALETTE /* ------------------------------------------------------------------ */ /* قواعد دسته‌بندی لایه‌ها */ /* ------------------------------------------------------------------ */ type Layer = { id: string type: string 'source-layer'?: string paint?: Record layout?: Record } type StyleDocument = { version?: number layers?: Layer[] sources?: Record /** الگوی نشانی گلیف‌ها — باید به پوشهٔ v2.0 باکت اشاره کند */ glyphs?: string } /** پوشهٔ فونت‌های آفلاین در باکت MinIO */ const GLYPHS_FOLDER = 'v2.0' /** الگوی استاندارد گلیف — جانگهدارها دست‌نخورده می‌مانند */ const GLYPH_TEMPLATE = '{fontstack}/{range}.pbf' /** * اصلاح مسیر گلیف‌ها: هر شکل نشانی (خالی، نسبی، یا با پوشهٔ اشتباه) به * «v2.0/{fontstack}/{range}.pbf» تبدیل می‌شود تا با ساختار پوشه‌های باکت * بخواند. تابع خالص و idempotent است؛ اگر مسیر از قبل درست باشد چیز تغییر نمی‌کند. */ function fixGlyphsPath(style: StyleDocument): { from: string; to: string } | null { const current = (style.glyphs ?? '').trim() const marker = current.indexOf('{fontstack}') const next = `${GLYPHS_FOLDER}/${marker >= 0 ? current.slice(marker) : GLYPH_TEMPLATE}` if (next === current) return null style.glyphs = next return { from: current || '(خالی)', to: next } } /** آیا مقدار text-field به یک فیلد «نام» اشاره می‌کند؟ (name، name_en، name:latin و…) */ const NAME_REFERENCE = /name(:[a-z-]+)?/i /** * فونت‌های محلی با پوشش فارسی — با اجرای «--audit-fonts» قابل بازبینی است. * تنها پوشه‌های Noto Sans در باکت گلیف عربی/فارسی دارند؛ Metropolis و بقیه * فقط لاتین‌اند. */ const PERSIAN_CAPABLE_FONTS = ['Noto Sans Regular', 'Noto Sans Italic', 'Noto Sans Bold'] /** * بازگردانی متن برچسب‌ها به قالب دوزبانهٔ اصلی، به‌تفکیک لایه. * (لاتین خط اول، فارسی خط دوم — یا کنار هم برای نام راه‌ها) */ const TEXT_FIELD_RESTORE: Record = { water_name: '{name:latin}\n{name:nonlatin}', place_other: '{name:latin}\n{name:nonlatin}', place_suburb: '{name:latin}\n{name:nonlatin}', place_village: '{name:latin}\n{name:nonlatin}', place_town: '{name:latin}\n{name:nonlatin}', place_city: '{name:latin}\n{name:nonlatin}', place_city_large: '{name:latin}\n{name:nonlatin}', place_state: '{name:latin}\n{name:nonlatin}', highway_name_other: '{name:latin} {name:nonlatin}', place_country_other: '{name:latin}', place_country_minor: '{name:latin}', place_country_major: '{name:latin}', } /** قالب پیش‌فرض برای هر لایهٔ برچسبِ دیگری که بعداً اضافه شود */ const DEFAULT_BILINGUAL_FIELD = '{name:latin}\n{name:nonlatin}' function referencesName(value: unknown): boolean { if (typeof value === 'string') return NAME_REFERENCE.test(value) if (Array.isArray(value)) return value.some(referencesName) if (value && typeof value === 'object') return Object.values(value).some(referencesName) return false } /** فونت فارسی متناسب با پشتهٔ همان لایه — ایتالیک بماند ایتالیک */ function persianFontFor(stack: string[]): string { const local = stack.find((font) => PERSIAN_CAPABLE_FONTS.includes(font)) if (local) return local return stack.some((font) => /italic/i.test(font)) ? 'Noto Sans Italic' : 'Noto Sans Regular' } type LabelChange = { id: string; field?: { from: string; to: string }; font: { from: string; to: string } } /** * آماده‌سازی برچسب‌ها برای فارسی: * ۱) متن به قالب دوزبانهٔ اصلی برمی‌گردد (لاتین + nonlatin که برای دادهٔ ایران فارسی است). * ۲) پشتهٔ فونت بازچینی می‌شود تا فونت دارای گلیف فارسی (Noto Sans محلی) اول بیفتد؛ * چون مپ‌لیبر برای هر پشته فقط یک فایل گلیف می‌گیرد، همان فونت اول باید هر دو خط * را پوشش دهد و Noto Sans لاتین را هم دارد. */ function fixLabels(style: StyleDocument): { changed: LabelChange[]; skipped: string[] } { const changed: LabelChange[] = [] const skipped: string[] = [] for (const layer of style.layers ?? []) { const layout = layer.layout if (layer.type !== 'symbol' || !layout) continue const stack = Array.isArray(layout['text-font']) ? (layout['text-font'] as unknown[]).map(String) : [] if (!stack.length) continue const persianFont = persianFontFor(stack) const restacked = [persianFont, ...stack.filter((font) => font !== persianFont)] const fontChange = { from: stack.join(', '), to: restacked.join(', ') } if (!('text-field' in layout)) { // لایهٔ برچسب بدون فیلد نام (مثل شمارهٔ راه) — فقط پشتهٔ فونت اصلاح می‌شود layout['text-font'] = restacked changed.push({ id: layer.id, font: fontChange }) continue } if (!referencesName(layout['text-field'])) { layout['text-font'] = restacked skipped.push(layer.id) continue } const from = JSON.stringify(layout['text-field']).slice(0, 48) const to = TEXT_FIELD_RESTORE[layer.id] ?? DEFAULT_BILINGUAL_FIELD layout['text-field'] = to layout['text-font'] = restacked changed.push({ id: layer.id, field: { from, to }, font: fontChange }) } return { changed, skipped } } /** * بررسی فونت‌ها: هر فونتی که در text-font لایه‌های برچسب استفاده شده باید در * پوشهٔ v2.0 باکت موجود باشد — هیچ نشانی بیرونی و هیچ فونت غایبی مجاز نیست. */ async function verifyFonts(style: StyleDocument): Promise { const fonts = new Set() for (const layer of style.layers ?? []) { if (layer.type !== 'symbol') continue const stack = layer.layout?.['text-font'] if (Array.isArray(stack)) for (const font of stack) if (typeof font === 'string') fonts.add(font) } if (!fonts.size) { console.log('• فونتی در لایه‌های برچسب پیدا نشد') return } console.log(`\nبررسی ${fonts.size} فونت در پوشهٔ ${GLYPHS_FOLDER}:`) const missing: string[] = [] for (const font of [...fonts].sort()) { const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/0-255.pbf`, ENDPOINT) const ok = (await fetch(url, { method: 'HEAD' })).ok if (!ok) missing.push(font) console.log(` ${ok ? '✓' : '✗'} ${font}`) } console.log( missing.length ? `✗ فونت‌های ناموجود در ${GLYPHS_FOLDER}: ${missing.join(', ')}` : `✓ همهٔ فونت‌ها در ${GLYPHS_FOLDER} باکت موجودند`, ) } /* ------------------------------------------------------------------ */ /* بررسی پوشش فونت‌های محلی (کدام فونت فارسی را رندر می‌کند؟) */ /* ------------------------------------------------------------------ */ /** بازه‌های کلیدی گلیف: لاتین پایه، عربی/فارسی، فرم‌های نمایشی عربی */ const COVERAGE_RANGES = [ { label: 'لاتین', range: '0-255' }, { label: 'فارسی', range: '1536-1791' }, { label: 'فرم فارسی', range: '65024-65279' }, ] as const /** فهرست پوشه‌های فونت داخل v2.0 باکت */ async function listFontFolders(): Promise { const response = await fetch( new URL(`/maps/?list-type=2&prefix=${GLYPHS_FOLDER}/&delimiter=/&max-keys=1000`, ENDPOINT), ) const xml = await response.text() const pattern = new RegExp(`${GLYPHS_FOLDER}/([^<]+)/`, 'g') return [...xml.matchAll(pattern)].map((match) => match[1]) } /** وضعیت یک بازهٔ گلیف برای یک فونت: «نیست» / «خالی» / حجم واقعی */ async function rangeCoverage(font: string, range: string): Promise { const url = new URL(`/maps/${GLYPHS_FOLDER}/${encodeURIComponent(font)}/${range}.pbf`, ENDPOINT) const response = await fetch(url, { method: 'HEAD' }) if (!response.ok) return 'نیست' const bytes = Number(response.headers.get('content-length') ?? 0) // بازهٔ بدون گلیف فقط سرصفحهٔ پروتوباف است (چند ده بایت) return bytes > 2000 ? `${Math.round(bytes / 1024)}KB` : 'خالی' } /** گزارش پوشش خط‌ها برای همهٔ فونت‌های باکت — ورودی تصمیم انتخاب فونت برچسب‌ها */ async function auditFonts(): Promise { const fonts = await listFontFolders() console.log(`${fonts.length} پوشهٔ فونت در ${GLYPHS_FOLDER}:\n`) for (const font of fonts) { const cells: string[] = [] for (const { label, range } of COVERAGE_RANGES) cells.push(`${label}=${await rangeCoverage(font, range)}`) console.log(` ${font.padEnd(26)} ${cells.join(' ')}`) } console.log('\nفونتی که «فارسی» و «فرم فارسی» آن خالی/نیست نباشد، برچسب فارسی را رندر می‌کند.') } /** * اعتبارسنجی ساختار سبک — پیش و پس از تغییر اجرا می‌شود تا مطمئن شویم سند * سالم است و اسکیمای سبک (نسخه، لایه‌ها، منابع) نشکسته است. */ function validateStyle(style: StyleDocument, label: string): void { const problems: string[] = [] if (style.version !== 8) problems.push(`version باید ۸ باشد (مقدار فعلی: ${String(style.version)})`) if (!Array.isArray(style.layers) || style.layers.length === 0) problems.push('layers باید آرایهٔ ناتهی باشد') else { const broken = style.layers.filter((layer) => !layer || typeof layer.id !== 'string' || typeof layer.type !== 'string') if (broken.length) problems.push(`${broken.length} لایه بدون id/type`) } if (!style.sources || typeof style.sources !== 'object' || Array.isArray(style.sources)) { problems.push('sources باید یک شیء باشد') } console.log( problems.length ? `✗ اعتبارسنجی ${label}: ${problems.join(' | ')}` : `✓ اعتبارسنجی ${label}: ساختار سالم است (${style.layers?.length ?? 0} لایه، ${Object.keys(style.sources ?? {}).length} منبع)`, ) if (problems.length) throw new Error(`ساختار سبک نامعتبر است (${label})`) } type Rule = { category: Category /** با این الگو روی شناسهٔ لایه تطبیق داده می‌شود */ test: RegExp /** کدام ویژگی‌های paint رنگ‌آمیزی شوند */ properties: string[] } /** * جدول قواعد — به ترتیب بررسی می‌شود و نخستین تطبیق برنده است. * رنگ‌های داخل عبارت‌ها (interpolate/match) هم بازنویسی می‌شوند تا پالت * یکدست بماند. */ const RULES: Rule[] = [ { category: 'background', test: /^background$/, properties: ['background-color'] }, // آب: خود لایه‌های آب و راه‌های آبی (برچسب‌ها قاعدهٔ جدا دارند) { category: 'water', test: /^(water|waterway)(_|$)/, properties: ['fill-color', 'line-color'] }, // پارک‌ها پیش از طبیعت بررسی می‌شوند تا چمن/پارک زیر «طبیعت» نرود { category: 'parks', test: /^(park|landcover_grass|landuse_(park|cemetery|pitch|grass))/, properties: ['fill-color', 'line-color'] }, // طبیعت: پوشش زمینِ جنگلی/علف‌زارِ بیرون از شهر { category: 'nature', test: /^landcover_/, properties: ['fill-color', 'line-color'] }, // کاربری شهری و ساختمان‌ها { category: 'urban', test: /^landuse_/, properties: ['fill-color', 'line-color'] }, { category: 'urban', test: /^building/, properties: ['fill-color'] }, // سه ردهٔ راه — بزرگراه، خیابان، کوچه/ریل/باند فرودگاه { category: 'highway', test: /^highway_motorway/, properties: ['line-color', 'fill-color'] }, { category: 'street', test: /^(highway_(major|minor)|road_)/, properties: ['line-color', 'fill-color'] }, { category: 'alley', test: /^(highway_(path|track)|railway|aeroway)/, properties: ['line-color', 'fill-color'] }, { category: 'borders', test: /^boundary/, properties: ['line-color', 'fill-color'] }, // برچسب‌های اصلی: شهر/شهرک و نام بزرگراه‌ها { category: 'mainText', test: /^(place_(city|town)|highway_name_motorway)/, properties: ['text-color'] }, // بقیهٔ برچسب‌ها (روستا، محله، نام خیابان، نام آب، نقاط) — متن فرعی { category: 'subText', test: /(_name|^place_|_label)/, properties: ['text-color'] }, ] /** ویژگی‌های فرعی که همیشه با رنگ پس‌زمینه هماهنگ می‌شوند تا خوانایی بماند */ const HALO_PROPERTIES = ['text-halo-color', 'icon-halo-color'] /** ویژگی‌های آیکون برچسب‌ها که با متن هم‌رنگ می‌شوند */ const ICON_PROPERTIES = ['icon-color'] const COLOR_PATTERN = /^(#|rgb|hsl)/i const isColor = (value: unknown): value is string => typeof value === 'string' && COLOR_PATTERN.test(value.trim()) /* ------------------------------------------------------------------ */ /* بازنویسی رنگ‌ها (توابع خالص) */ /* ------------------------------------------------------------------ */ /** هر رشتهٔ رنگی داخل یک مقدار paint — حتی داخل عبارت‌ها — را با رنگ تازه عوض می‌کند */ function paintColor(value: unknown, color: string, stats: { replaced: number; seen: Set }): unknown { if (isColor(value)) { stats.seen.add(value.trim()) stats.replaced += 1 return color } if (Array.isArray(value)) return value.map((item) => paintColor(item, color, stats)) if (value && typeof value === 'object') { const result: Record = {} for (const [key, inner] of Object.entries(value)) result[key] = paintColor(inner, color, stats) return result } return value } type LayerReport = { id: string; category: Category | null; replaced: number; colors: string[] } /** یک لایه را بر پایهٔ قواعد رنگ‌آمیزی می‌کند و گزارش تغییرها را برمی‌گرداند */ function recolorLayer(layer: Layer): LayerReport { const rule = RULES.find((candidate) => candidate.test.test(layer.id)) const stats = { replaced: 0, seen: new Set() } if (!layer.paint) return { id: layer.id, category: rule?.category ?? null, replaced: 0, colors: [] } if (rule) { for (const property of rule.properties) { if (!(property in layer.paint)) continue layer.paint[property] = paintColor(layer.paint[property], PALETTE[rule.category], stats) } } // لایه‌های برچسب: هاله و آیکون‌ها با پالت هماهنگ می‌شوند if (layer.type === 'symbol') { const textColor = rule?.category === 'mainText' || rule?.category === 'subText' ? PALETTE[rule.category] : PALETTE.subText if (rule?.category !== 'mainText' && rule?.category !== 'subText' && 'text-color' in layer.paint) { layer.paint['text-color'] = paintColor(layer.paint['text-color'], textColor, stats) } for (const property of [...HALO_PROPERTIES, ...ICON_PROPERTIES]) { if (!(property in layer.paint)) continue const target = property === 'icon-color' ? textColor : PALETTE.background layer.paint[property] = paintColor(layer.paint[property], target, stats) } } // لبهٔ ساختمان‌ها با رنگ مرزها هم‌خانواده می‌شود if (layer.paint['fill-outline-color'] && /^building/.test(layer.id)) { layer.paint['fill-outline-color'] = paintColor(layer.paint['fill-outline-color'], PALETTE.borders, stats) } return { id: layer.id, category: rule?.category ?? null, replaced: stats.replaced, colors: [...stats.seen] } } /** کل سند سبک را بازنویسی می‌کند (بدون دست‌زدن به ساختار و ویژگی‌های غیررنگی) */ function recolorStyle(style: StyleDocument): LayerReport[] { const layers = style.layers ?? [] return layers.map(recolorLayer) } /** گزارش فشردهٔ تغییرهای پالت — به‌تفکیک دسته و لایه‌های دست‌نخورده */ function reportPalette(reports: LayerReport[]): void { const byCategory = new Map() for (const report of reports) { if (!report.replaced) continue const category = report.category ?? 'بدون‌دسته' byCategory.set(category, (byCategory.get(category) ?? 0) + report.replaced) } console.log('\nگزارش تغییرها:') for (const [category, count] of [...byCategory].sort((a, b) => b[1] - a[1])) { console.log(` ${category.padEnd(12)} ${String(count).padStart(3)} مقدار رنگ`) } const untouched = reports.filter((report) => !report.replaced) if (untouched.length) { console.log(`\nلایه‌های بدون تغییر (${untouched.length}): ${untouched.map((report) => report.id).join(', ')}`) } } /* ------------------------------------------------------------------ */ /* دسترسی به MinIO (S3 Signature V4 — بدون وابستگی تازه) */ /* ------------------------------------------------------------------ */ type Credentials = { user: string; secret: string } async function readCredentials(): Promise { const envPath = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '.env') const fromFile: Record = {} try { for (const line of (await readFile(envPath, 'utf8')).split(/\r?\n/)) { const match = line.match(/^([A-Z0-9_]+)=(.*)$/) if (match) fromFile[match[1]] = match[2].trim() } } catch { /* فایل .env اختیاری است — متغیرهای محیطی جایگزین می‌شوند */ } const user = process.env.MINIO_ROOT_USER || fromFile.MINIO_ROOT_USER || 'aramesh' const secret = process.env.MINIO_ROOT_PASSWORD || fromFile.MINIO_ROOT_PASSWORD if (!secret) throw new Error('MINIO_ROOT_PASSWORD پیدا نشد (نه در .env و نه در متغیرهای محیطی)') return { user, secret } } const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex') const hmac = (key: string | Buffer, value: string) => createHmac('sha256', key).update(value).digest() const hmacHex = (key: Buffer, value: string) => createHmac('sha256', key).update(value).digest('hex') /** PUT امضاشدهٔ S3 — همان چیزی که کنسول MinIO و پنل ادمین هم انجام می‌دهند */ async function putObject(endpoint: string, objectPath: string, body: string, credentials: Credentials): Promise { const url = new URL(objectPath, endpoint) const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '') const dateStamp = amzDate.slice(0, 8) const payloadHash = sha256(body) const canonicalHeaders = `host:${url.host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n` const signedHeaders = 'host;x-amz-content-sha256;x-amz-date' const canonicalRequest = ['PUT', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n') const scope = `${dateStamp}/us-east-1/s3/aws4_request` const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256(canonicalRequest)].join('\n') const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.secret}`, dateStamp), 'us-east-1'), 's3'), 'aws4_request') const authorization = `AWS4-HMAC-SHA256 Credential=${credentials.user}/${scope}, ` + `SignedHeaders=${signedHeaders}, Signature=${hmacHex(signingKey, stringToSign)}` const response = await fetch(url, { method: 'PUT', headers: { Authorization: authorization, 'x-amz-content-sha256': payloadHash, 'x-amz-date': amzDate, 'Content-Type': 'application/json', }, body, }) if (!response.ok) throw new Error(`PUT ${objectPath} → HTTP ${response.status} ${await response.text()}`) } /* ------------------------------------------------------------------ */ /* اجرا */ /* ------------------------------------------------------------------ */ const ENDPOINT = process.env.MAP_ENDPOINT ?? 'http://localhost:19000' const STYLE_PATH = '/maps/style.json' const BACKUP_PATH = '/maps/style.backup.json' const OUTPUT_DIR = join(dirname(fileURLToPath(import.meta.url)), 'map-palette') const LOCAL_BACKUP = join(OUTPUT_DIR, 'style.backup.json') /** بازگردانی: پشتیبان دقیقاً به‌جای style.json نوشته می‌شود */ async function restore(): Promise { const backup = await readFile(LOCAL_BACKUP, 'utf8') validateStyle(JSON.parse(backup) as StyleDocument, 'نسخهٔ پشتیبان') await putObject(ENDPOINT, STYLE_PATH, backup, await readCredentials()) const restored = await (await fetch(new URL(STYLE_PATH, ENDPOINT))).text() console.log( restored === backup ? '✓ style.json دقیقاً به نسخهٔ پشتیبان برگشت' : '✗ محتوای بازگردانده‌شده با پشتیبان یکسان نیست — دستی بررسی کنید', ) } const ACTIONS = ['--audit-fonts', '--fix-labels', '--fix-glyphs', '--palette', '--restore'] as const async function main(): Promise { const dryRun = process.argv.includes('--dry-run') const action = ACTIONS.find((flag) => process.argv.includes(flag)) // پیش‌فرض امن: بدون پرچم هیچ نوشتنی انجام نمی‌شود if (!action) { console.log( 'کاری انجام نشد — یکی از پرچم‌ها را بدهید:\n' + ' --audit-fonts گزارش پوشش خط‌های هر فونت در v2.0 (فقط خواندن)\n' + ' --fix-labels برچسب‌های دوزبانه + پشتهٔ فونت محلی\n' + ' --fix-glyphs اصلاح مسیر گلیف‌ها به v2.0\n' + ' --palette اعمال پالت تازه\n' + ' --restore بازگردانی از پشتیبان\n' + ' [--dry-run] فقط گزارش، بدون نوشتن روی MinIO', ) return } if (action === '--audit-fonts') { await auditFonts() return } if (action === '--restore') { await restore() return } // ۱) خواندن سبک فعلی const response = await fetch(new URL(STYLE_PATH, ENDPOINT)) if (!response.ok) throw new Error(`GET ${STYLE_PATH} → HTTP ${response.status}`) const original = await response.text() const style = JSON.parse(original) as StyleDocument console.log(`سبک فعلی خوانده شد — ${(original.length / 1024).toFixed(1)} کیلوبایت`) validateStyle(style, 'نسخهٔ فعلی') // ۲) پشتیبان‌گیری، پیش از هر تغییر await mkdir(OUTPUT_DIR, { recursive: true }) await writeFile(LOCAL_BACKUP, original, 'utf8') console.log(`پشتیبان روی دیسک: scripts/map-palette/style.backup.json`) if (!dryRun) { await putObject(ENDPOINT, BACKUP_PATH, original, await readCredentials()) console.log(`پشتیبان روی MinIO: ${BACKUP_PATH}`) } // ۳) تغییر درخواستی، سپس اعتبارسنجی دوباره if (action === '--fix-labels') { const { changed, skipped } = fixLabels(style) console.log(`\nلایه‌های برچسب اصلاح‌شده (${changed.length}):`) for (const layer of changed) { if (layer.field) console.log(` ${layer.id}\n متن: ${layer.field.from} → ${JSON.stringify(layer.field.to)}`) console.log(` فونت: ${layer.font.from} → ${layer.font.to}`) } if (skipped.length) { console.log(`\nلایه‌های بدون فیلد نام (فقط فونت اصلاح شد): ${skipped.join(', ')}`) } await verifyFonts(style) } else if (action === '--fix-glyphs') { const change = fixGlyphsPath(style) console.log( change ? `\nمسیر گلیف‌ها: ${change.from} → ${change.to}` : '\nمسیر گلیف‌ها از قبل به v2.0 اشاره می‌کند', ) await verifyFonts(style) } else { reportPalette(recolorStyle(style)) } validateStyle(style, 'نسخهٔ تازه') // ۴) نوشتن و آپلود نسخهٔ تازه const updated = JSON.stringify(style, null, 2) await writeFile(join(OUTPUT_DIR, 'style.json'), updated, 'utf8') if (dryRun) { console.log('\nحالت آزمایشی — هیچ چیزی روی MinIO نوشته نشد.') return } await putObject(ENDPOINT, STYLE_PATH, updated, await readCredentials()) console.log(`\nسبک تازه روی MinIO نوشته شد: ${STYLE_PATH}`) } main().catch((error: unknown) => { console.error('\nخطا:', error instanceof Error ? error.message : error) process.exitCode = 1 })