diff --git a/package-lock.json b/package-lock.json index 3f19302b..db10eebe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "front-matter": "^4.0.2", "fs-extra": "^11.3.2", "gamedig": "^5.3.2", + "glob": "^13.0.6", "highlight.js": "^11.11.1", "ioredis": "^5.8.2", "js-yaml": "^4.1.1", @@ -5486,6 +5487,59 @@ "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "license": "ISC" }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", + "integrity": "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==", + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -6929,6 +6983,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", @@ -7208,6 +7271,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -7927,6 +7999,22 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", diff --git a/package.json b/package.json index c1375a84..daa2e21e 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "front-matter": "^4.0.2", "fs-extra": "^11.3.2", "gamedig": "^5.3.2", + "glob": "^13.0.6", "highlight.js": "^11.11.1", "ioredis": "^5.8.2", "js-yaml": "^4.1.1", diff --git a/scripts/check-translations.js b/scripts/check-translations.js new file mode 100644 index 00000000..6e32f563 --- /dev/null +++ b/scripts/check-translations.js @@ -0,0 +1,195 @@ +//node scripts/check-translations.js + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; +import yaml from "js-yaml"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); + +const srcDir = path.join(projectRoot, "src"); +const localesDir = path.join(srcDir, "lib", "locales"); + +const SUPPORTED_FORMATS = new Set(["json", "yaml"]); + +const SOURCE_GLOBS = ["src/**/*.{svelte,ts,js,mts,cts}", "!src/lib/locales/**/*.json", "!src/**/*.d.ts"]; + +const TRANSLATION_CALL_REGEX = /\$t\s*\(\s*(["'`])([\s\S]*?)\1\s*(?:,|\))/g; + +const COMMENT_PATTERNS = [/\/\*[\s\S]*?\*\//g, /\/\/[^\n\r]*/g, //g]; + +function decodeQuotedContent(value) { + return value + .replace(/\\\\/g, "\\") + .replace(/\\n/g, "\n") + .replace(/\\r/g, "\r") + .replace(/\\t/g, "\t") + .replace(/\\"/g, '"') + .replace(/\\'/g, "'") + .replace(/\\`/g, "`"); +} + +function getLineNumber(text, index) { + let line = 1; + for (let i = 0; i < index; i += 1) { + if (text[i] === "\n") { + line += 1; + } + } + return line; +} + +function maskComments(content) { + let result = content; + for (const pattern of COMMENT_PATTERNS) { + result = result.replace(pattern, (match) => match.replace(/[^\n]/g, " ")); + } + return result; +} + +function getUsedTranslationKeys() { + const files = globSync(SOURCE_GLOBS, { + cwd: projectRoot, + nodir: true, + ignore: ["**/node_modules/**", "**/.svelte-kit/**", "**/build/**", "**/dist/**"], + }); + + const usedKeys = new Set(); + const skippedDynamicCalls = []; + + for (const relativePath of files) { + const absolutePath = path.join(projectRoot, relativePath); + const content = fs.readFileSync(absolutePath, "utf8"); + const searchable = maskComments(content); + TRANSLATION_CALL_REGEX.lastIndex = 0; + + for (const match of searchable.matchAll(TRANSLATION_CALL_REGEX)) { + const quote = match[1]; + const rawKey = match[2].trim(); + if (!rawKey) { + continue; + } + + if (quote === "`" && rawKey.includes("${")) { + skippedDynamicCalls.push({ + file: relativePath, + line: getLineNumber(searchable, match.index ?? 0), + reason: "template literal contains interpolation", + }); + continue; + } + + usedKeys.add(decodeQuotedContent(rawKey)); + } + } + + return { + usedKeys, + skippedDynamicCalls, + }; +} + +function readLocaleMappings() { + const localeFiles = globSync("*.json", { + cwd: localesDir, + nodir: true, + }); + + const localeMap = new Map(); + + for (const fileName of localeFiles) { + const fullPath = path.join(localesDir, fileName); + const raw = fs.readFileSync(fullPath, "utf8"); + const json = JSON.parse(raw); + + if (!json || typeof json !== "object" || typeof json.mappings !== "object") { + throw new Error(`Invalid locale file format in ${fileName}: expected { mappings: {...} }`); + } + + localeMap.set(fileName, new Set(Object.keys(json.mappings))); + } + + return localeMap; +} + +function parseArgs(argv) { + let format = "json"; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--format") { + format = (argv[i + 1] || "").toLowerCase(); + i += 1; + continue; + } + + if (arg.startsWith("--format=")) { + format = arg.split("=")[1].toLowerCase(); + } + } + + if (!SUPPORTED_FORMATS.has(format)) { + throw new Error(`Unsupported format: ${format}. Use --format json or --format yaml.`); + } + + return { + format, + outputFile: path.join(projectRoot, `translation-report.${format === "yaml" ? "yaml" : "json"}`), + }; +} + +function main() { + const { format, outputFile } = parseArgs(process.argv.slice(2)); + const { usedKeys, skippedDynamicCalls } = getUsedTranslationKeys(); + const locales = readLocaleMappings(); + + const sortedUsedKeys = [...usedKeys].sort((a, b) => a.localeCompare(b)); + const localeFiles = [...locales.keys()].sort((a, b) => a.localeCompare(b)); + + const localesReport = {}; + + for (const localeFile of localeFiles) { + const localeKeys = locales.get(localeFile) ?? new Set(); + + const missing = sortedUsedKeys.filter((key) => !localeKeys.has(key)); + const unused = [...localeKeys].filter((key) => !usedKeys.has(key)).sort((a, b) => a.localeCompare(b)); + + localesReport[localeFile] = { + missing, + unused, + missingCount: missing.length, + unusedCount: unused.length, + }; + } + + const report = { + generatedAt: new Date().toISOString(), + format, + scannedSourceDir: "src", + usedLiteralKeysCount: sortedUsedKeys.length, + usedLiteralKeys: sortedUsedKeys, + localeFilesCount: localeFiles.length, + locales: localesReport, + skippedDynamicCallsCount: skippedDynamicCalls.length, + skippedDynamicCalls, + }; + + if (format === "yaml") { + fs.writeFileSync(outputFile, yaml.dump(report, { noRefs: true }) + "\n", "utf8"); + } else { + fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + "\n", "utf8"); + } + + console.log(`Translation report written to: ${outputFile}`); +} + +try { + main(); +} catch (error) { + console.error("Failed to check translations."); + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +} diff --git a/scripts/clean-translations.js b/scripts/clean-translations.js new file mode 100644 index 00000000..5867050c --- /dev/null +++ b/scripts/clean-translations.js @@ -0,0 +1,238 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { globSync } from "glob"; +import yaml from "js-yaml"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, ".."); +const localesDir = path.join(projectRoot, "src", "lib", "locales"); + +function fail(message) { + throw new Error(message); +} + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function parseArgs(argv) { + let reportPath; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + + if (arg === "--report") { + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + fail("Missing value for --report. Usage: --report "); + } + reportPath = next; + i += 1; + continue; + } + + if (arg.startsWith("--report=")) { + reportPath = arg.slice("--report=".length); + if (!reportPath) { + fail("Missing value for --report. Usage: --report "); + } + continue; + } + + fail(`Unknown argument: ${arg}`); + } + + return { reportPath }; +} + +function detectReportPath(overridePath) { + if (overridePath) { + const resolved = path.resolve(projectRoot, overridePath); + if (!fs.existsSync(resolved)) { + fail(`Report file not found: ${resolved}`); + } + return resolved; + } + + const jsonPath = path.join(projectRoot, "translation-report.json"); + const yamlPath = path.join(projectRoot, "translation-report.yaml"); + + if (fs.existsSync(jsonPath)) return jsonPath; + if (fs.existsSync(yamlPath)) return yamlPath; + + fail( + "Could not find translation report. Expected translation-report.json or translation-report.yaml in project root, or use --report .", + ); +} + +function loadReport(reportPath) { + const ext = path.extname(reportPath).toLowerCase(); + const raw = fs.readFileSync(reportPath, "utf8"); + + let parsed; + try { + if (ext === ".json") { + parsed = JSON.parse(raw); + } else if (ext === ".yaml" || ext === ".yml") { + parsed = yaml.load(raw); + } else { + fail(`Unsupported report file extension: ${ext}. Use .json, .yaml, or .yml.`); + } + } catch (error) { + fail(`Failed to parse report at ${reportPath}: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!isPlainObject(parsed)) { + fail("Invalid report format: expected a top-level object."); + } + + if (!isPlainObject(parsed.locales)) { + fail("Invalid report format: expected report.locales to be an object."); + } + + return parsed; +} + +function getUnusedKeysForLocale(report, localeFileName) { + const localeReport = report.locales[localeFileName]; + + if (localeReport === undefined) return []; + + if (!isPlainObject(localeReport)) { + fail(`Invalid report format for locales.${localeFileName}: expected an object.`); + } + + const { unused } = localeReport; + + if (unused === undefined) return []; + + if (!Array.isArray(unused)) { + fail(`Invalid report format for locales.${localeFileName}.unused: expected an array.`); + } + + const nonStrings = unused.filter((key) => typeof key !== "string"); + if (nonStrings.length > 0) { + fail(`Invalid report format for locales.${localeFileName}.unused: all entries must be strings.`); + } + + return unused; +} + +function loadLocaleJson(localePath, localeFileName) { + const raw = fs.readFileSync(localePath, "utf8"); + + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + fail(`Invalid JSON in ${localeFileName}: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!isPlainObject(parsed)) { + fail(`Invalid locale file ${localeFileName}: expected a top-level object.`); + } + + if (!isPlainObject(parsed.mappings)) { + fail(`Invalid locale file ${localeFileName}: expected \"mappings\" to be an object.`); + } + + return parsed; +} + +function sortObjectKeysAscending(input) { + const keys = Object.keys(input).sort((a, b) => a.localeCompare(b)); + const result = {}; + for (const key of keys) { + result[key] = input[key]; + } + return result; +} + +function replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings) { + const next = {}; + let sawMappings = false; + + for (const key of Object.keys(localeData)) { + if (key === "mappings") { + next[key] = sortedMappings; + sawMappings = true; + } else { + next[key] = localeData[key]; + } + } + + if (!sawMappings) { + next.mappings = sortedMappings; + } + + return next; +} + +function cleanTranslations(report) { + if (!fs.existsSync(localesDir)) { + fail(`Locales directory not found: ${localesDir}`); + } + + const localeFiles = globSync("*.json", { + cwd: localesDir, + nodir: true, + }).sort((a, b) => a.localeCompare(b)); + + if (localeFiles.length === 0) { + fail(`No locale files found in ${localesDir}`); + } + + let totalRemoved = 0; + const perFile = []; + + for (const localeFileName of localeFiles) { + const localePath = path.join(localesDir, localeFileName); + const localeData = loadLocaleJson(localePath, localeFileName); + const unusedKeys = getUnusedKeysForLocale(report, localeFileName); + const unusedSet = new Set(unusedKeys); + + const currentMappings = localeData.mappings; + const cleanedMappings = {}; + + let removedCount = 0; + for (const [key, value] of Object.entries(currentMappings)) { + if (unusedSet.has(key)) { + removedCount += 1; + } else { + cleanedMappings[key] = value; + } + } + + const sortedMappings = sortObjectKeysAscending(cleanedMappings); + const nextLocaleData = replaceMappingsPreserveTopLevelOrder(localeData, sortedMappings); + + fs.writeFileSync(localePath, `${JSON.stringify(nextLocaleData, null, 2)}\n`, "utf8"); + + totalRemoved += removedCount; + perFile.push({ file: localeFileName, removed: removedCount }); + } + + for (const item of perFile) { + console.log(`${item.file}: removed ${item.removed} key${item.removed === 1 ? "" : "s"}`); + } + console.log(`Total removed: ${totalRemoved}`); +} + +function main() { + const { reportPath: reportArg } = parseArgs(process.argv.slice(2)); + const reportPath = detectReportPath(reportArg); + const report = loadReport(reportPath); + + console.log(`Using report: ${path.relative(projectRoot, reportPath)}`); + cleanTranslations(report); +} + +try { + main(); +} catch (error) { + console.error("Failed to clean translations."); + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/src/lib/components/IncidentItem.svelte b/src/lib/components/IncidentItem.svelte index 0a4b334f..312092d8 100644 --- a/src/lib/components/IncidentItem.svelte +++ b/src/lib/components/IncidentItem.svelte @@ -14,6 +14,7 @@ import { SveltePurify } from "@humanspeak/svelte-purify"; import mdToHTML from "$lib/marked"; import { slide } from "svelte/transition"; + import { page } from "$app/state"; interface Props { incident: IncidentForMonitorListWithComments; @@ -35,19 +36,13 @@ // If ongoing, use current timestamp for duration calculation const endTimeForDuration = $derived(incident.end_date_time ?? Math.floor(Date.now() / 1000)); - // Oldest comment added to this incident - const firstCommentAdded = $derived.by((): IncidentCommentRecord | null => { - if (!incident.comments || incident.comments.length === 0) return null; - - // Comments are already sorted in DB by commented_at DESC, id DESC - // so oldest comment is the last one. - return incident.comments.at(-1) ?? null; - }); + const isEmbedded = page.route.id?.includes("(embed)"); -
+
+ {incident.state} {incident.title} @@ -58,7 +53,7 @@
{#each incident.monitors as monitor (`${incident.id}-${monitor.monitor_tag}`)} - + (showComments = !showComments)} > @@ -160,7 +155,7 @@
{/if} - {#if showComments && incident.comments && incident.comments.length > 0} + {#if showSummary && showComments && incident.comments && incident.comments.length > 0}
{#each incident.comments as comment (comment.id)}
diff --git a/src/lib/components/MaintenanceItem.svelte b/src/lib/components/MaintenanceItem.svelte index a7393f36..98cdd449 100644 --- a/src/lib/components/MaintenanceItem.svelte +++ b/src/lib/components/MaintenanceItem.svelte @@ -12,6 +12,7 @@ import clientResolver from "$lib/client/resolver.js"; import { GetInitials } from "$lib/clientTools.js"; import type { MaintenanceEventsMonitorList } from "$lib/server/types/db"; + import { page } from "$app/state"; interface Props { maintenance: MaintenanceEventsMonitorList; @@ -20,12 +21,12 @@ } let { maintenance, class: className = "", hideMonitors = false }: Props = $props(); - console.log(">>>>>>---- MaintenanceItem:23 ", maintenance); // Check if maintenance is ongoing (current time is between start and end) const isOngoing = $derived(() => { const now = Date.now() / 1000; return now >= maintenance.start_date_time && now <= maintenance.end_date_time; }); + const isEmbedded = page.route.id?.includes("(embed)"); @@ -51,7 +52,7 @@
{#each maintenance.monitors as monitor} - + + import { Button } from "$lib/components/ui/button/index.js"; + import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; + import { Spinner } from "$lib/components/ui/spinner/index.js"; + import { resolve } from "$app/paths"; + import clientResolver from "$lib/client/resolver.js"; + import { onMount } from "svelte"; + import ChevronDown from "@lucide/svelte/icons/chevron-down"; + import type { PageNavItem } from "$lib/server/controllers/dashboardController.js"; + import { page } from "$app/state"; + + let currentPath = $derived(page.params.page_path); + + let pages = $state([]); + let pagesLoading = $state(false); + + const currentPage = $derived(pages.find((p) => p.page_path === currentPath) || pages[0]); + + async function fetchPages() { + pagesLoading = true; + try { + const response = await fetch(clientResolver(resolve, "/dashboard-apis/pages")); + if (response.ok) { + pages = await response.json(); + } + } catch { + // silently fail, pages dropdown will just not show + } finally { + pagesLoading = false; + } + } + + onMount(() => { + fetchPages(); + }); + + +
+ {#if pagesLoading} + + {:else if pages.length > 0} + + + {#snippet child({ props })} + + {/snippet} + + + {#each pages as page (page.page_path)} + + {/each} + + + {/if} +
diff --git a/src/lib/components/ThemePlus.svelte b/src/lib/components/ThemePlus.svelte index 9eae2564..845bb8ea 100644 --- a/src/lib/components/ThemePlus.svelte +++ b/src/lib/components/ThemePlus.svelte @@ -1,19 +1,15 @@
-
- {#if pagesLoading} - - {:else if pages.length > 1} - - - {#snippet child({ props })} - - {/snippet} - - - {#each pages as page (page.page_path)} - - {/each} - - - {/if} -
+
{#if page.data.isSubsEnabled && page.data.canSendEmail}