{"version":3,"file":"migrate-CfERwXhY.mjs","names":[],"sources":["../src/registry/migrate.ts"],"sourcesContent":["/**\n * Migration helper: imports the existing JSON session-registry into the\n * new SQLite registry.db.\n *\n * Source file:  ~/.claude/session-registry.json\n * Target:       openRegistry() → projects + sessions tables\n *\n * The JSON registry uses encoded directory names as keys (Claude Code's\n * encoding: leading `/` is replaced by `-`, then each remaining `/` is also\n * replaced by `-`).  This module reverses that encoding to recover the real\n * filesystem path.\n *\n * Session note filenames are expected in one of two formats:\n *   Modern:  \"NNNN - YYYY-MM-DD - Description.md\"   (space-dash-space)\n *   Legacy:  \"NNNN_YYYY-MM-DD_description.md\"        (underscores)\n */\n\nimport { existsSync, readdirSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { Database } from \"better-sqlite3\";\nimport { smartDecodeDir } from \"../cli/utils.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** Shape of a single entry in session-registry.json */\ninterface RegistryEntry {\n  /** Absolute path to the Notes/ directory for this project */\n  notesDir?: string;\n  /** Display name stored in the registry (optional) */\n  displayName?: string;\n  /** Any other keys the file might carry */\n  [key: string]: unknown;\n}\n\n/** Top-level shape of session-registry.json */\ntype SessionRegistry = Record<string, RegistryEntry>;\n\n// ---------------------------------------------------------------------------\n// Encoding / decoding\n// ---------------------------------------------------------------------------\n\n/**\n * Build a lookup table from session-registry.json mapping encoded_dir →\n * original_path.  This is the authoritative source for decoding because the\n * encoding is ambiguous: `/`, ` ` (space), `.` (dot), and `-` (literal\n * hyphen) all map to `-` or `--` in ways that cannot be uniquely reversed.\n *\n * Example:\n *   `-Users-alice--ssh`  encodes  `/Users/alice/.ssh`\n *   `-Users-alice-dev-projects-04---My-App-My-App-2020---2029`\n *                        encodes  `/Users/alice/dev/projects/04 - My-App/My-App 2020 - 2029`\n *\n * @param jsonPath  Path to session-registry.json.\n *                  Defaults to ~/.claude/session-registry.json.\n * @returns Map from encoded_dir → original_path, or empty map if the file is\n *          missing / unparseable.\n */\nexport function buildEncodedDirMap(\n  jsonPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): Map<string, string> {\n  const map = new Map<string, string>();\n  if (!existsSync(jsonPath)) return map;\n\n  try {\n    const raw = readFileSync(jsonPath, \"utf8\");\n    const parsed = JSON.parse(raw) as Record<string, unknown>;\n\n    // Support both formats:\n    //   list-based:   { \"projects\": [ { \"encoded_dir\", \"original_path\" }, ... ] }\n    //   object-keyed: { \"<encoded_dir>\": { ... } }  (original Claude format)\n    if (Array.isArray(parsed.projects)) {\n      for (const entry of parsed.projects as Array<Record<string, unknown>>) {\n        const key = entry.encoded_dir as string | undefined;\n        const val = entry.original_path as string | undefined;\n        if (key && val) map.set(key, val);\n      }\n    } else {\n      // Object-keyed format — keys are encoded dirs\n      for (const [key, value] of Object.entries(parsed)) {\n        if (key === \"version\") continue;\n        const val = (value as Record<string, unknown>)?.original_path as\n          | string\n          | undefined;\n        if (val) map.set(key, val);\n      }\n    }\n  } catch {\n    // Unparseable — return empty map; callers fall back to heuristic decode\n  }\n\n  return map;\n}\n\n/**\n * Reverse Claude Code's directory encoding.\n *\n * Claude Code's actual encoding rules:\n *   - `/` (path separator) → `-`\n *   - ` ` (space)          → `--`  (escaped)\n *   - `.` (dot)            → `--`  (escaped)\n *   - `-` (literal hyphen) → `--`  (escaped)\n *\n * Because space, dot, and hyphen all encode to `--`, the encoding is\n * **lossy** — you cannot unambiguously reverse it.  This function therefore\n * provides a *best-effort* heuristic decode (treating `--` as a literal `-`\n * which gives wrong results for paths with spaces or dots).\n *\n * PREFER using {@link buildEncodedDirMap} to get the authoritative mapping\n * from session-registry.json instead of calling this function directly.\n *\n * Examples (best-effort, may be wrong for paths with spaces/dots):\n *   `-Users-alice-dev-apps-MyProject` → `/Users/alice/dev/apps/MyProject`\n *   `-Users-alice--ssh`               → `/Users/alice/-ssh` ← WRONG (actually .ssh)\n *\n * @param encoded   The Claude-encoded directory name.\n * @param lookupMap Optional authoritative map from {@link buildEncodedDirMap}.\n *                  If provided and the key is found, that value is returned\n *                  instead of the heuristic result.\n */\nexport function decodeEncodedDir(\n  encoded: string,\n  lookupMap?: Map<string, string>\n): string {\n  // Authoritative lookup wins — but only if the path actually exists on disk.\n  // session-registry.json may contain stale or incorrectly decoded paths.\n  if (lookupMap?.has(encoded)) {\n    const mapped = lookupMap.get(encoded)!;\n    if (existsSync(mapped)) return mapped;\n  }\n\n  // Filesystem-walking decode (handles spaces, dots, hyphens correctly)\n  const smart = smartDecodeDir(encoded);\n  if (smart) return smart;\n\n  // Fall back to lookup map even if path doesn't exist (for display purposes)\n  if (lookupMap?.has(encoded)) {\n    return lookupMap.get(encoded)!;\n  }\n\n  // Last resort: every `-` maps to `/` (wrong for paths with spaces/dots/hyphens)\n  if (encoded.startsWith(\"-\")) {\n    return encoded.replace(/-/g, \"/\");\n  }\n\n  // Not a Claude-encoded path — return as-is\n  return encoded;\n}\n\n// ---------------------------------------------------------------------------\n// Slug generation\n// ---------------------------------------------------------------------------\n\n/**\n * Derive a URL-safe kebab-case slug from an arbitrary string.\n *\n * Uses the last path component so that `/Users/alice/dev/my-app` → `my-app`.\n */\nexport function slugify(value: string): string {\n  // Take last path segment if it looks like a path\n  const segment = value.includes(\"/\")\n    ? value.replace(/\\/$/, \"\").split(\"/\").pop() ?? value\n    : value;\n\n  return segment\n    .toLowerCase()\n    .replace(/[^a-z0-9]+/g, \"-\") // non-alphanumeric runs → single dash\n    .replace(/^-+|-+$/g, \"\");    // trim leading/trailing dashes\n}\n\n// ---------------------------------------------------------------------------\n// Session note parsing\n// ---------------------------------------------------------------------------\n\ninterface ParsedSession {\n  number: number;\n  date: string;\n  slug: string;\n  title: string;\n  filename: string;\n}\n\n/** Match `0027 - 2026-01-04 - Some Description.md` */\nconst MODERN_RE = /^(\\d{4})\\s+-\\s+(\\d{4}-\\d{2}-\\d{2})\\s+-\\s+(.+)\\.md$/i;\n\n/** Match `0027_2026-01-04_some_description.md` */\nconst LEGACY_RE = /^(\\d{4})_(\\d{4}-\\d{2}-\\d{2})_(.+)\\.md$/i;\n\n/**\n * Attempt to parse a session note filename into its structured parts.\n *\n * Returns `null` if the filename does not match either known format.\n */\nexport function parseSessionFilename(\n  filename: string\n): ParsedSession | null {\n  let m = MODERN_RE.exec(filename);\n  if (m) {\n    const [, num, date, description] = m;\n    return {\n      number: parseInt(num, 10),\n      date,\n      slug: slugify(description),\n      title: description.trim(),\n      filename,\n    };\n  }\n\n  m = LEGACY_RE.exec(filename);\n  if (m) {\n    const [, num, date, rawDesc] = m;\n    const description = rawDesc.replace(/_/g, \" \");\n    return {\n      number: parseInt(num, 10),\n      date,\n      slug: slugify(description),\n      title: description.trim(),\n      filename,\n    };\n  }\n\n  return null;\n}\n\n// ---------------------------------------------------------------------------\n// Migration\n// ---------------------------------------------------------------------------\n\nexport interface MigrationResult {\n  projectsInserted: number;\n  projectsSkipped: number;\n  sessionsInserted: number;\n  errors: string[];\n}\n\n/**\n * Migrate the existing JSON session-registry into the SQLite registry.\n *\n * @param db            Open better-sqlite3 Database (target).\n * @param registryPath  Path to session-registry.json.\n *                      Defaults to ~/.claude/session-registry.json.\n *\n * The migration is idempotent: projects and sessions that already exist\n * (matched by slug / project_id+number) are silently skipped.\n */\nexport function migrateFromJson(\n  db: Database,\n  registryPath: string = join(homedir(), \".claude\", \"session-registry.json\")\n): MigrationResult {\n  const result: MigrationResult = {\n    projectsInserted: 0,\n    projectsSkipped: 0,\n    sessionsInserted: 0,\n    errors: [],\n  };\n\n  // ── Load source file ──────────────────────────────────────────────────────\n  if (!existsSync(registryPath)) {\n    result.errors.push(`Registry file not found: ${registryPath}`);\n    return result;\n  }\n\n  let registry: SessionRegistry;\n  try {\n    const raw = readFileSync(registryPath, \"utf8\");\n    registry = JSON.parse(raw) as SessionRegistry;\n  } catch (err) {\n    result.errors.push(`Failed to parse registry JSON: ${String(err)}`);\n    return result;\n  }\n\n  // ── Prepared statements ───────────────────────────────────────────────────\n  const insertProject = db.prepare(`\n    INSERT OR IGNORE INTO projects\n      (slug, display_name, root_path, encoded_dir, type, status,\n       created_at, updated_at)\n    VALUES\n      (@slug, @display_name, @root_path, @encoded_dir, 'local', 'active',\n       @created_at, @updated_at)\n  `);\n\n  const getProject = db.prepare(\n    \"SELECT id FROM projects WHERE slug = ?\"\n  );\n\n  const insertSession = db.prepare(`\n    INSERT OR IGNORE INTO sessions\n      (project_id, number, date, slug, title, filename, status, created_at)\n    VALUES\n      (@project_id, @number, @date, @slug, @title, @filename, 'completed',\n       @created_at)\n  `);\n\n  const now = Date.now();\n\n  // ── Build authoritative encoded-dir → path lookup ─────────────────────────\n  const lookupMap = buildEncodedDirMap(registryPath);\n\n  // ── Process each encoded directory entry ──────────────────────────────────\n  for (const [encodedDir, entry] of Object.entries(registry)) {\n    const rootPath = decodeEncodedDir(encodedDir, lookupMap);\n    const baseSlug = slugify(rootPath);\n\n    // --- Upsert project ---\n    let slug = baseSlug;\n    let attempt = 0;\n    while (true) {\n      const info = insertProject.run({\n        slug,\n        display_name:\n          (entry.displayName as string | undefined) ??\n          (rootPath.split(\"/\").pop() ?? rootPath),\n        root_path: rootPath,\n        encoded_dir: encodedDir,\n        created_at: now,\n        updated_at: now,\n      });\n\n      if (info.changes > 0) {\n        result.projectsInserted++;\n        break;\n      }\n\n      // Row existed — check if it's ours (matching root_path) or a collision\n      const existing = db\n        .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n        .get(rootPath);\n      if (existing) {\n        result.projectsSkipped++;\n        break;\n      }\n\n      // Genuine slug collision — append numeric suffix and retry\n      attempt++;\n      slug = `${baseSlug}-${attempt}`;\n    }\n\n    const projectRow = getProject.get(slug) as { id: number } | undefined;\n    // Also check by root_path in case slug was different\n    const projectById = projectRow ??\n      (db\n        .prepare(\"SELECT id FROM projects WHERE root_path = ?\")\n        .get(rootPath) as { id: number } | undefined);\n\n    if (!projectById) {\n      result.errors.push(\n        `Could not resolve project id for encoded dir: ${encodedDir}`\n      );\n      continue;\n    }\n\n    const projectId = projectById.id;\n\n    // --- Scan Notes/ directory for session notes ---\n    const notesDir =\n      typeof entry.notesDir === \"string\"\n        ? entry.notesDir\n        : join(rootPath, \"Notes\");\n\n    if (!existsSync(notesDir)) {\n      // No notes directory — that is fine, project still gets created\n      continue;\n    }\n\n    let files: string[];\n    try {\n      files = readdirSync(notesDir);\n    } catch (err) {\n      result.errors.push(\n        `Cannot read notes dir ${notesDir}: ${String(err)}`\n      );\n      continue;\n    }\n\n    for (const filename of files) {\n      if (!filename.endsWith(\".md\")) continue;\n\n      const parsed = parseSessionFilename(filename);\n      if (!parsed) continue;\n\n      try {\n        const info = insertSession.run({\n          project_id: projectId,\n          number: parsed.number,\n          date: parsed.date,\n          slug: parsed.slug,\n          title: parsed.title,\n          filename: parsed.filename,\n          created_at: now,\n        });\n        if (info.changes > 0) result.sessionsInserted++;\n      } catch (err) {\n        result.errors.push(\n          `Failed to insert session ${filename}: ${String(err)}`\n        );\n      }\n    }\n  }\n\n  return result;\n}\n"],"mappings":"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"}