mirror of
https://github.com/ckelsoe/obsidian-plaud-importer.git
synced 2026-07-22 07:49:02 +00:00
* 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:
parent
f8eac7f336
commit
cc8caa1c88
13 changed files with 624 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
122
__tests__/folder-catalog.test.ts
Normal file
122
__tests__/folder-catalog.test.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
93
folder-catalog.ts
Normal 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, '');
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
main.ts
1
main.ts
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
'',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue