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).
This commit is contained in:
Charles Kelsoe 2026-07-03 07:55:48 -04:00 committed by GitHub
parent f8eac7f336
commit cc8caa1c88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 624 additions and 5 deletions

View file

@ -4,6 +4,14 @@ All notable changes to Plaud Importer will be documented in this file.
## [Unreleased]
### Added
- Imported notes now record which Plaud folder each recording is in. Every note gets a `plaud-folder` frontmatter field holding the folder name(s), so you can find and group recordings by their Plaud folder in Obsidian, for example with Dataview. A recording that is not filed in a folder gets no field.
### Fixed
- Plaud folder tags now appear as readable names. A recording filed in a Plaud folder used to write the folder's internal id (a long code) into the note's `tags`; imported notes now use the folder name instead. Names are converted to valid tag form, so spaces and symbols become dashes.
## [0.18.1] - 2026-07-03
### Changed

View file

@ -0,0 +1,122 @@
import {
buildFolderNameMap,
folderNameToTag,
resolveFolderNames,
} from '../folder-catalog';
import type { PlaudFolder } from '../plaud-client';
const CATALOG: readonly PlaudFolder[] = [
{ id: 'id-work', name: 'Work', icon: 'e644', color: '#fff' },
{ id: 'id-journal', name: 'Daily Journal' },
{ id: 'id-bnb', name: 'B&B' },
];
describe('buildFolderNameMap', () => {
it('indexes the catalog by id', () => {
const map = buildFolderNameMap(CATALOG);
expect(map.get('id-work')).toBe('Work');
expect(map.get('id-journal')).toBe('Daily Journal');
expect(map.size).toBe(3);
});
it('skips entries with an empty id', () => {
const map = buildFolderNameMap([{ id: '', name: 'Orphan' }, ...CATALOG]);
expect(map.has('')).toBe(false);
expect(map.size).toBe(3);
});
it('keeps the first name when an id repeats', () => {
const map = buildFolderNameMap([
{ id: 'dup', name: 'First' },
{ id: 'dup', name: 'Second' },
]);
expect(map.get('dup')).toBe('First');
});
it('returns an empty map for an empty catalog', () => {
expect(buildFolderNameMap([]).size).toBe(0);
});
});
describe('resolveFolderNames', () => {
const map = buildFolderNameMap(CATALOG);
it('resolves a hit to its name', () => {
const { names, missing } = resolveFolderNames(['id-work'], map);
expect(names).toEqual(['Work']);
expect(missing).toEqual([]);
});
it('reports a miss and drops it from names', () => {
const { names, missing } = resolveFolderNames(['id-gone'], map);
expect(names).toEqual([]);
expect(missing).toEqual(['id-gone']);
});
it('handles a mix of hits and misses, preserving order', () => {
const { names, missing } = resolveFolderNames(
['id-journal', 'id-gone', 'id-work'],
map,
);
expect(names).toEqual(['Daily Journal', 'Work']);
expect(missing).toEqual(['id-gone']);
});
it('returns empty results for undefined or empty ids', () => {
expect(resolveFolderNames(undefined, map)).toEqual({ names: [], missing: [] });
expect(resolveFolderNames([], map)).toEqual({ names: [], missing: [] });
});
it('collapses a repeated id and skips empty/non-string ids', () => {
const { names, missing } = resolveFolderNames(
['id-work', 'id-work', '', 'id-work'],
map,
);
expect(names).toEqual(['Work']);
expect(missing).toEqual([]);
});
it('skips a resolvable id whose folder name is blank (not a miss)', () => {
const blankMap = buildFolderNameMap([{ id: 'blank', name: ' ' }]);
const { names, missing } = resolveFolderNames(['blank'], blankMap);
expect(names).toEqual([]);
expect(missing).toEqual([]);
});
it('collapses two ids that resolve to the same name', () => {
const twinMap = buildFolderNameMap([
{ id: 'a', name: 'Same' },
{ id: 'b', name: 'Same' },
]);
const { names } = resolveFolderNames(['a', 'b'], twinMap);
expect(names).toEqual(['Same']);
});
});
describe('folderNameToTag', () => {
it('lowercases and dashes spaces', () => {
expect(folderNameToTag('Daily Journal')).toBe('daily-journal');
});
it('replaces invalid tag characters (& etc.) with a dash', () => {
expect(folderNameToTag('B&B')).toBe('b-b');
});
it('trims leading and trailing separators', () => {
expect(folderNameToTag(' Work ')).toBe('work');
expect(folderNameToTag('#tag!')).toBe('tag');
});
it('collapses runs of separators into a single dash', () => {
expect(folderNameToTag('A & B')).toBe('a-b');
});
it('preserves letters, digits, and underscore', () => {
expect(folderNameToTag('Q2_Review')).toBe('q2_review');
});
it('returns empty string when nothing usable remains', () => {
expect(folderNameToTag('!!!')).toBe('');
expect(folderNameToTag(' ')).toBe('');
});
});

