ckelsoe_obsidian-plaud-impo.../import-core.ts
Charles Kelsoe 3b055ff3c7 Release 0.27.0: hide-updates filter and consistent Hide-* toggle labels
Adds a 'Hide updates available' toggle to the import dialog (default off; update-available rows are actionable so shown by default). Relabels 'Show trashed' to 'Hide trashed' so all four filter toggles share one polarity (checked = hidden).
2026-07-08 18:30:14 -04:00

733 lines
28 KiB
TypeScript

// -----------------------------------------------------------------------------
// import-core.ts
//
// Pure, framework-free core of the import pipeline: shared option/result types
// and the pure helpers (error classification, formatting, tallying, paging
// merge). This module imports NO `obsidian` API and does NOT depend on
// `import-modal.ts`, so it is the acyclic base both the modal UI
// (`import-modal.ts`) and the future headless runner can build on. Keeping
// these symbols here also breaks the former `import-modal` <-> `attachment-importer`
// import cycle (attachment-importer imports `ArtifactSelection` from here).
//
// `import-modal.ts` re-exports every symbol below so existing import paths
// (main.ts, the test suite) keep resolving unchanged.
// -----------------------------------------------------------------------------
import type { PlaudRecordingId, Recording } from './plaud-client';
import type { ImportedRecord } from './vault-index';
import {
PlaudApiError,
PlaudAuthError,
PlaudParseError,
} from './plaud-client-re';
import {
NoteWriterError,
type TagMode,
type NoteWriterOptions,
type WriteOutcome,
type PlaceholderWriteOutcome,
} from './note-writer';
import type { DebugLogger } from './debug-logger';
/**
* Modal-level options passed to `ImportModal`. Extends
* `NoteWriterOptions` with concerns that belong to the post-write
* pipeline — specifically whether to auto-fold the transcript wrapping
* heading via `app.foldManager.save` after each created/overwritten
* note. `foldTranscript` is not a NoteWriter concern (the writer
* doesn't touch fold state) so it lives on the modal's options
* surface, not inside `NoteWriterOptions`.
*/
export interface ImportModalOptions extends NoteWriterOptions {
readonly foldTranscript?: boolean;
/** Which tag sources land in `tags:` frontmatter. Defaults to 'plaud'. */
readonly tagMode?: TagMode;
/** Comma-separated user tags appended in every mode except 'none'. */
readonly customTags?: string;
/**
* Write AI keywords excluded from `tags:` to a `keywords:` frontmatter
* property. Defaults to true.
*/
readonly aiKeywordsAsProperty?: boolean;
/**
* Auto-close the post-import summary view after a fully successful
* batch (no failures). Defaults to true. A batch with failures always
* keeps the summary open so the errors stay visible.
*/
readonly autoCloseSummary?: boolean;
/** Countdown length for the summary auto-close, in seconds. Defaults to 20. */
readonly autoCloseSummarySeconds?: number;
/**
* When true (the default), a recording Plaud reports it cannot transcribe
* or summarize yet (an in-band server error such as -12) gets a placeholder
* note carrying the recording ID and a Plaud link, instead of a bare
* failure. The stub is replaced automatically by a later successful import.
* Set false to keep such recordings as plain failures with no file written.
*/
readonly writePlaceholderForUnprocessed?: boolean;
/**
* Show recordings that are in Plaud's trash in the import list. Defaults to
* false (trash hidden, matching the Plaud web UI). The recordings are still
* fetched — they are filtered at the display layer — so pagination is
* unaffected.
*/
readonly showTrashedRecordings?: boolean;
/**
* Hide recordings already imported AND unchanged since import. New and
* changed (update-available) recordings always show. Defaults to true.
* A dialog view preference, toggled from the filter bar; not a settings-tab
* field. See `filterListView`.
*/
readonly hideProcessedRecordings?: boolean;
/**
* Hide imported recordings that have changed in Plaud since import. Defaults
* to false: an update is actionable work, so update-available rows show
* unless the user opts to collapse them. A dialog view preference.
*/
readonly hideUpdatesRecordings?: boolean;
/**
* Hide recordings the user has ignored (their id is in `ignoredRecordingIds`).
* Defaults to true. A dialog view preference toggled from the filter bar.
*/
readonly hideIgnoredRecordings?: boolean;
/**
* Plaud recording ids the user has permanently ignored. Ignored recordings
* are dropped from the dialog list (when `hideIgnoredRecordings`) and never
* pulled by auto-sync. Snapshot at modal-open; the modal mutates its own copy
* and persists changes via `onViewStateChange`.
*/
readonly ignoredRecordingIds?: readonly PlaudRecordingId[];
/**
* Persist a change to the dialog's view state (the three filter toggles and
* the ignore set) back to plugin settings. The modal calls this after every
* filter toggle or per-row ignore/unignore so the choice survives reopen and
* auto-sync sees the updated ignore set. Fire-and-forget from the modal's
* perspective; the host owns save-error handling.
*/
readonly onViewStateChange?: (patch: ImportViewStatePatch) => void;
readonly defaultIncludeSummary?: boolean;
readonly defaultIncludeAttachments?: boolean;
readonly defaultIncludeMindmap?: boolean;
readonly defaultIncludeCard?: boolean;
readonly defaultIncludeAudio?: boolean;
/**
* Optional token provider used for follow-up attachment fetches that
* may require authenticated Plaud API calls (for example, image paths
* nested inside attachment JSON blobs).
*/
readonly getAuthToken?: () => string | null;
/**
* Optional provider for the current Plaud API host. Threaded to the
* attachment importer so relative-asset download candidates resolve
* against the region the user was redirected to, not the hardcoded US
* host. Defaults to the US host when omitted.
*/
readonly getApiBaseUrl?: () => string;
/**
* Optional debug logger shared with the Plaud client. When provided and
* enabled, attachment import emits granular events (JSON parsing, nested
* picture-link extraction, and per-candidate fetch attempts) so users can
* copy one coherent troubleshooting log.
*/
readonly debugLogger?: DebugLogger;
/**
* Optional inline re-authentication via the email/password login window.
* Resolves true once a fresh token is captured and saved, false if the user
* closed the window or the login API is unavailable on this build. When
* supplied, the modal's error screen offers a "Sign in" CTA for the
* token-rejected / not-configured categories instead of dead-ending.
*/
readonly onReauth?: () => Promise<boolean>;
/**
* Optional Google/Apple SSO affordances mirroring the settings tab's
* bookmarklet flow. When supplied alongside onReauth, the error screen adds
* an "Other sign-in methods" expander wiring these three callbacks.
*/
readonly onReauthSso?: {
/** Open the one-time bookmarklet setup page in the system browser. */
readonly setupBookmark: () => void;
/** Launch the browser sign-in walkthrough (Google/Apple/password). */
readonly signIn: () => void;
/** Read a token from the clipboard and store it; resolves true on success. */
readonly pasteToken: () => Promise<boolean>;
};
/**
* Fired once when the import modal closes. The plugin uses it to release the
* shared import gate so a background auto-sync tick can resume; auto-sync
* ticks are suppressed for the modal's whole open lifetime.
*/
readonly onClosed?: () => void;
}
// -----------------------------------------------------------------------------
// Pure helpers (exported for unit testing). Keeping them outside the Modal
// class means the test suite can exercise classification and formatting
// without mocking the Modal base class or the DOM.
// -----------------------------------------------------------------------------
export type ErrorCategory =
| 'not-configured'
| 'token-rejected'
| 'rate-limited'
| 'server-error'
| 'parse-error'
| 'api-error'
| 'network-error'
| 'config-error'
| 'write-collision'
| 'write-failed'
| 'unknown';
export interface ErrorClassification {
readonly category: ErrorCategory;
readonly message: string;
readonly canRetry: boolean;
}
/**
* Whether an error category represents an authentication problem the user can
* resolve by re-authenticating (an expired/revoked token, or none configured).
* The import modal shows its inline "Sign in" CTA only for these categories.
* Pure and exported so it can be unit-tested without a DOM harness.
*/
export function categoryAllowsReauth(category: ErrorCategory): boolean {
return category === 'token-rejected' || category === 'not-configured';
}
const NOT_CONFIGURED_MESSAGE =
'No Plaud token configured. Open Settings → Community Plugins → Plaud Importer to paste your token, then run this command again.';
const TOKEN_REJECTED_MESSAGE =
'Plaud rejected your token. It may be expired or revoked. Open Settings → Community Plugins → Plaud Importer and re-enter it.';
export function classifyError(err: unknown): ErrorClassification {
// NoteWriterError classification must come first — it is not a
// PlaudApiError subclass, and different messages map to different
// categories (collision vs config vs vault-level write failure).
if (err instanceof NoteWriterError) {
const msg = err.message;
if (msg.toLowerCase().includes('filename collision')) {
return {
category: 'write-collision',
message: msg,
canRetry: false,
};
}
if (
msg.toLowerCase().includes('invalid ondup') ||
msg.toLowerCase().includes('escape the vault') ||
msg.toLowerCase().includes('output folder')
) {
return {
category: 'config-error',
message: `${msg}. Open Settings → Community Plugins → Plaud Importer to fix it.`,
canRetry: false,
};
}
return {
category: 'write-failed',
message: msg,
canRetry: true,
};
}
// PlaudAuthError discriminates via its `reason` field so the UI never has
// to match on message text. Both cases are non-retryable at this layer
// because Obsidian modals are blocking — the user has to close and fix
// settings before retrying makes sense.
if (err instanceof PlaudAuthError) {
if (err.reason === 'not_configured') {
return {
category: 'not-configured',
message: NOT_CONFIGURED_MESSAGE,
canRetry: false,
};
}
return {
category: 'token-rejected',
message: TOKEN_REJECTED_MESSAGE,
canRetry: false,
};
}
// PlaudParseError must come before the generic PlaudApiError branch
// because it extends PlaudApiError — ordering by specificity.
if (err instanceof PlaudParseError) {
return {
category: 'parse-error',
message: `Plaud returned data in an unexpected shape. The plugin may need an update. (${err.message})`,
canRetry: false,
};
}
if (err instanceof PlaudApiError) {
if (err.status === 429) {
return {
category: 'rate-limited',
message: 'Plaud is rate-limiting requests. Try again in a minute.',
canRetry: true,
};
}
if (err.status !== undefined && err.status >= 500) {
return {
category: 'server-error',
message: `Plaud.AI returned a server error (${err.status}). This is usually temporary — try again in a moment.`,
canRetry: true,
};
}
if (err.status !== undefined && err.status >= 400 && err.status < 500) {
// 4xx that isn't 401 (handled above) or 429 (handled above) —
// things like 403 (revoked), 404 (endpoint moved), 400 (bad
// request). These are almost always "the plugin is out of
// date" or "Plaud changed something," not "your network is
// broken," so saying "Could not reach Plaud.AI" would be a lie.
return {
category: 'api-error',
message: `Plaud.AI returned HTTP ${err.status}. The plugin may need an update — check the plugin repository for a newer version.`,
canRetry: false,
};
}
// No status. Two sub-cases distinguished by message text:
// 1. In-band error (Plaud returned a failure envelope on HTTP
// 200) → api-error, message states plainly that this is a
// Plaud-side issue, NOT the plugin failing to read the response.
// 2. Fetcher threw (DNS, TLS, offline) → network-error with a
// "could not reach" prefix.
if (err.message.includes('in-band error from')) {
// Lead with the ownership statement: an in-band error means Plaud
// returned a structured error envelope that the plugin parsed
// correctly, so the failure is on Plaud's side. -12 "start trans
// task error" specifically means Plaud has not produced a transcript
// or summary for this recording yet; other codes get the generic
// Plaud-side phrasing. Plaud's raw status/msg is kept at the end for
// bug reports.
const lead =
err.inBandStatus === -12
? 'Plaud has no transcript or summary for this recording. A common cause is audio with no detectable speech (Plaud shows "No speech detected"); it can also mean the recording is still processing.'
: 'Plaud could not complete this request and returned an error.';
return {
category: 'api-error',
message:
`${lead} This is a Plaud-side issue, not a problem with the plugin reading the data. ` +
`The plugin received and parsed Plaud's response correctly. Open the recording in the ` +
`Plaud app to check the exact reason, then re-import if content becomes available. (${err.message})`,
canRetry: true,
};
}
return {
category: 'network-error',
message: `Could not reach Plaud.AI: ${err.message}`,
canRetry: true,
};
}
// Defense in depth — should never hit this. Retrying an unknown error
// almost never helps (the cause is a code bug, not a transient failure),
// so we tell the user to report it instead of offering a retry that
// will keep producing the same error.
return {
category: 'unknown',
message: `Unexpected error in Plaud Importer. Please report this at the plugin's GitHub repository. (${
err instanceof Error ? err.message : String(err)
})`,
canRetry: false,
};
}
/**
* True when an import error is a Plaud-side, in-band failure that means Plaud
* itself has no transcript or summary for the recording yet (it returned a
* structured error envelope, which the plugin parsed correctly). This is the
* case where writing a placeholder note is the right move: the recording exists
* on Plaud but has no derivable content, so a stub with the link is more useful
* than a bare failure. Excludes token/auth failures (those need the user to fix
* the token, not a stub) and transport/parse failures (retryable or plugin
* bugs, where a placeholder would be misleading).
*/
export function isPlaudUnprocessedError(err: unknown): boolean {
return (
err instanceof PlaudApiError &&
!(err instanceof PlaudAuthError) &&
!(err instanceof PlaudParseError) &&
err.message.includes('in-band error from')
);
}
export function formatDate(d: Date): string {
const pad = (n: number): string => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(
d.getHours(),
)}:${pad(d.getMinutes())}`;
}
export function formatDuration(seconds: number): string {
// Guard against non-finite input before any arithmetic — Math.floor(NaN)
// returns NaN and downstream template interpolation produces garbage
// like "NaNh NaNm NaNs" in the UI. The parser already rejects non-finite
// durations upstream, but formatDuration is exported as a standalone
// pure helper so this is defense-in-depth for future callers.
if (!Number.isFinite(seconds)) {
return '0m 0s';
}
const safe = Math.max(0, Math.floor(seconds));
const h = Math.floor(safe / 3600);
const m = Math.floor((safe % 3600) / 60);
const s = safe % 60;
if (h > 0) {
return `${h}h ${m}m ${s}s`;
}
return `${m}m ${s}s`;
}
// -----------------------------------------------------------------------------
// Import result tallying — pure helpers, exported for unit testing.
// -----------------------------------------------------------------------------
export type ImportResult =
| {
readonly kind: 'written';
readonly recording: Recording;
readonly writeOutcome: WriteOutcome;
}
| {
readonly kind: 'failed';
readonly recording: Recording;
// Short human-readable reason for the failures list. Derived
// from the classification so the UI doesn't have to re-run it.
readonly reason: string;
// Typed classification (category + retryability) so future
// consumers can group failures by category, decide whether
// to retry, or render category-specific help text.
readonly classification: ErrorClassification;
// Preserved original value so a future logError pass can
// surface the full stack / error class / status in telemetry.
readonly cause: unknown;
}
| {
// Plaud never produced a transcript OR a summary for this
// recording (e.g. a never-processed raw clip). There is
// genuinely nothing to write, so this is a benign skip, not a
// failure: surfaced separately from duplicate-skips and from
// the -12 error path so the user sees an honest "no content"
// reason instead of a spurious error.
readonly kind: 'skipped-no-content';
readonly recording: Recording;
}
| {
// Plaud confirmed (via an in-band server error such as -12) that it
// has no transcript or summary for this recording yet, but the
// recording exists. Instead of a bare failure, the importer wrote a
// placeholder note carrying the recording ID and a link back to
// Plaud, which a later successful import replaces automatically.
readonly kind: 'placeholder-written';
readonly recording: Recording;
readonly outcome: PlaceholderWriteOutcome;
// The classified Plaud-side reason, surfaced in the summary and the
// copy-details payload so the user knows why it was a placeholder.
readonly reason: string;
readonly classification: ErrorClassification;
};
export interface ImportTally {
readonly total: number;
readonly created: number;
readonly overwritten: number;
// Notes skipped because a same-path/same-id note already existed and
// the duplicate policy was 'skip'. Distinct from noContent.
readonly skipped: number;
// Recordings skipped because Plaud has no transcript and no summary
// for them — nothing to import, reported as a benign skip.
readonly noContent: number;
readonly failed: number;
readonly failures: readonly ImportResult[];
// The 'skipped-no-content' results, kept for the summary view's
// informational (non-error) list.
readonly noContentResults: readonly ImportResult[];
// Recordings Plaud has not processed yet, for which a placeholder note was
// written (created or refreshed). Counts only actual stubs, not the
// 'kept-existing' case (which is reported as a skip via a 'written' result).
readonly placeholdersWritten: number;
readonly placeholderResults: readonly ImportResult[];
}
export function tallyImportResults(results: readonly ImportResult[]): ImportTally {
let created = 0;
let overwritten = 0;
let skipped = 0;
let noContent = 0;
let failed = 0;
let placeholdersWritten = 0;
const failures: ImportResult[] = [];
const noContentResults: ImportResult[] = [];
const placeholderResults: ImportResult[] = [];
for (const r of results) {
if (r.kind === 'failed') {
failed++;
failures.push(r);
continue;
}
if (r.kind === 'skipped-no-content') {
noContent++;
noContentResults.push(r);
continue;
}
if (r.kind === 'placeholder-written') {
placeholdersWritten++;
placeholderResults.push(r);
continue;
}
switch (r.writeOutcome.status) {
case 'created':
created++;
break;
case 'overwritten':
overwritten++;
break;
case 'skipped':
skipped++;
break;
}
}
return {
total: results.length,
created,
overwritten,
skipped,
noContent,
failed,
failures,
noContentResults,
placeholdersWritten,
placeholderResults,
};
}
/**
* Build the one-line Notice text shown after an import batch completes.
* The Notice is the only feedback the user sees outside the modal itself,
* so it needs to report counts compactly.
*/
export function formatImportNotice(tally: ImportTally): string {
if (tally.total === 0) {
return 'Plaud importer: nothing to import.';
}
const imported = tally.created + tally.overwritten;
const parts: string[] = [];
parts.push(`${imported} imported`);
if (tally.skipped > 0) {
parts.push(`${tally.skipped} skipped`);
}
if (tally.noContent > 0) {
parts.push(`${tally.noContent} no content`);
}
if (tally.placeholdersWritten > 0) {
parts.push(`${tally.placeholdersWritten} placeholder`);
}
if (tally.failed > 0) {
parts.push(`${tally.failed} failed`);
}
return `Plaud importer: ${parts.join(', ')}.`;
}
/**
* Format an error classification as a structured block suitable for
* pasting into a bug report. Exported so the renderError path and any
* future "copy failure details" button in the summary view can share
* the same format.
*/
export function formatErrorForClipboard(classification: ErrorClassification): string {
return [
'Plaud Importer error',
`Category: ${classification.category}`,
`Retryable: ${classification.canRetry}`,
'Message:',
classification.message,
].join('\n');
}
// -----------------------------------------------------------------------------
// Progressive paging helpers
// -----------------------------------------------------------------------------
/**
* Number of recordings fetched per "Load more" click. Kept small so the
* first render is cheap and the user can stop scrolling early if they
* already see the meeting they want. Tune by feel — there is no hard cap
* from Plaud's side.
*/
export const PAGE_SIZE = 10;
/**
* Merge a freshly-fetched page into the accumulator that backs the modal
* list, and decide whether more pages probably exist.
*
* Background:
* - Plaud's `/file/simple/web` endpoint supports offset pagination via the
* `skip` and `limit` query params, but it does NOT report the total
* count of recordings, nor does it return an explicit "end of stream"
* marker. So we can only *infer* "no more recordings" from the batch
* size that came back.
* - Recordings can be uploaded to Plaud while the modal is open. That
* means a naive `[...existing, ...incoming]` can produce duplicates: if
* a new recording is uploaded between page 1 and page 2, Plaud's newest-
* first sort will shift page 2 down by one, and the first row of the new
* page will be a repeat of the last row of the previous page.
*
* Strategy:
* - Deduplicate by recording ID so mid-session uploads that shift Plaud's
* offset windows do not produce duplicate rows in the modal.
* - Preserve first-seen ordering by appending only unseen incoming rows.
* - Infer "has more" from page fullness (`incoming.length >= pageSize`).
* This avoids an extra empty trailing fetch on normal pagination.
*/
export function mergeRecordings(
existing: readonly Recording[],
incoming: readonly Recording[],
pageSize: number,
): { readonly merged: readonly Recording[]; readonly hasMore: boolean } {
const merged: Recording[] = [...existing];
const seen = new Set(existing.map((r) => r.id));
for (const recording of incoming) {
if (seen.has(recording.id)) {
continue;
}
seen.add(recording.id);
merged.push(recording);
}
const hasMore = incoming.length >= pageSize;
return { merged, hasMore };
}
/**
* Filter the recordings the modal should DISPLAY. Trashed recordings are kept
* in the modal's accumulator (so pagination math stays correct — the server
* page size and skip offset must not change) but hidden from the list unless
* the user opts in. Pure and exported so the view logic is unit-testable
* without a DOM.
*/
export function filterVisibleRecordings(
recordings: readonly Recording[],
showTrashed: boolean,
): readonly Recording[] {
if (showTrashed) {
return recordings;
}
return recordings.filter((r) => !r.isTrashed);
}
/**
* True when an already-imported recording has changed in Plaud since import:
* both the listed and the stored `version_ms` are known and the listed one is
* greater. Used by the import dialog's "update available" badge (a manual-
* import cue), by `filterListView` (a changed recording always shows even when
* "hide already-processed" is on), and mirrored by the auto-sync `changed`
* classification. A missing stored marker (legacy note) is NOT flagged,
* matching the migration rule.
*
* Lives here (the acyclic pure base) rather than in auto-sync.ts because the
* list-view filter needs it and auto-sync.ts already depends on this module;
* importing it the other way would create a cycle. `auto-sync.ts` re-exports it
* so its existing import paths keep resolving.
*/
export function isUpdateAvailable(
listedVersionMs: number | undefined,
storedVersionMs: number | undefined,
): boolean {
return (
listedVersionMs !== undefined &&
storedVersionMs !== undefined &&
listedVersionMs > storedVersionMs
);
}
/**
* The import dialog's persisted view state: the three filter-bar toggles plus
* the ignore set. Snapshotted into the modal at open; changes are pushed back
* to plugin settings via `ImportModalOptions.onViewStateChange`.
*/
export interface ImportViewState {
readonly showTrashedRecordings: boolean;
readonly hideProcessedRecordings: boolean;
readonly hideUpdatesRecordings: boolean;
readonly hideIgnoredRecordings: boolean;
readonly ignoredRecordingIds: readonly PlaudRecordingId[];
}
/** A partial view-state update carried by `onViewStateChange`. */
export type ImportViewStatePatch = Partial<ImportViewState>;
export interface ListViewFilter {
/** Show recordings in Plaud's trash. */
readonly showTrashed: boolean;
/** Hide imported recordings that are unchanged since import. */
readonly hideProcessed: boolean;
/**
* Hide imported recordings that have CHANGED in Plaud since import (an
* update is available). Separate from `hideProcessed` because an update is
* actionable work (re-importing overwrites the note with Plaud's newer
* version), so it shows by default; this toggle is the opt-in to collapse
* those too.
*/
readonly hideUpdates: boolean;
/** Hide recordings whose id is in `ignoredIds`. */
readonly hideIgnored: boolean;
/** Vault index (plaud-id -> imported note) used to decide "processed". */
readonly index: ReadonlyMap<PlaudRecordingId, ImportedRecord>;
/** Ignored recording ids. */
readonly ignoredIds: ReadonlySet<PlaudRecordingId>;
}
/**
* Filter the recordings the dialog should DISPLAY, applying the filter-bar
* toggles. Distinct from `filterVisibleRecordings` (which is shared with
* auto-sync and only ever drops trash): this is the richer view-only filter and
* must not be used by the import/auto-sync write paths.
*
* Order and rules:
* 1. Drop trash unless `showTrashed`.
* 2. Drop ignored ids when `hideIgnored`.
* 3. For an imported recording (present in the index): if it has changed in
* Plaud (`isUpdateAvailable`) it is governed by `hideUpdates`; otherwise it
* is governed by `hideProcessed`. A new recording (not in the index) always
* shows, so importable work is never hidden.
*
* Pure and exported so the view logic is unit-testable without a DOM.
*/
export function filterListView(
recordings: readonly Recording[],
filter: ListViewFilter,
): readonly Recording[] {
return recordings.filter((r) => {
if (r.isTrashed && !filter.showTrashed) {
return false;
}
if (filter.hideIgnored && filter.ignoredIds.has(r.id)) {
return false;
}
const existing = filter.index.get(r.id);
if (existing !== undefined) {
// Imported. Split by whether Plaud has a newer version than the note.
const updated = isUpdateAvailable(r.versionMs, existing.versionMs);
if (updated ? filter.hideUpdates : filter.hideProcessed) {
return false;
}
}
return true;
});
}
export interface ArtifactSelection {
readonly includeSummary: boolean;
readonly includeTranscript: boolean;
readonly includeAttachments: boolean;
readonly includeMindmap: boolean;
readonly includeCard: boolean;
readonly includeAudio: boolean;
}
/**
* Five-button per-duplicate prompt used when the user has chosen "Ask
* each time" duplicate handling. The last three buttons (overwrite-all,
* skip-all, cancel) escalate the decision: the caller uses them to
* short-circuit subsequent duplicates in the same batch.
*/
export type DuplicateDecisionChoice =
| 'overwrite'
| 'skip'
| 'overwrite-all'
| 'skip-all'
| 'cancel';