ckelsoe_obsidian-plaud-impo.../folder-catalog.ts
Charles Kelsoe cc8caa1c88
Resolve Plaud folders to names in frontmatter and tags (issue #16) (#24)
* Resolve Plaud folders to names in frontmatter and tags (issue #16)

On import, resolve each recording's filetag_id_list (opaque folder ids) to
human folder names via a GET /filetag/ catalog, and:

- write the resolved name(s) to a new plaud-folder: frontmatter field, and
- fix a latent bug where the raw folder ids leaked into tags: under the
  default plaud tag mode. Names are slugified into valid tag tokens; the
  pretty original names stay in plaud-folder:.

Client: add PlaudFolder + getFolderCatalog() to the PlaudClient contract,
implemented in the RE client with a per-session cache and a drift-tolerant
parse (a malformed filetag is skipped, not fatal, so one bad entry never
costs every recording its folder resolution).

folder-catalog.ts: pure buildFolderNameMap/resolveFolderNames/folderNameToTag
helpers (no I/O, no obsidian import). resolveFolderNames dedups ids and names,
tracks misses, and drops blank names. An unresolved id is dropped, never shown
raw.

import-runner: fetch the catalog once per run (best-effort; a fetch failure or
absent dep degrades to no folders and never fails the import), resolve per
recording, feed slugified names to buildNoteTags and pretty names to
plaud-folder:. Wired into both runImport call sites (modal + auto-sync).

Tier 1 (frontmatter) only; Tier 2 subfolder mapping stays deferred.

Tests: id->name resolution (hit/miss/mixed/dedup/blank), slug edge cases,
catalog parse + per-session cache + malformed-skip, and end-to-end that
tags: use names not ids, plaud-folder: is present/absent correctly, and an
absent or failing catalog does not leak ids or fail the import.

* Accept null icon/color in the filetag guard (CodeRabbit #24)

Plaud's /filetag/ returns null for icon/color on a folder with no custom
styling. isRawFiletag rejected null, which would silently drop such a folder
from the catalog and lose its name for resolution. Accept null in the guard
and coerce it to undefined when building PlaudFolder, so the public type stays
string | undefined. Applies to both icon and color (the review flagged color;
icon had the same latent issue).
2026-07-03 07:55:48 -04:00

93 lines
3.5 KiB
TypeScript

// -----------------------------------------------------------------------------
// folder-catalog.ts
//
// Pure helpers for turning Plaud's flat folder/tag catalog into the two things
// the import path needs:
// 1. human folder NAMES for the `plaud-folder:` frontmatter field, and
// 2. valid Obsidian tag tokens for the `tags:` field (issue #16 fixes the
// latent bug where raw filetag IDs leaked into `tags:`).
//
// No I/O and no `obsidian` import: the client fetches `GET /filetag/`, these
// functions do the id->name join and the name->tag slug so both are unit
// testable in isolation. Phase 4 (folder mirroring) will extend this module
// with the reverse name->id direction.
// -----------------------------------------------------------------------------
import type { PlaudFolder } from './plaud-client';
/**
* Index a folder catalog by id for O(1) name lookup. Built once per import run
* (the catalog is small) and reused across every recording. First occurrence
* wins if Plaud ever returns a duplicate id, so the map is deterministic.
*/
export function buildFolderNameMap(
catalog: readonly PlaudFolder[],
): ReadonlyMap<string, string> {
const map = new Map<string, string>();
for (const folder of catalog) {
if (folder.id.length === 0 || map.has(folder.id)) {
continue;
}
map.set(folder.id, folder.name);
}
return map;
}
/**
* Resolve a recording's `filetag_id_list` to folder names using a prebuilt
* name map. Duplicate ids and duplicate resolved names collapse (a note should
* never show `[Work, Work]`), preserving first-seen order. Ids with no catalog
* entry are dropped from `names` and returned in `missing` so the caller can
* debug-log them. A miss means the id has no name to show, so surfacing the
* raw id would just reintroduce the bug this feature fixes.
*/
export function resolveFolderNames(
ids: readonly string[] | undefined,
nameMap: ReadonlyMap<string, string>,
): { names: string[]; missing: string[] } {
const names: string[] = [];
const missing: string[] = [];
const seenIds = new Set<string>();
const seenNames = new Set<string>();
for (const id of ids ?? []) {
if (typeof id !== 'string' || id.length === 0 || seenIds.has(id)) {
continue;
}
seenIds.add(id);
const name = nameMap.get(id);
if (name === undefined) {
missing.push(id);
continue;
}
// A resolvable id with a blank name (an unnamed Plaud folder) has nothing
// to show: skip it silently rather than emit an empty `plaud-folder`
// entry or a bare dash tag. Not a "miss": the id did resolve.
if (name.trim().length === 0 || seenNames.has(name)) {
continue;
}
seenNames.add(name);
names.push(name);
}
return { names, missing };
}
/**
* Convert a folder name into a single valid Obsidian tag token. Obsidian tags
* allow letters, numbers, and `_`; spaces and other punctuation are not valid
* inside a tag, so a raw name like `"B&B"` or `"Daily Journal"` would break the
* tag pane. Runs of any non-tag character collapse to a single dash, dashes are
* de-duplicated, and leading/trailing dashes are trimmed. Unicode letters and
* digits are preserved (Obsidian tags accept them). Returns `''` when nothing
* usable remains (e.g. a name of only punctuation); callers filter those out.
*
* "Daily Journal" -> "daily-journal"
* "B&B" -> "b-b"
* " Work " -> "work"
*/
export function folderNameToTag(name: string): string {
return name
.toLowerCase()
.replace(/[^\p{L}\p{N}_]+/gu, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}