#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const researchDir = path.resolve(scriptDir, "..");
const projectDir = path.resolve(researchDir, "../..");
const outputDir = path.join(researchDir, "UltimateDefaultCatalog");
const sources = {
curatedCatalog: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.csv"),
appleNotes: path.join(projectDir, "Apptag", "AppleDefaultAppNotes.swift"),
localizationDir: path.join(projectDir, "Apptag", "Localization"),
};
const outputs = {
reviewCSV: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.csv"),
runtimeJSON: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.json"),
report: path.join(outputDir, "SmartStart_UltimateDefaultCatalog_Report.md"),
duplicateReview: path.join(outputDir, "SmartStart_UltimateDefaultCatalog_DuplicateReview.csv"),
translationQA: path.join(outputDir, "SmartStart_UltimateDefaultCatalog_TranslationQA.md"),
};
const stableTagOrder = [
"browser",
"communication",
"productivity",
"file-management",
"transfer",
"development",
"design",
"writing",
"media",
"video",
"audio",
"picture-photo",
"utilities",
"system",
"system-enhancement",
"entertainment",
"game",
"finance",
"education",
"ai-tools",
"security",
"other",
];
const stableTags = new Set(stableTagOrder);
const noteLimit = 80;
const tagerToStableTags = new Map(Object.entries({
"3d-cad": ["design"],
"Automation": ["productivity", "system-enhancement"],
"Font": ["design", "utilities"],
"GTD": ["productivity"],
"Meeting": ["communication", "productivity"],
"Notes": ["productivity", "writing"],
"PDF": ["writing", "utilities"],
"ai-tools": ["ai-tools"],
"api-tools": ["development"],
"audio": ["media", "audio"],
"browser": ["browser"],
"communication": ["communication"],
"database-tools": ["development"],
"design": ["design"],
"device-management": ["utilities", "system-enhancement"],
"devops": ["development"],
"diagramming": ["design"],
"education": ["education"],
"entertainment": ["entertainment"],
"file-management": ["file-management", "utilities"],
"finance": ["finance"],
"game": ["entertainment", "game"],
"ide": ["development"],
"input-tools": ["utilities", "system-enhancement"],
"media": ["media"],
"network-tools": ["utilities"],
"office": ["productivity", "writing"],
"picture-photo": ["media", "picture-photo"],
"runtime-sdk": ["development"],
"security": ["security"],
"system": ["system", "utilities"],
"system-maintenance": ["utilities", "system-enhancement"],
"terminal-tools": ["development"],
"transfer": ["transfer", "utilities"],
"ui-prototyping": ["design"],
"utilities": ["utilities"],
"video": ["media", "video"],
"window-management": ["utilities", "system-enhancement"],
"writing": ["writing"],
}));
function parseCSV(text) {
const rows = [];
let row = [];
let field = "";
let quoted = false;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoted) {
if (char === "\"") {
if (text[index + 1] === "\"") {
field += "\"";
index += 1;
} else {
quoted = false;
}
} else {
field += char;
}
continue;
}
if (char === "\"") {
quoted = true;
} else if (char === ",") {
row.push(field);
field = "";
} else if (char === "\n") {
row.push(field);
rows.push(row);
row = [];
field = "";
} else if (char !== "\r") {
field += char;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
return rows;
}
function stringifyCSV(rows) {
return rows
.map((row) =>
row
.map((field) => {
const value = String(field ?? "");
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, "\"\"")}"`;
}
return value;
})
.join(","),
)
.join("\n");
}
function readCSVObjects(filePath) {
const rows = parseCSV(fs.readFileSync(filePath, "utf8"));
const header = rows[0] ?? [];
return rows.slice(1).map((row) =>
Object.fromEntries(header.map((column, index) => [column, row[index] ?? ""])),
);
}
function splitTags(value) {
return String(value ?? "")
.split(/[|;,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function orderedTags(tags) {
const cleaned = [];
const seen = new Set();
for (const tag of tags) {
if (!stableTags.has(tag) || seen.has(tag)) continue;
seen.add(tag);
cleaned.push(tag);
}
const meaningful = cleaned.filter((tag) => tag !== "other");
const finalTags = meaningful.length > 0 ? meaningful : cleaned;
return stableTagOrder.filter((tag) => finalTags.includes(tag));
}
function normalizeName(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") || String(value ?? "").trim().toLowerCase();
}
function normalizeBundle(value) {
const trimmed = String(value ?? "").trim();
if (!trimmed || ["null", "nil", "undefined", "n/a", "-"].includes(trimmed.toLowerCase())) {
return null;
}
return trimmed;
}
function charLength(value) {
return Array.from(String(value ?? "")).length;
}
function stripTerminalPunctuation(value) {
return String(value ?? "")
.replace(/\s+/g, " ")
.trim()
.replace(/[。.\.!!\??;;::,,、]+$/u, "")
.trim();
}
function stripLeadingPunctuation(value) {
const clean = String(value ?? "")
.replace(/\s+/g, " ")
.trim();
if (clean.startsWith(".NET")) {
return `Microsoft .NET${clean.slice(".NET".length)}`.trim();
}
return clean
.replace(/^[\p{P}\s]+/u, "")
.trim();
}
function truncateNote(value) {
const clean = stripTerminalPunctuation(stripLeadingPunctuation(value));
if (charLength(clean) <= noteLimit) return clean;
let sliced = Array.from(clean).slice(0, noteLimit).join("");
return stripTerminalPunctuation(sliced);
}
function loadCategoryTranslations() {
const translations = new Map();
for (const fileName of fs.readdirSync(sources.localizationDir).sort()) {
if (!fileName.endsWith(".json")) continue;
const code = fileName.replace(/\.json$/, "");
const raw = JSON.parse(fs.readFileSync(path.join(sources.localizationDir, fileName), "utf8"));
const categories = {};
for (const tag of stableTagOrder) {
categories[tag] = raw[`smart.category.${tag}`] ?? tag;
}
translations.set(code, categories);
}
return translations;
}
function inferSourceNoteLanguage(note) {
const value = String(note ?? "");
if (/[\u3040-\u30ff]/u.test(value)) return "ja";
if (/[\uac00-\ud7af]/u.test(value)) return "ko";
return "zh-Hans";
}
function buildLocalizedNotes(noteZH) {
if (!noteZH) return {};
return {
[inferSourceNoteLanguage(noteZH)]: truncateNote(noteZH),
};
}
function parseAppleNotes() {
if (!fs.existsSync(sources.appleNotes)) {
return { byBundle: new Map(), byName: new Map() };
}
const swift = fs.readFileSync(sources.appleNotes, "utf8");
const byBundle = new Map();
const byName = new Map();
const entryPattern = /"([^"]+)"\s*:\s*"([^"]+)"/g;
let currentSection = null;
for (const line of swift.split("\n")) {
if (line.includes("private static let byBundleID")) currentSection = "bundle";
if (line.includes("private static let byName")) currentSection = "name";
if (line.includes("static func note")) currentSection = null;
let match;
while ((match = entryPattern.exec(line)) !== null) {
if (currentSection === "bundle") {
byBundle.set(match[1].toLowerCase(), match[2]);
} else if (currentSection === "name") {
byName.set(match[1], match[2]);
}
}
}
return { byBundle, byName };
}
function rawEntry({
source,
name,
normalizedName,
bundleIdentifier,
tags,
noteZH = "",
rank = Number.MAX_SAFE_INTEGER,
evidence = [],
}) {
const cleanName = String(name ?? "").trim();
const cleanNormalized = String(normalizedName ?? "").trim() || normalizeName(cleanName);
const cleanBundle = normalizeBundle(bundleIdentifier);
const cleanTags = orderedTags(tags);
return {
source,
name: cleanName,
normalizedName: cleanNormalized,
bundleIdentifier: cleanBundle,
tags: cleanTags,
noteZH: truncateNote(noteZH),
rank: Number.isFinite(Number(rank)) ? Number(rank) : Number.MAX_SAFE_INTEGER,
evidence: evidence.filter(Boolean),
priority: sourcePriority[source] ?? 0,
};
}
function mergeInto(target, incoming, stats) {
target.sources.add(incoming.source);
for (const evidence of incoming.evidence) target.sourceEvidence.add(evidence);
target.sourceEvidence.add(incoming.source);
target.tags = orderedTags([...target.tags, ...incoming.tags]);
target.rank = Math.min(target.rank, incoming.rank);
if (incoming.bundleIdentifier && !target.bundleIdentifier) {
target.bundleIdentifier = incoming.bundleIdentifier;
}
if (incoming.priority > target.namePriority && incoming.name) {
target.name = incoming.name;
target.namePriority = incoming.priority;
}
if (incoming.noteZH && (!target.noteZH || incoming.priority >= target.notePriority)) {
target.noteZH = incoming.noteZH;
target.notePriority = incoming.priority;
}
stats.mergedRows += 1;
}
function mapCuratedTags(value) {
const tags = [];
const unknownTokens = [];
for (const token of splitTags(value)) {
const mapped = tagerToStableTags.get(token);
if (!mapped) {
unknownTokens.push(token);
continue;
}
tags.push(...mapped);
}
return {
tags: orderedTags(tags.length > 0 ? tags : ["other"]),
unknownTokens,
};
}
const duplicateAliasSuffixes = ["-app", "-desktop", "-mac", "-macos"];
function duplicateAliasBase(normalizedName) {
const clean = String(normalizedName ?? "").trim();
for (const suffix of duplicateAliasSuffixes) {
if (clean.endsWith(suffix)) {
return clean.slice(0, -suffix.length);
}
}
return clean;
}
function duplicateComparable(row) {
return JSON.stringify({
defaultTag: String(row.defaultTag ?? "").trim(),
tager: String(row.tager ?? "").trim(),
bundleIdentifier: normalizeBundle(row.bundleIdentifier),
noteZH: truncateNote(row["defaultNote-ZH"]),
});
}
function dedupeCuratedRows(rows, stats) {
const byNormalized = new Map();
for (const row of rows) {
const normalizedName = String(row.normalizedName ?? "").trim() || normalizeName(row.Name ?? "");
if (normalizedName && !byNormalized.has(normalizedName)) {
byNormalized.set(normalizedName, row);
}
}
const filtered = [];
for (const row of rows) {
const normalizedName = String(row.normalizedName ?? "").trim() || normalizeName(row.Name ?? "");
const aliasBase = duplicateAliasBase(normalizedName);
const baseRow = aliasBase === normalizedName ? null : byNormalized.get(aliasBase);
if (baseRow && duplicateComparable(row) === duplicateComparable(baseRow)) {
stats.aliasDuplicatesRemoved += 1;
continue;
}
filtered.push(row);
}
return filtered;
}
function build() {
fs.mkdirSync(outputDir, { recursive: true });
const translations = loadCategoryTranslations();
const appleNotes = parseAppleNotes();
const previousRuntimeEntries = fs.existsSync(outputs.runtimeJSON)
? new Map(
(JSON.parse(fs.readFileSync(outputs.runtimeJSON, "utf8")).entries ?? []).map((entry) => [entry.normalizedName, entry]),
)
: new Map();
const rawCuratedRows = readCSVObjects(sources.curatedCatalog);
const unknownTagerTokens = new Set();
const stats = {
curatedRows: rawCuratedRows.length,
aliasDuplicatesRemoved: 0,
rowsWithSourceNotes: 0,
appleNotesAttached: 0,
tagChangedVsPrevious: 0,
zhNoteChangedVsPrevious: 0,
bundleChangedVsPrevious: 0,
missingLocalizedNotes: 0,
};
const curatedRows = dedupeCuratedRows(rawCuratedRows, stats);
const finalRows = curatedRows.map((row, index) => {
const name = String(row.Name ?? "").trim();
const normalizedName = String(row.normalizedName ?? "").trim() || normalizeName(name);
const bundleIdentifier = normalizeBundle(row.bundleIdentifier);
const legacyTags = orderedTags(splitTags(row.defaultTag));
const mapping = mapCuratedTags(row.tager);
for (const token of mapping.unknownTokens) unknownTagerTokens.add(token);
let noteZH = truncateNote(row["defaultNote-ZH"]);
let usedAppleFallback = false;
if (!noteZH) {
const bundleNote = bundleIdentifier ? appleNotes.byBundle.get(bundleIdentifier.toLowerCase()) : null;
const nameNote = appleNotes.byName.get(name);
if (bundleNote || nameNote) {
noteZH = truncateNote(bundleNote ?? nameNote);
usedAppleFallback = true;
stats.appleNotesAttached += 1;
}
} else {
stats.rowsWithSourceNotes += 1;
}
const notes = buildLocalizedNotes(noteZH);
const previous = previousRuntimeEntries.get(normalizedName);
if (previous) {
if (JSON.stringify(previous.defaultTag ?? []) !== JSON.stringify(mapping.tags)) {
stats.tagChangedVsPrevious += 1;
}
if (((previous.notes ?? {})["zh-Hans"] ?? "") !== (noteZH ?? "")) {
stats.zhNoteChangedVsPrevious += 1;
}
if ((previous.bundleIdentifier ?? null) !== bundleIdentifier) {
stats.bundleChangedVsPrevious += 1;
}
}
return {
rank: index + 1,
name,
normalizedName,
bundleIdentifier,
tags: mapping.tags,
legacyTags,
noteZH,
notes,
sourceEvidence: usedAppleFallback
? ["curated_tager_catalog", "apple_default_notes"]
: ["curated_tager_catalog"],
};
});
const invalidTagRows = finalRows.filter((row) => row.tags.some((tag) => !stableTags.has(tag)));
const emptyNormalizedRows = finalRows.filter((row) => !row.normalizedName);
const exactOtherRows = finalRows.filter((row) => row.tags.length === 1 && row.tags[0] === "other");
const mixedOtherRows = finalRows.filter((row) => row.tags.length > 1 && row.tags.includes("other"));
const noteRows = finalRows.filter((row) => row.noteZH);
const noteQualityIssues = [];
for (const row of noteRows) {
for (const [code, note] of Object.entries(row.notes)) {
if (charLength(note) > noteLimit) {
noteQualityIssues.push({ name: row.name, code, issue: `over_limit:${charLength(note)}` });
} else if (stripTerminalPunctuation(note) != note) {
noteQualityIssues.push({ name: row.name, code, issue: "trailing_punctuation" });
} else if (stripLeadingPunctuation(note) != note) {
noteQualityIssues.push({ name: row.name, code, issue: "leading_punctuation" });
}
}
}
const cleanedCuratedRows = [
["Name", "normalizedName", "defaultTag", "tager", "bundleIdentifier", "defaultNote-ZH"],
...curatedRows.map((row) => [
String(row.Name ?? "").trim(),
String(row.normalizedName ?? "").trim() || normalizeName(row.Name ?? ""),
String(row.defaultTag ?? "").trim(),
String(row.tager ?? "").trim(),
normalizeBundle(row.bundleIdentifier) ?? "null",
truncateNote(row["defaultNote-ZH"]),
]),
];
fs.writeFileSync(outputs.reviewCSV, `${stringifyCSV(cleanedCuratedRows)}\n`);
const runtime = {
version: 2,
generatedAt: new Date().toISOString(),
noteLimit,
supportedLanguages: [...translations.keys()].sort(),
entries: finalRows.map((row) => ({
rank: row.rank,
name: row.name,
normalizedName: row.normalizedName,
bundleIdentifier: row.bundleIdentifier,
defaultTag: row.tags,
notes: row.notes,
sourceEvidence: row.sourceEvidence,
})),
};
fs.writeFileSync(outputs.runtimeJSON, `${JSON.stringify(runtime, null, 2)}\n`);
const duplicateRows = [
["name", "normalizedName", "tager", "legacyDefaultTag", "runtimeDefaultTag", "bundleIdentifier", "defaultNote-ZH"],
...finalRows
.filter((row) => row.legacyTags.join("|") !== row.tags.join("|"))
.map((row) => [
row.name,
row.normalizedName,
(curatedRows[row.rank - 1]?.tager ?? ""),
row.legacyTags.join("|"),
row.tags.join("|"),
row.bundleIdentifier ?? "null",
row.noteZH ?? "",
]),
];
fs.writeFileSync(outputs.duplicateReview, `${stringifyCSV(duplicateRows)}\n`);
const sourceLines = [
"- curated CSV: `Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv`",
`- curated rows: ${stats.curatedRows}`,
`- curated rows after alias dedupe: ${curatedRows.length}`,
`- alias duplicates removed: ${stats.aliasDuplicatesRemoved}`,
`- unknown tager tokens: ${unknownTagerTokens.size}`,
].join("\n");
const topTagLines = stableTagOrder
.map((tag) => [tag, finalRows.filter((row) => row.tags.includes(tag)).length])
.filter(([, count]) => count > 0)
.map(([tag, count]) => `- ${tag}: ${count}`)
.join("\n");
const report = `# Smart Start Ultimate Default Catalog Report
Generated: ${runtime.generatedAt}
## Inputs
${sourceLines}
## Outputs
- Review CSV: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv\`
- Runtime JSON: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.json\`
- Duplicate review: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog_DuplicateReview.csv\`
- Translation QA: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog_TranslationQA.md\`
## Summary
- Final rows: ${finalRows.length}
- Rows with Chinese default notes: ${noteRows.length}
- Rows with source Chinese notes from curated CSV: ${stats.rowsWithSourceNotes}
- Rows with Apple note fallback attached: ${stats.appleNotesAttached}
- Invalid tag rows: ${invalidTagRows.length}
- Empty normalizedName rows: ${emptyNormalizedRows.length}
- Exact \`other\` rows: ${exactOtherRows.length}
- Mixed \`other\` rows after cleanup: ${mixedOtherRows.length}
- Tag changes vs previous runtime JSON: ${stats.tagChangedVsPrevious}
- Chinese note changes vs previous runtime JSON: ${stats.zhNoteChangedVsPrevious}
- Bundle identifier changes vs previous runtime JSON: ${stats.bundleChangedVsPrevious}
- Unknown tager tokens: ${unknownTagerTokens.size}
- Note quality issues: ${noteQualityIssues.length}
## Tag Distribution
${topTagLines}
## Notes
- Runtime tags are generated from the curated CSV \`tager\` column, not the legacy \`defaultTag\` column.
- Runtime notes are generated only from real source notes. The generator must not synthesize notes from app names or category labels.
- Missing translations are intentionally omitted until a real translation pipeline or reviewed translation table provides them.
`;
fs.writeFileSync(outputs.report, report);
const translationReport = `# Smart Start Ultimate Default Catalog Translation QA
Generated: ${runtime.generatedAt}
## Summary
- Supported languages: ${runtime.supportedLanguages.length}
- Rows with source Chinese notes: ${noteRows.length}
- Source notes: ${noteRows.length}
- Required machine/reviewed translations: ${noteRows.length * (runtime.supportedLanguages.length - 1)}
- Generated placeholder translations: 0
- Note quality issues: ${noteQualityIssues.length}
- Note limit: ${noteLimit}
## Issue Sample
${noteQualityIssues.slice(0, 50).map((issue) => `- ${issue.name} / ${issue.code}: ${issue.issue}`).join("\n") || "- None"}
`;
fs.writeFileSync(outputs.translationQA, translationReport);
return {
finalRows: finalRows.length,
noteRows: noteRows.length,
changedVsPrevious: stats.tagChangedVsPrevious,
invalidTagRows: invalidTagRows.length,
emptyNormalizedRows: emptyNormalizedRows.length,
exactOtherRows: exactOtherRows.length,
mixedOtherRows: mixedOtherRows.length,
noteQualityIssues: noteQualityIssues.length,
unknownTagerTokens: [...unknownTagerTokens].sort(),
outputDir,
};
}
console.log(JSON.stringify(build(), null, 2));