View file

@ -243,6 +243,110 @@ describe('runImport', () => {
expect(written).toEqual([recording.id]);
});
// Issue #16: folder ids resolve to names in both plaud-folder: and tags:.
it('resolves folder ids to names: plaud-folder set, tags use names not ids', async () => {
const vault = makeFakeVault();
const recording = makeRecording({ tags: ['id-work', 'id-bnb'] });
const { fetchArtifacts } = makeFetch(
new Map([[recording.id, makeArtifacts(recording)]]),
);
const outcome = await runImport({
recordings: [recording],
selection: SELECTION,
writer: makeWriter(vault),
attachments: makeAttachmentStub().pipeline,
options: OPTIONS,
fetchArtifacts,
fetchFolderCatalog: async () => [
{ id: 'id-work', name: 'Work' },
{ id: 'id-bnb', name: 'B&B' },
],
});
expect(outcome.stop).toBe('completed');
const note = [...vault.files.values()][0];
// plaud-folder keeps the pretty original names (quoting where needed).
expect(note).toContain('plaud-folder: [Work, "B&B"]');
// tags: carries the slugified names, and the raw ids never appear.
expect(note).toContain('tags: [work, b-b]');
expect(note).not.toContain('id-work');
expect(note).not.toContain('id-bnb');
});
it('drops unresolved folder ids from tags and plaud-folder rather than leaking them', async () => {
const vault = makeFakeVault();
const recording = makeRecording({ tags: ['id-known', 'id-gone'] });
const { fetchArtifacts } = makeFetch(
new Map([[recording.id, makeArtifacts(recording)]]),
);
await runImport({
recordings: [recording],
selection: SELECTION,
writer: makeWriter(vault),
attachments: makeAttachmentStub().pipeline,
options: OPTIONS,
fetchArtifacts,
fetchFolderCatalog: async () => [{ id: 'id-known', name: 'Known' }],
});
const note = [...vault.files.values()][0];
expect(note).toContain('plaud-folder: [Known]');
expect(note).toContain('tags: [known]');
expect(note).not.toContain('id-gone');
});
it('does not leak raw folder ids when no folder catalog dep is provided', async () => {
const vault = makeFakeVault();
const recording = makeRecording({ tags: ['id-work'] });
const { fetchArtifacts } = makeFetch(
new Map([[recording.id, makeArtifacts(recording)]]),
);
// No fetchFolderCatalog: mirrors an older caller. Ids must vanish, never
// fall through raw into tags: (the pre-#16 bug).
const outcome = await runImport({
recordings: [recording],
selection: SELECTION,
writer: makeWriter(vault),
attachments: makeAttachmentStub().pipeline,
options: OPTIONS,
fetchArtifacts,
});
expect(outcome.stop).toBe('completed');
const note = [...vault.files.values()][0];
expect(note).not.toContain('id-work');
expect(note).not.toMatch(/plaud-folder:/);
});
it('completes the import when the folder catalog fetch fails', async () => {
const vault = makeFakeVault();
const recording = makeRecording({ tags: ['id-work'] });
const { fetchArtifacts } = makeFetch(
new Map([[recording.id, makeArtifacts(recording)]]),
);
const outcome = await runImport({
recordings: [recording],
selection: SELECTION,
writer: makeWriter(vault),
attachments: makeAttachmentStub().pipeline,
options: OPTIONS,
fetchArtifacts,
fetchFolderCatalog: async () => {
throw new Error('boom');
},
});
expect(outcome.stop).toBe('completed');
expect(outcome.results[0]).toMatchObject({ kind: 'written' });
const note = [...vault.files.values()][0];
expect(note).not.toContain('id-work');
expect(note).not.toMatch(/plaud-folder:/);
});
it('overwrites an existing note on a second run with onDuplicate overwrite', async () => {
const vault = makeFakeVault();
const recording = makeRecording();

View file

@ -552,6 +552,36 @@ describe('formatFrontmatter', () => {
expect(fm).toContain('keywords: ["Q2: Review", "true"]');
});
// plaud-folder (issue #16): resolved folder NAMES for the recording. Kept
// separate from tags: so the pretty name survives; the field is an array to
// tolerate multi-folder data even though single-folder is the Plaud norm.
it('emits a single folder name as a plaud-folder array', () => {
const fm = formatFrontmatter(
makeRecording({ tags: ['work'] }),
[],
null,
undefined,
['Daily Journal'],
);
expect(fm).toContain('plaud-folder: [Daily Journal]');
});
it('emits multiple folder names, quoting ones with special characters', () => {
const fm = formatFrontmatter(makeRecording(), [], null, undefined, [
'Daily Journal',
'B&B',
]);
expect(fm).toContain('plaud-folder: [Daily Journal, "B&B"]');
});
it('omits plaud-folder when folders is absent or empty', () => {
expect(formatFrontmatter(makeRecording(), [])).not.toMatch(/plaud-folder:/);
expect(
formatFrontmatter(makeRecording(), [], null, undefined, []),
).not.toMatch(/plaud-folder:/);
});
it('clamps negative/infinite durations in the duration-seconds line', () => {
const fm = formatFrontmatter(
makeRecording({ durationSeconds: -10 }),

View file

@ -3718,3 +3718,77 @@ describe('parseAudioTempUrl', () => {
expect(() => parseAudioTempUrl('nope', '/file/temp-url/x')).toThrow(PlaudParseError);
});
});
// getFolderCatalog (issue #16): fetch + cache the flat folder/tag catalog.
describe('getFolderCatalog', () => {
const filetagEnvelope = (items: unknown[]): Record<string, unknown> => ({
status: 0,
msg: 'success',
request_id: 'req-ft',
data_filetag_total: items.length,
data_filetag_list: items,
});
it('parses the flat {id,name,icon,color} shape and calls GET /filetag/', async () => {
const { fetcher, lastRequest } = captureFetcher(
ok(
filetagEnvelope([
{ id: 'a', name: 'Work', icon: 'e644', color: '#fff' },
{ id: 'b', name: 'B&B' },
]),
),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
const catalog = await client.getFolderCatalog();
expect(catalog[0]).toEqual({ id: 'a', name: 'Work', icon: 'e644', color: '#fff' });
expect(catalog[1].id).toBe('b');
expect(catalog[1].name).toBe('B&B');
expect(lastRequest()?.url).toContain('/filetag/');
expect(lastRequest()?.method).toBe('GET');
});
it('caches per session: a second call does not refetch', async () => {
const { fetcher, allRequests } = captureFetcher(
ok(filetagEnvelope([{ id: 'a', name: 'Work' }])),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
await client.getFolderCatalog();
await client.getFolderCatalog();
expect(allRequests().length).toBe(1);
});
it('keeps a filetag whose icon/color are null (no custom styling)', async () => {
const { fetcher } = captureFetcher(
ok(filetagEnvelope([{ id: 'a', name: 'Work', icon: null, color: null }])),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
const catalog = await client.getFolderCatalog();
expect(catalog).toHaveLength(1);
expect(catalog[0].id).toBe('a');
expect(catalog[0].name).toBe('Work');
expect(catalog[0].icon).toBeUndefined();
expect(catalog[0].color).toBeUndefined();
});
it('skips malformed entries instead of failing the whole catalog', async () => {
const { fetcher } = captureFetcher(
ok(
filetagEnvelope([
{ id: 'a', name: 'Work' },
{ id: 'b' }, // missing name
{ name: 'no id' }, // missing id
{ id: 'c', name: 'Ok' },
]),
),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
const catalog = await client.getFolderCatalog();
expect(catalog.map((f) => f.id)).toEqual(['a', 'c']);
});
it('throws a parse error when data_filetag_list is missing', async () => {
const { fetcher } = captureFetcher(ok({ status: 0, msg: 'x' }));
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
await expect(client.getFolderCatalog()).rejects.toBeInstanceOf(PlaudParseError);
});
});

93
folder-catalog.ts Normal file
View file

@ -0,0 +1,93 @@
// -----------------------------------------------------------------------------
// 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, '');
}

View file

@ -1702,6 +1702,7 @@ export class ImportModal extends Modal {
options: this.noteWriterOptions,
fetchArtifacts: (id) => this.ensureArtifactsForRecording(id),
fetchAudioUrl: (id) => this.client.getAudioTempUrl(id),
fetchFolderCatalog: () => this.client.getFolderCatalog(),
applyFold: (filePath) => this.applyNoteFolds(filePath),
observer: {
onRecordingStart: (index, total) => {

View file

@ -17,6 +17,7 @@
import type {
PlaudRecordingId,
PlaudFolder,
Recording,
TranscriptAndSummary,
AttachmentAsset,
@ -27,6 +28,11 @@ import {
type NoteWriter,
type FormatMarkdownOptions,
} from './note-writer';
import {
buildFolderNameMap,
folderNameToTag,
resolveFolderNames,
} from './folder-catalog';
import {
classifyError,
categoryAllowsReauth,
@ -101,6 +107,15 @@ export interface ImportRunDeps {
* artifacts" preflight is not re-fetched.
*/
fetchArtifacts(recordingId: PlaudRecordingId): Promise<TranscriptAndSummary>;
/**
* Resolves the account's flat folder/tag catalog, called ONCE per run to
* turn each recording's `filetag_id_list` into folder names (issue #16).
* Optional: a caller that omits it (or whose fetch fails) simply resolves no
* folders. The import still runs; notes just carry no `plaud-folder:` and no
* folder-derived tags. The modal and auto-sync both inject the client's
* cached `getFolderCatalog`.
*/
fetchFolderCatalog?(): Promise<readonly PlaudFolder[]>;
/**
* Resolves one recording's original-audio download URL, or null when Plaud
* exposes none. Only invoked when `selection.includeAudio` is set, so a
@ -171,6 +186,23 @@ export async function runImport(deps: ImportRunDeps): Promise<ImportRunOutcome>
const total = recordings.length;
const shouldAbort = (): boolean => observer?.shouldAbort?.() ?? false;
// Resolve the folder/tag catalog ONCE for the whole batch (issue #16), then
// reuse the id->name map for every recording. Best-effort: a fetch failure
// (or an absent dep for older callers/tests) degrades to an empty map, so
// notes just carry no folder data rather than the import failing. This is
// also what fixes the latent bug where raw filetag ids leaked into `tags:`:
// names replace ids below, and an unresolved id is dropped, never shown raw.
let folderNameMap: ReadonlyMap<string, string> = new Map();
if (deps.fetchFolderCatalog) {
try {
folderNameMap = buildFolderNameMap(await deps.fetchFolderCatalog());
} catch (err) {
emitImportDebug(options, 'folder catalog fetch failed; folders unresolved', {
reason: err instanceof Error ? err.message : String(err),
});
}
}
const results: ImportResult[] = [];
for (let i = 0; i < total; i++) {
// Bail on mid-import modal close. The caller renders the partial
@ -212,16 +244,35 @@ export async function runImport(deps: ImportRunDeps): Promise<ImportRunOutcome>
attachments ?? [],
summaryLinkedAttachments,
);
// Issue #16: a recording's `tags` are its raw filetag_id_list (opaque
// folder ids). Resolve them to folder names via the catalog before
// building the note: the pretty names go to `plaud-folder:`, and the
// slugified names become the base for `tags:` (replacing the ids that
// leaked before). An id with no catalog entry is dropped, not shown
// raw. Empty-after-slug names (a punctuation-only folder) are filtered.
const { names: folderNames, missing: missingFolderIds } = resolveFolderNames(
recording.tags,
folderNameMap,
);
if (missingFolderIds.length > 0) {
emitImportDebug(options, 'folder ids not in catalog; dropped from tags/plaud-folder', {
recordingId: recording.id,
missing: missingFolderIds,
});
}
const folderTags = folderNames
.map(folderNameToTag)
.filter((tag) => tag.length > 0);
// DD-004: combine Plaud's AI-generated keyword list (from
// /file/detail/), the recording's own tags, and the user's
// custom tags before the note is rendered. buildNoteTags
// /file/detail/), the recording's folder-derived tags, and the
// user's custom tags before the note is rendered. buildNoteTags
// owns the mode filtering, namespacing, slug, and dedup
// rules; this site just feeds it the sources and settings.
// The recording's tags are ALWAYS overwritten with the
// built list (even when empty) so a restrictive tag mode
// cannot leak the raw Plaud tags into the frontmatter.
// formatFrontmatter omits empty tags:/keywords: keys.
const tagResult = buildNoteTags(recording.tags, aiKeywords, {
const tagResult = buildNoteTags(folderTags, aiKeywords, {
tagMode: options.tagMode ?? 'plaud',
customTags: options.customTags ?? '',
aiKeywordsAsProperty: options.aiKeywordsAsProperty ?? true,
@ -231,6 +282,7 @@ export async function runImport(deps: ImportRunDeps): Promise<ImportRunOutcome>
includeTranscript: selection.includeTranscript,
includeSummary: selection.includeSummary,
keywords: tagResult.keywords,
folders: folderNames,
consumerNotes,
};
const selectedChapters = selection.includeTranscript

View file

@ -598,6 +598,7 @@ export default class PlaudImporterPlugin extends Plugin {
options,
fetchArtifacts,
fetchAudioUrl,
fetchFolderCatalog: () => client.getFolderCatalog(),
// Stop between recordings if the plugin unloads mid-tick, so a
// disable/re-enable cannot leave this loop writing while a fresh
// instance starts its own tick.

View file

@ -775,6 +775,7 @@ export function formatFrontmatter(
speakers: readonly string[],
summary?: Summary | null,
keywords?: readonly string[],
folders?: readonly string[],
): string {
const duration = Number.isFinite(recording.durationSeconds)
? Math.max(0, Math.floor(recording.durationSeconds))
@ -800,6 +801,13 @@ export function formatFrontmatter(
if (recording.tags && recording.tags.length > 0) {
lines.push(`tags: ${yamlArray(recording.tags)}`);
}
// Resolved Plaud folder name(s) for the recording (issue #16). Separate from
// tags: this keeps the original folder name (case, spaces, `&`) for humans
// and Dataview, while tags: carries the slugified tag form. Emitted only for
// filed recordings; unfiled ones get no key.
if (folders && folders.length > 0) {
lines.push(`plaud-folder: ${yamlArray(folders)}`);
}
// AI keywords demoted from tags by the tag-mode setting. A plain
// frontmatter property is searchable and Dataview-queryable but does
// not feed Obsidian's vault-wide tag pane.
@ -1341,6 +1349,15 @@ export interface FormatMarkdownOptions {
* from `tags:` and the keep-as-property setting is on.
*/
readonly keywords?: readonly string[];
/**
* Resolved Plaud folder NAMES for this recording, written to the
* `plaud-folder:` frontmatter field (issue #16). These are the human names
* behind the recording's `filetag_id_list`, in original case (the `tags:`
* field gets slugified variants; this field keeps the pretty name). Empty or
* omitted emits no `plaud-folder:` line. In Plaud a recording is normally in
* a single folder, but the field is an array to tolerate multi-folder data.
*/
readonly folders?: readonly string[];
/**
* Extra Plaud AI template outputs (Key Points, Daily Journal, etc.) to
* render in the note as a `## Template outputs` block. Empty or omitted
@ -1368,7 +1385,7 @@ export function formatMarkdown(
? formatTranscriptSection(transcript, groups, headerLevel)
: '';
const parts: string[] = [
formatFrontmatter(recording, speakers, summary, options.keywords),
formatFrontmatter(recording, speakers, summary, options.keywords, options.folders),
'',
`# ${expandedTitle}`,
'',

View file

@ -7,7 +7,7 @@
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"package:brat": "npm run build && node package-brat.mjs",
"lint": "eslint main.ts plaud-client.ts plaud-client-re.ts plaud-login.ts import-core.ts import-modal.ts import-runner.ts attachment-importer.ts note-writer.ts debug-logger.ts vault-index.ts __tests__/plaud-client-re.test.ts __tests__/import-modal.test.ts __tests__/import-runner.test.ts __tests__/note-writer.test.ts __tests__/attachment-importer.test.ts __tests__/debug-logger.test.ts __tests__/vault-index.test.ts --max-warnings 0 && node scripts/check-submission.mjs",
"lint": "eslint main.ts plaud-client.ts plaud-client-re.ts plaud-login.ts import-core.ts import-modal.ts import-runner.ts attachment-importer.ts note-writer.ts folder-catalog.ts debug-logger.ts vault-index.ts __tests__/plaud-client-re.test.ts __tests__/import-modal.test.ts __tests__/import-runner.test.ts __tests__/note-writer.test.ts __tests__/folder-catalog.test.ts __tests__/attachment-importer.test.ts __tests__/debug-logger.test.ts __tests__/vault-index.test.ts --max-warnings 0 && node scripts/check-submission.mjs",
"test": "jest --passWithNoTests",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},

View file

@ -13,6 +13,7 @@ import type {
Chapter,
ConsumerNote,
PlaudClient,
PlaudFolder,
PlaudRecordingId,
Recording,
RecordingFilter,
@ -140,6 +141,12 @@ export class ReverseEngineeredPlaudClient implements PlaudClient {
private baseUrl: string;
private readonly debugLogger: DebugLogger | undefined;
private readonly onBaseUrlChanged: ((newBaseUrl: string) => void) | undefined;
// Per-session cache of the flat folder/tag catalog (`GET /filetag/`). The
// catalog changes rarely, so one fetch per plugin session is enough; a
// folder renamed in Plaud after this is read shows its old name until the
// plugin reloads (a fresh client instance clears the cache). undefined means
// "not yet fetched"; an empty array is a valid cached "no folders" result.
private folderCatalogCache: readonly PlaudFolder[] | undefined;
constructor(
tokenProvider: PlaudTokenProvider,
@ -230,6 +237,28 @@ export class ReverseEngineeredPlaudClient implements PlaudClient {
return out;
}
async getFolderCatalog(): Promise<readonly PlaudFolder[]> {
if (this.folderCatalogCache !== undefined) {
return this.folderCatalogCache;
}
const endpoint = '/filetag/';
const url = `${this.baseUrl}${endpoint}`;
const raw = await this.fetchJson(url, endpoint);
const { catalog, skipped } = parseFolderCatalog(raw, endpoint);
this.folderCatalogCache = catalog;
if (this.debugLogger?.enabled === true) {
this.debugLogger.log({
kind: 'parsed',
endpoint,
message: `parsed ${catalog.length} folders${
skipped > 0 ? ` (skipped ${skipped} malformed)` : ''
}`,
payload: { count: catalog.length, skipped },
});
}
return catalog;
}
async getTranscriptAndSummary(id: PlaudRecordingId): Promise<TranscriptAndSummary> {
if (id.length === 0) {
throw new PlaudApiError(
@ -1096,6 +1125,68 @@ function parseListResponse(raw: unknown, endpoint: string): readonly RawRecordin
});
}
// Wire shape of one `GET /filetag/` catalog entry. `id`/`name` are required;
// `icon`/`color` are display metadata carried through for Phase 4 but unused by
// the import path.
interface RawFiletag {
readonly id: string;
readonly name: string;
// icon/color may be null on the wire: Plaud returns null for a folder with
// no custom styling. Accept null so such a folder is not dropped, then
// coerce it to undefined when building the clean PlaudFolder below.
readonly icon?: string | null;
readonly color?: string | null;
}
function isRawFiletag(value: unknown): value is RawFiletag {
return (
isRecord(value) &&
typeof value.id === 'string' &&
value.id.length > 0 &&
typeof value.name === 'string' &&
(value.icon === undefined || value.icon === null || typeof value.icon === 'string') &&
(value.color === undefined || value.color === null || typeof value.color === 'string')
);
}
// Parse the folder/tag catalog envelope. Unlike parseListResponse a single
// malformed entry does NOT abort the whole parse: folders are best-effort
// import enrichment, so one bad filetag must not cost every recording its
// folder resolution. Only a structurally-wrong envelope (missing/!array
// `data_filetag_list`) throws; per-item failures are skipped and counted so the
// client can debug-log the drift. `name` may be an empty string (a Plaud folder
// can be blank); the id->name map still resolves it, and the empty name is
// dropped later when it fails tag/frontmatter emission.
function parseFolderCatalog(
raw: unknown,
endpoint: string,
): { catalog: readonly PlaudFolder[]; skipped: number } {
if (!isRecord(raw)) {
throw new PlaudParseError('Response body is not an object', endpoint);
}
const list = raw.data_filetag_list;
if (!Array.isArray(list)) {
throw new PlaudParseError('Response is missing data_filetag_list array', endpoint);
}
const catalog: PlaudFolder[] = [];
let skipped = 0;
for (const item of list) {
if (!isRawFiletag(item)) {
skipped++;
continue;
}
catalog.push({
id: item.id,
name: item.name,
// null (no custom styling) collapses to undefined so PlaudFolder
// stays string | undefined.
icon: typeof item.icon === 'string' ? item.icon : undefined,
color: typeof item.color === 'string' ? item.color : undefined,
});
}
return { catalog, skipped };
}
// Plausibility bounds for a Plaud recording's start_time, which is a unix
// MILLISECONDS timestamp. Reject anything beyond 2100 or before 2000 — the
// former catches unit-confusion where something even larger than ms is

View file

@ -126,9 +126,35 @@ export interface AttachmentAsset {
readonly mimeType?: string;
}
/**
* One Plaud "filetag". In Plaud a folder and a tag are the same primitive, so
* this catalog entry doubles as both a recording's folder membership (via the
* recording's `filetag_id_list`, surfaced as `Recording.tags`) and the source
* for the `plaud-folder:` frontmatter field on import. The catalog is FLAT:
* there is no `parent_id`, so folders never nest. `icon` and `color` are Plaud
* display metadata, unused by the forward import path but modeled here so the
* future folder-mirroring work (Phase 4) can reuse this shape without a second
* fetch. Verified live 2026-06-29/2026-07-02: `GET /filetag/` returns
* `{id, name, icon, color}` per entry.
*/
export interface PlaudFolder {
readonly id: string;
readonly name: string;
readonly icon?: string;
readonly color?: string;
}
export interface PlaudClient {
listRecordings(filter?: RecordingFilter): Promise<readonly Recording[]>;
getTranscriptAndSummary(id: PlaudRecordingId): Promise<TranscriptAndSummary>;
/**
* Fetch the account's flat folder/tag catalog (`GET /filetag/`) so import
* can resolve a recording's `filetag_id_list` (opaque ids) into human folder
* NAMES. Implementations should cache per session; the catalog changes
* rarely and a re-fetch is cheap. Best-effort at the call site: a failed
* fetch must degrade to "no folders resolved", never fail an import.
*/
getFolderCatalog(): Promise<readonly PlaudFolder[]>;
/**
* Resolve the pre-signed download URL for a recording's original audio
* (Opus in an Ogg container). Returns null when Plaud exposes no audio