Ariver
2026-05-28 b4a1ba5df0f073bf346c3f4407b613bf08d29f08
Research/SmartStart/Scripts/build-ultimate-default-catalog.mjs
@@ -2,6 +2,8 @@
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
@@ -18,19 +20,40 @@
const outputs = {
  reviewCSV: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.csv"),
  runtimeJSON: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.json"),
  runtimeBaseJSON: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.base.json"),
  runtimeManifestJSON: path.join(outputDir, "SmartStart_UltimateDefaultCatalog.manifest.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"),
  sourceNoteTranslationCache: path.join(outputDir, "SmartStart_UltimateDefaultCatalog_SourceNoteTranslationCache.json"),
};
const resourceFormatVersion = 1;
const catalogContentVersion = 2;
const notesVersion = 1;
const requiredFallbackLanguages = ["en", "zh-Hans", "zh-Hant"];
const stableTagOrder = [
  "browser",
  "communication",
  "productivity",
  "GTD",
  "Notes",
  "Meeting",
  "office",
  "PDF",
  "file-management",
  "transfer",
  "development",
  "design",
  "ai-tools",
  "api-tools",
  "database-tools",
  "devops",
  "ide",
  "runtime-sdk",
  "terminal-tools",
  "Font",
  "ui-prototyping",
  "3d-cad",
  "diagramming",
  "writing",
  "media",
  "video",
@@ -38,58 +61,31 @@
  "picture-photo",
  "utilities",
  "system",
  "system-enhancement",
  "system-maintenance",
  "window-management",
  "device-management",
  "input-tools",
  "Automation",
  "network-tools",
  "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"],
const translationProvider = "google-translate-gtx-source-notes-20260520";
const translationBackend = process.env.SMARTSTART_TRANSLATION_BACKEND || "google-gtx";
const lingvaBaseURL = process.env.SMARTSTART_LINGVA_BASE_URL || "https://lingva.ml";
const translationBatchSize = Number(process.env.SMARTSTART_TRANSLATION_BATCH_SIZE || 50);
const translationPauseMS = Number(process.env.SMARTSTART_TRANSLATION_PAUSE_MS || 150);
const translationRetryLimit = Number(process.env.SMARTSTART_TRANSLATION_RETRY_LIMIT || 4);
const equivalentTargetLanguages = new Map(Object.entries({
  "ar-Najdi": ["ar"],
  "nn": ["nb", "no"],
  "no": ["nb", "nn"],
}));
function parseCSV(text) {
@@ -155,7 +151,9 @@
function readCSVObjects(filePath) {
  const rows = parseCSV(fs.readFileSync(filePath, "utf8"));
  const header = rows[0] ?? [];
  const header = (rows[0] ?? []).map((column, index) =>
    index === 0 ? String(column ?? "").replace(/^\uFEFF/, "") : column,
  );
  return rows.slice(1).map((row) =>
    Object.fromEntries(header.map((column, index) => [column, row[index] ?? ""])),
  );
@@ -177,8 +175,7 @@
    cleaned.push(tag);
  }
  const meaningful = cleaned.filter((tag) => tag !== "other");
  const finalTags = meaningful.length > 0 ? meaningful : cleaned;
  return stableTagOrder.filter((tag) => finalTags.includes(tag));
  return meaningful.length > 0 ? meaningful : cleaned;
}
function normalizeName(value) {
@@ -189,6 +186,52 @@
    .replace(/&/g, " and ")
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "") || String(value ?? "").trim().toLowerCase();
}
function sha256(value) {
  return crypto.createHash("sha256").update(value).digest("hex");
}
function smartStartNotesPath(languageCode) {
  return path.join(outputDir, `SmartStart_UltimateDefaultCatalog.notes.${languageCode}.json`);
}
function entryIDFor(row) {
  const baseSource = row.bundleIdentifier || row.normalizedName || row.name || "entry";
  const base = normalizeName(baseSource) || "entry";
  const identity = [
    row.bundleIdentifier ?? "",
    row.normalizedName ?? "",
    row.name ?? "",
  ].join("\u0000");
  return `${base}-${sha256(identity).slice(0, 12)}`;
}
function loadRuntimeSmartCategoryIDs() {
  const filePath = path.join(projectDir, "Apptag", "SmartCategorization", "SmartCategory.swift");
  const source = fs.readFileSync(filePath, "utf8");
  const enumBody = source.match(/enum SmartCategoryID:[\s\S]*?\n}/)?.[0] ?? "";
  const ids = new Set();
  const casePattern = /^\s*case\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*"([^"]+)")?/gm;
  let match;
  while ((match = casePattern.exec(enumBody)) !== null) {
    ids.add(match[2] ?? match[1]);
  }
  return ids;
}
function normalizedForNoteComparison(value) {
  return String(value ?? "")
    .normalize("NFKD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z0-9\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]+/g, "");
}
function noteRepeatsAppName(note, name) {
  const normalizedNameValue = normalizedForNoteComparison(name);
  if (normalizedNameValue.length < 3) return false;
  return normalizedForNoteComparison(note).includes(normalizedNameValue);
}
function normalizeBundle(value) {
@@ -232,32 +275,442 @@
  return stripTerminalPunctuation(sliced);
}
function loadCategoryTranslations() {
  const translations = new Map();
function normalizeTranslatedNote(value) {
  return truncateNote(value);
}
function loadSupportedLanguageCodes() {
  const codes = [];
  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);
    codes.push(fileName.replace(/\.json$/, ""));
  }
  return translations;
  return codes.sort();
}
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 googleLanguageCode(code) {
  switch (code) {
  case "zh-Hans": return "zh-CN";
  case "zh-Hant": return "zh-TW";
  case "pt-BR": return "pt";
  case "sr-Cyrl": return "sr";
  case "ar-Najdi": return "ar";
  case "nb":
  case "nn":
  case "no":
    return "no";
  default:
    return code;
  }
}
function buildLocalizedNotes(noteZH) {
  if (!noteZH) return {};
function sourceNotesFingerprint(sourceNotes) {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify([...sourceNotes].sort()))
    .digest("hex");
}
function translationCacheKey(targetLanguage, sourceNote) {
  return ["zh-Hans", targetLanguage, sourceNote].join("\u0001");
}
function loadSourceNoteTranslationCache(sourceFingerprint) {
  if (!fs.existsSync(outputs.sourceNoteTranslationCache)) {
    return {
      version: 1,
      provider: translationProvider,
      sourceFingerprint,
      entries: {},
      ignoredExistingCache: false,
    };
  }
  const cache = JSON.parse(fs.readFileSync(outputs.sourceNoteTranslationCache, "utf8"));
  if (
    cache.provider !== translationProvider ||
    cache.sourceFingerprint !== sourceFingerprint ||
    cache.version !== 1
  ) {
    return {
      version: 1,
      provider: translationProvider,
      sourceFingerprint,
      entries: {},
      ignoredExistingCache: true,
    };
  }
  return {
    [inferSourceNoteLanguage(noteZH)]: truncateNote(noteZH),
    version: 1,
    provider: translationProvider,
    sourceFingerprint,
    entries: cache.entries ?? {},
    ignoredExistingCache: false,
  };
}
function saveSourceNoteTranslationCache(cache) {
  const sortedEntries = Object.fromEntries(
    Object.entries(cache.entries).sort(([left], [right]) => left.localeCompare(right)),
  );
  fs.writeFileSync(outputs.sourceNoteTranslationCache, `${JSON.stringify({
    version: 1,
    provider: translationProvider,
    sourceFingerprint: cache.sourceFingerprint,
    updatedAt: new Date().toISOString(),
    entries: sortedEntries,
  }, null, 2)}\n`);
}
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function parseGoogleTranslation(payload) {
  return (payload?.[0] ?? [])
    .map((segment) => segment?.[0] ?? "")
    .join("");
}
async function requestGoogleTranslationPayload(params, attempt = 0) {
  const result = spawnSync("curl", [
    "-sS",
    "--fail",
    "--compressed",
    "--max-time",
    "45",
    "--retry",
    "2",
    "--retry-delay",
    "1",
    "-A",
    "TagLauncher-CatalogOps/1.0",
    "-H",
    "content-type: application/x-www-form-urlencoded;charset=UTF-8",
    "--data",
    params.toString(),
    "https://translate.googleapis.com/translate_a/single",
  ], {
    encoding: "utf8",
    maxBuffer: 16 * 1024 * 1024,
  });
  if (result.status === 0) {
    try {
      return JSON.parse(result.stdout);
    } catch (error) {
      if (attempt < translationRetryLimit) {
        await sleep(2500 * (attempt + 1));
        return requestGoogleTranslationPayload(params, attempt + 1);
      }
      throw new Error(`translation response was not JSON: ${error.message}`);
    }
  }
  if (attempt < translationRetryLimit) {
    await sleep(2500 * (attempt + 1));
    return requestGoogleTranslationPayload(params, attempt + 1);
  }
  throw new Error(`translation request failed: ${result.stderr || result.stdout || result.error?.message}`);
}
async function requestLingvaTranslationBatch(notes, targetLanguage, attempt = 0) {
  const targetCode = googleLanguageCode(targetLanguage);
  const query = encodeURIComponent(notes.map((note) => note.replace(/\//g, "/")).join("\n"));
  const result = spawnSync("curl", [
    "-sS",
    "--fail",
    "--compressed",
    "--max-time",
    "60",
    "--retry",
    "4",
    "--retry-all-errors",
    "--retry-connrefused",
    "--retry-delay",
    "2",
    "-A",
    "TagLauncher-CatalogOps/1.0",
    `${lingvaBaseURL.replace(/\/+$/, "")}/api/v1/zh/${targetCode}/${query}`,
  ], {
    encoding: "utf8",
    maxBuffer: 16 * 1024 * 1024,
  });
  if (result.status === 0) {
    try {
      const payload = JSON.parse(result.stdout);
      if (payload.error) throw new Error(payload.error);
      const translated = String(payload.translation ?? "")
        .replace(/\r\n/g, "\n")
        .replace(/\r/g, "\n");
      const parts = translated.split("\n").map(normalizeTranslatedNote);
      if (parts.length !== notes.length || parts.some((part) => !part)) {
        throw new Error(`translation batch line mismatch: expected ${notes.length}, got ${parts.length}`);
      }
      return parts;
    } catch (error) {
      if (attempt < translationRetryLimit && isRemoteThrottleError(error)) {
        await sleep(2500 * (attempt + 1));
        return requestLingvaTranslationBatch(notes, targetLanguage, attempt + 1);
      }
      throw error;
    }
  }
  if (attempt < translationRetryLimit) {
    await sleep(2500 * (attempt + 1));
    return requestLingvaTranslationBatch(notes, targetLanguage, attempt + 1);
  }
  throw new Error(`lingva translation request failed: ${result.stderr || result.stdout || result.error?.message}`);
}
async function requestOnlineTranslationBatch(notes, targetLanguage) {
  if (translationBackend === "lingva") {
    return requestLingvaTranslationBatch(notes, targetLanguage);
  }
  const params = new URLSearchParams({
    client: "gtx",
    sl: googleLanguageCode("zh-Hans"),
    tl: googleLanguageCode(targetLanguage),
    dt: "t",
    q: notes.join("\n"),
  });
  const translated = parseGoogleTranslation(await requestGoogleTranslationPayload(params))
    .replace(/\r\n/g, "\n")
    .replace(/\r/g, "\n");
  const parts = translated.split("\n").map(normalizeTranslatedNote);
  if (parts.length !== notes.length || parts.some((part) => !part)) {
    throw new Error(`translation batch line mismatch: expected ${notes.length}, got ${parts.length}`);
  }
  return parts;
}
function isRemoteThrottleError(error) {
  const message = String(error?.message ?? "");
  return message.includes("not JSON") || message.includes("translation request failed");
}
async function translateNotesWithSplit(notes, targetLanguage) {
  try {
    return await requestOnlineTranslationBatch(notes, targetLanguage);
  } catch (error) {
    if (isRemoteThrottleError(error) || notes.length <= 1) {
      throw error;
    }
    const middle = Math.ceil(notes.length / 2);
    const left = await translateNotesWithSplit(notes.slice(0, middle), targetLanguage);
    const right = await translateNotesWithSplit(notes.slice(middle), targetLanguage);
    return [...left, ...right];
  }
}
function reusableTranslation(cache, targetLanguage, sourceNote) {
  const directKey = translationCacheKey(targetLanguage, sourceNote);
  if (cache.entries[directKey]) return cache.entries[directKey];
  for (const equivalentLanguage of equivalentTargetLanguages.get(targetLanguage) ?? []) {
    const equivalentKey = translationCacheKey(equivalentLanguage, sourceNote);
    if (cache.entries[equivalentKey]) return cache.entries[equivalentKey];
  }
  return null;
}
async function translateMissingNotes(uniqueSourceNotes, supportedLanguages, cache) {
  let translatedCount = 0;
  let failedCount = 0;
  let reusedEquivalentCount = 0;
  let cacheHits = 0;
  for (const sourceNote of uniqueSourceNotes) {
    cache.entries[translationCacheKey("zh-Hans", sourceNote)] = normalizeTranslatedNote(sourceNote);
  }
  for (const targetLanguage of supportedLanguages) {
    if (targetLanguage === "zh-Hans" || targetLanguage === "zh-Hant") continue;
    const missing = [];
    for (const sourceNote of uniqueSourceNotes) {
      const key = translationCacheKey(targetLanguage, sourceNote);
      if (cache.entries[key]) {
        cacheHits += 1;
        continue;
      }
      const reusable = reusableTranslation(cache, targetLanguage, sourceNote);
      if (reusable) {
        cache.entries[key] = reusable;
        reusedEquivalentCount += 1;
        continue;
      }
      missing.push({ sourceNote, key });
    }
    if (missing.length > 0) {
      console.warn(`Translating ${missing.length} source notes to ${targetLanguage}...`);
    }
    for (let index = 0; index < missing.length; index += translationBatchSize) {
      const batch = missing.slice(index, index + translationBatchSize);
      try {
        const translations = await translateNotesWithSplit(
          batch.map((item) => item.sourceNote),
          targetLanguage,
        );
        translations.forEach((translation, translationIndex) => {
          cache.entries[batch[translationIndex].key] = translation;
          translatedCount += 1;
        });
      } catch (error) {
        failedCount += batch.length;
        saveSourceNoteTranslationCache(cache);
        throw new Error(`Batch failed for ${targetLanguage}; cached progress saved.\n${error.message}`);
      }
      saveSourceNoteTranslationCache(cache);
      await sleep(translationPauseMS);
    }
  }
  saveSourceNoteTranslationCache(cache);
  return { translatedCount, failedCount, reusedEquivalentCount, cacheHits };
}
function convertSimplifiedNotesToTraditional(sourceNotes) {
  const notes = sourceNotes.map(normalizeTranslatedNote).filter(Boolean);
  if (notes.length === 0) return new Map();
  const swiftCode = `
import Foundation
let inputData = FileHandle.standardInput.readDataToEndOfFile()
let notes = try JSONDecoder().decode([String].self, from: inputData)
let converted = notes.map { note -> String in
    let mutable = NSMutableString(string: note)
    CFStringTransform(mutable, nil, "Simplified-Traditional" as CFString, false)
    return mutable as String
}
let outputData = try JSONEncoder().encode(converted)
FileHandle.standardOutput.write(outputData)
`;
  const result = spawnSync("/usr/bin/swift", ["-e", swiftCode], {
    input: JSON.stringify(notes),
    encoding: "utf8",
    maxBuffer: 64 * 1024 * 1024,
  });
  if (result.error) {
    throw result.error;
  }
  if (result.status !== 0) {
    throw new Error(`zh-Hant conversion failed:\n${result.stderr}`);
  }
  const converted = JSON.parse(result.stdout);
  if (!Array.isArray(converted) || converted.length !== notes.length) {
    throw new Error("zh-Hant conversion returned an unexpected result shape");
  }
  return new Map(notes.map((note, index) => [note, normalizeTranslatedNote(converted[index])]));
}
function buildLocalizedNotes(sourceNote, supportedLanguages, cache, traditionalNote) {
  const cleanSourceNote = normalizeTranslatedNote(sourceNote);
  if (!cleanSourceNote) return {};
  const notes = {};
  for (const languageCode of supportedLanguages) {
    if (languageCode === "zh-Hans") {
      notes[languageCode] = cleanSourceNote;
      continue;
    }
    if (languageCode === "zh-Hant") {
      const cleanTraditionalNote = normalizeTranslatedNote(traditionalNote);
      if (cleanTraditionalNote) notes[languageCode] = cleanTraditionalNote;
      continue;
    }
    const translatedNote = normalizeTranslatedNote(cache.entries[translationCacheKey(languageCode, cleanSourceNote)]);
    if (translatedNote) notes[languageCode] = translatedNote;
  }
  return Object.fromEntries(
    Object.entries(notes).sort(([left], [right]) => left.localeCompare(right)),
  );
}
function writeJSON(filePath, value) {
  const text = `${JSON.stringify(value, null, 2)}\n`;
  fs.writeFileSync(filePath, text);
  return {
    file: path.basename(filePath),
    sha256: sha256(text),
    bytes: Buffer.byteLength(text),
  };
}
function buildSplitResources({ finalRows, supportedLanguages, generatedAt, noteLimit }) {
  const baseResource = {
    resourceFormatVersion,
    catalogContentVersion,
    generatedAt,
    noteLimit,
    supportedLanguages,
    fallbackLanguages: requiredFallbackLanguages,
    entries: finalRows.map((row) => ({
      entryID: row.entryID,
      rank: row.rank,
      name: row.name,
      normalizedName: row.normalizedName,
      bundleIdentifier: row.bundleIdentifier,
      defaultTag: row.tags,
      sourceEvidence: row.sourceEvidence,
    })),
  };
  const baseInfo = writeJSON(outputs.runtimeBaseJSON, baseResource);
  const notesResources = {};
  for (const languageCode of supportedLanguages) {
    const notesResource = {
      resourceFormatVersion,
      catalogContentVersion,
      notesVersion,
      generatedAt,
      language: languageCode,
      entries: finalRows
        .map((row) => ({
          entryID: row.entryID,
          note: row.notes[languageCode],
        }))
        .filter((entry) => entry.note),
    };
    const notesPath = smartStartNotesPath(languageCode);
    notesResources[languageCode] = {
      ...writeJSON(notesPath, notesResource),
      count: notesResource.entries.length,
    };
  }
  const manifest = {
    resourceFormatVersion,
    catalogContentVersion,
    notesVersion,
    generatedAt,
    noteLimit,
    supportedLanguages,
    fallbackLanguages: requiredFallbackLanguages,
    baseResource: {
      ...baseInfo,
      count: baseResource.entries.length,
    },
    notesResources,
  };
  const manifestInfo = writeJSON(outputs.runtimeManifestJSON, manifest);
  return {
    baseInfo,
    manifestInfo,
    notesResources,
  };
}
@@ -342,16 +795,15 @@
  const tags = [];
  const unknownTokens = [];
  for (const token of splitTags(value)) {
    const mapped = tagerToStableTags.get(token);
    if (!mapped) {
    if (!stableTags.has(token)) {
      unknownTokens.push(token);
      continue;
    }
    tags.push(...mapped);
    tags.push(token);
  }
  return {
    tags: orderedTags(tags.length > 0 ? tags : ["other"]),
    tags: orderedTags(tags),
    unknownTokens,
  };
}
@@ -400,9 +852,21 @@
  return filtered;
}
function build() {
async function build() {
  fs.mkdirSync(outputDir, { recursive: true });
  const translations = loadCategoryTranslations();
  const supportedLanguages = loadSupportedLanguageCodes();
  const runtimeSmartCategoryIDs = loadRuntimeSmartCategoryIDs();
  const missingRuntimeCategories = stableTagOrder.filter((tag) => !runtimeSmartCategoryIDs.has(tag));
  if (missingRuntimeCategories.length > 0) {
    throw new Error(
      `SmartCategoryID is missing generated catalog tags: ${missingRuntimeCategories.join(", ")}`,
    );
  }
  for (const language of requiredFallbackLanguages) {
    if (!supportedLanguages.includes(language)) {
      throw new Error(`required fallback language is missing from Localization: ${language}`);
    }
  }
  const appleNotes = parseAppleNotes();
  const previousRuntimeEntries = fs.existsSync(outputs.runtimeJSON)
    ? new Map(
@@ -421,12 +885,11 @@
    bundleChangedVsPrevious: 0,
    missingLocalizedNotes: 0,
  };
  const curatedRows = dedupeCuratedRows(rawCuratedRows, stats);
  const finalRows = curatedRows.map((row, index) => {
  const curatedRows = rawCuratedRows;
  const preparedRows = 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);
@@ -444,7 +907,6 @@
      stats.rowsWithSourceNotes += 1;
    }
    const notes = buildLocalizedNotes(noteZH);
    const previous = previousRuntimeEntries.get(normalizedName);
    if (previous) {
      if (JSON.stringify(previous.defaultTag ?? []) !== JSON.stringify(mapping.tags)) {
@@ -464,52 +926,94 @@
      normalizedName,
      bundleIdentifier,
      tags: mapping.tags,
      legacyTags,
      noteZH,
      notes,
      sourceEvidence: usedAppleFallback
        ? ["curated_tager_catalog", "apple_default_notes"]
        : ["curated_tager_catalog"],
    };
  });
  const uniqueSourceNotes = [
    ...new Set(preparedRows.map((row) => normalizeTranslatedNote(row.noteZH)).filter(Boolean)),
  ].sort((left, right) => left.localeCompare(right));
  const sourceFingerprint = sourceNotesFingerprint(uniqueSourceNotes);
  const translationCache = loadSourceNoteTranslationCache(sourceFingerprint);
  const traditionalNotesBySource = convertSimplifiedNotesToTraditional(uniqueSourceNotes);
  for (const [sourceNote, traditionalNote] of traditionalNotesBySource.entries()) {
    translationCache.entries[translationCacheKey("zh-Hant", sourceNote)] = traditionalNote;
  }
  const translationStats = await translateMissingNotes(uniqueSourceNotes, supportedLanguages, translationCache);
  if (translationStats.failedCount > 0) {
    throw new Error(`translation failed for ${translationStats.failedCount} source-note/language pairs; JSON generation blocked`);
  }
  const finalRows = preparedRows.map((row) => ({
    ...row,
    entryID: entryIDFor(row),
    notes: buildLocalizedNotes(
      row.noteZH,
      supportedLanguages,
      translationCache,
      traditionalNotesBySource.get(normalizeTranslatedNote(row.noteZH)),
    ),
  }));
  const invalidTagRows = finalRows.filter((row) => row.tags.some((tag) => !stableTags.has(tag)));
  const runtimeInvalidTagRows = finalRows.filter((row) => row.tags.some((tag) => !runtimeSmartCategoryIDs.has(tag)));
  const entryIDCounts = new Map();
  for (const row of finalRows) {
    entryIDCounts.set(row.entryID, (entryIDCounts.get(row.entryID) ?? 0) + 1);
  }
  const duplicateEntryIDs = [...entryIDCounts.entries()].filter(([, count]) => count > 1);
  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 actualLocalizedNoteCount = noteRows.reduce(
    (total, row) => total + Object.keys(row.notes).filter((code) => supportedLanguages.includes(code)).length,
    0,
  );
  const missingLocalizedNotes = noteRows.length * supportedLanguages.length - actualLocalizedNoteCount;
  const missingLocalizedNoteRows = noteRows.filter(
    (row) => supportedLanguages.some((code) => !row.notes[code]),
  );
  if (runtimeInvalidTagRows.length > 0) {
    throw new Error(
      `runtime SmartCategoryID cannot decode ${runtimeInvalidTagRows.length} catalog rows`,
    );
  }
  if (duplicateEntryIDs.length > 0) {
    throw new Error(
      `duplicate Smart Start entryID values: ${duplicateEntryIDs.map(([id]) => id).slice(0, 10).join(", ")}`,
    );
  }
  const noteQualityIssues = [];
  for (const row of noteRows) {
    for (const code of supportedLanguages) {
      if (!row.notes[code]) {
        noteQualityIssues.push({ name: row.name || row.normalizedName, code, issue: "missing_translation" });
      }
    }
    for (const [code, note] of Object.entries(row.notes)) {
      if (charLength(note) > noteLimit) {
        noteQualityIssues.push({ name: row.name, code, issue: `over_limit:${charLength(note)}` });
        noteQualityIssues.push({ name: row.name || row.normalizedName, code, issue: `over_limit:${charLength(note)}` });
      } else if (stripTerminalPunctuation(note) != note) {
        noteQualityIssues.push({ name: row.name, code, issue: "trailing_punctuation" });
        noteQualityIssues.push({ name: row.name || row.normalizedName, code, issue: "trailing_punctuation" });
      } else if (stripLeadingPunctuation(note) != note) {
        noteQualityIssues.push({ name: row.name, code, issue: "leading_punctuation" });
        noteQualityIssues.push({ name: row.name || row.normalizedName, code, issue: "leading_punctuation" });
      } else if (noteRepeatsAppName(note, row.name)) {
        noteQualityIssues.push({ name: row.name || row.normalizedName, code, issue: "repeats_app_name" });
      }
    }
  }
  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(),
    supportedLanguages,
    entries: finalRows.map((row) => ({
      entryID: row.entryID,
      rank: row.rank,
      name: row.name,
      normalizedName: row.normalizedName,
@@ -520,16 +1024,24 @@
    })),
  };
  fs.writeFileSync(outputs.runtimeJSON, `${JSON.stringify(runtime, null, 2)}\n`);
  const splitResources = buildSplitResources({
    finalRows,
    supportedLanguages,
    generatedAt: runtime.generatedAt,
    noteLimit,
  });
  const duplicateRows = [
    ["name", "normalizedName", "tager", "legacyDefaultTag", "runtimeDefaultTag", "bundleIdentifier", "defaultNote-ZH"],
    ["name", "normalizedName", "previousRuntimeDefaultTag", "runtimeDefaultTag", "bundleIdentifier", "defaultNote-ZH"],
    ...finalRows
      .filter((row) => row.legacyTags.join("|") !== row.tags.join("|"))
      .filter((row) => {
        const previous = previousRuntimeEntries.get(row.normalizedName);
        return previous && JSON.stringify(previous.defaultTag ?? []) !== JSON.stringify(row.tags);
      })
      .map((row) => [
        row.name,
        row.normalizedName,
        (curatedRows[row.rank - 1]?.tager ?? ""),
        row.legacyTags.join("|"),
        (previousRuntimeEntries.get(row.normalizedName)?.defaultTag ?? []).join("|"),
        row.tags.join("|"),
        row.bundleIdentifier ?? "null",
        row.noteZH ?? "",
@@ -540,8 +1052,7 @@
  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}`,
    `- curated rows used for JSON: ${curatedRows.length}`,
    `- unknown tager tokens: ${unknownTagerTokens.size}`,
  ].join("\n");
  const topTagLines = stableTagOrder
@@ -561,8 +1072,12 @@
- Review CSV: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.csv\`
- Runtime JSON: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog.json\`
- Split base JSON: \`Research/SmartStart/UltimateDefaultCatalog/${splitResources.baseInfo.file}\`
- Split manifest JSON: \`Research/SmartStart/UltimateDefaultCatalog/${splitResources.manifestInfo.file}\`
- Split notes JSON files: ${Object.keys(splitResources.notesResources).length}
- Duplicate review: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog_DuplicateReview.csv\`
- Translation QA: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog_TranslationQA.md\`
- Source-note translation cache: \`Research/SmartStart/UltimateDefaultCatalog/SmartStart_UltimateDefaultCatalog_SourceNoteTranslationCache.json\`
## Summary
@@ -570,7 +1085,24 @@
- 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}
- Supported languages: ${supportedLanguages.length}
- Unique source notes: ${uniqueSourceNotes.length}
- Expected localized notes: ${noteRows.length * supportedLanguages.length}
- Actual localized notes: ${actualLocalizedNoteCount}
- Missing localized notes: ${missingLocalizedNotes}
- Rows missing at least one localized note: ${missingLocalizedNoteRows.length}
- Source note fingerprint: ${sourceFingerprint}
- Existing source-note cache ignored: ${translationCache.ignoredExistingCache ? "yes" : "no"}
- Translation provider: ${translationProvider}
- Translation backend: ${translationBackend}
- Translation cache hits before run: ${translationStats.cacheHits}
- Equivalent-language cache reuses: ${translationStats.reusedEquivalentCount}
- New non-Chinese translations generated: ${translationStats.translatedCount}
- Translation failures: ${translationStats.failedCount}
- zh-Hant generation: macOS CFStringTransform Simplified-Traditional from zh-Hans source notes
- Invalid tag rows: ${invalidTagRows.length}
- Runtime-invalid tag rows: ${runtimeInvalidTagRows.length}
- Duplicate entryID values: ${duplicateEntryIDs.length}
- Empty normalizedName rows: ${emptyNormalizedRows.length}
- Exact \`other\` rows: ${exactOtherRows.length}
- Mixed \`other\` rows after cleanup: ${mixedOtherRows.length}
@@ -586,9 +1118,10 @@
## 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.
- Runtime JSON is generated strictly from the curated CSV. The generator does not rewrite the source CSV.
- Runtime tags are copied directly from the curated CSV \`tager\` column.
- Runtime notes are generated only from curated CSV \`defaultNote-ZH\`: \`zh-Hans\` is the source note, \`zh-Hant\` is converted from it, and all other supported languages are translated from the same source note.
- The source-note translation cache is only a fingerprint-validated acceleration artifact. Localized tag-summary notes are not valid sources for runtime notes.
`;
  fs.writeFileSync(outputs.report, report);
@@ -601,7 +1134,22 @@
- 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)}
- Required zh-Hant conversions: ${noteRows.length}
- Required non-Chinese machine/reviewed translations: ${noteRows.length * (runtime.supportedLanguages.length - 2)}
- Unique source notes: ${uniqueSourceNotes.length}
- Expected localized notes including source language: ${noteRows.length * runtime.supportedLanguages.length}
- Actual localized notes: ${actualLocalizedNoteCount}
- Missing localized notes: ${missingLocalizedNotes}
- Rows missing at least one localized note: ${missingLocalizedNoteRows.length}
- Source note fingerprint: ${sourceFingerprint}
- Existing source-note cache ignored: ${translationCache.ignoredExistingCache ? "yes" : "no"}
- Translation provider: ${translationProvider}
- Translation backend: ${translationBackend}
- Translation cache hits before run: ${translationStats.cacheHits}
- Equivalent-language cache reuses: ${translationStats.reusedEquivalentCount}
- New non-Chinese translations generated: ${translationStats.translatedCount}
- Translation failures: ${translationStats.failedCount}
- zh-Hant generation: macOS CFStringTransform Simplified-Traditional from zh-Hans source notes
- Generated placeholder translations: 0
- Note quality issues: ${noteQualityIssues.length}
- Note limit: ${noteLimit}
@@ -615,15 +1163,30 @@
  return {
    finalRows: finalRows.length,
    noteRows: noteRows.length,
    splitBaseBytes: splitResources.baseInfo.bytes,
    splitNotesFiles: Object.keys(splitResources.notesResources).length,
    changedVsPrevious: stats.tagChangedVsPrevious,
    invalidTagRows: invalidTagRows.length,
    runtimeInvalidTagRows: runtimeInvalidTagRows.length,
    duplicateEntryIDs: duplicateEntryIDs.length,
    emptyNormalizedRows: emptyNormalizedRows.length,
    exactOtherRows: exactOtherRows.length,
    mixedOtherRows: mixedOtherRows.length,
    supportedLanguages: supportedLanguages.length,
    uniqueSourceNotes: uniqueSourceNotes.length,
    expectedLocalizedNotes: noteRows.length * supportedLanguages.length,
    actualLocalizedNotes: actualLocalizedNoteCount,
    missingLocalizedNotes,
    sourceFingerprint,
    sourceNoteCacheIgnoredExisting: translationCache.ignoredExistingCache,
    translationCacheHitsBeforeRun: translationStats.cacheHits,
    equivalentLanguageCacheReuses: translationStats.reusedEquivalentCount,
    newNonChineseTranslationsGenerated: translationStats.translatedCount,
    translationFailures: translationStats.failedCount,
    noteQualityIssues: noteQualityIssues.length,
    unknownTagerTokens: [...unknownTagerTokens].sort(),
    outputDir,
  };
}
console.log(JSON.stringify(build(), null, 2));
console.log(JSON.stringify(await build(), null, 2));