mirror of
https://github.com/ckelsoe/obsidian-plaud-importer.git
synced 2026-07-22 07:49:02 +00:00
The long-lived user token (0.31.0) made the 24h silent-refresh machinery dead weight: it could never fire against the stored token, and running it would only mint a 24h WT and reinstate daily expiry. Delete it end to end: plaud-refresh.ts, plaud-refresh-net.ts, their tests, the proactive timer, retry backoff, reactive refresh, the 'Refresh session now' command, and the keepSessionAlive setting (a stale key in existing data.json is scrubbed at load). Pause + Reconnect and both sign-in flows stay; they are the roughly yearly re-auth path. The rebuild spec lives in dev-docs/plaud-importer/2026-07-17-auth-mechanisms-reference.md (workspace). Reconnect routing (SSO vs email) used to infer the account type from the stored WRT that this release deletes. Capture now records how the session was obtained (settings.signInMethod: the email window sets 'window', storeAccessToken sets 'browser' for paste and deep-link) and Reconnect routes off that; pre-0.32.0 sessions with no recorded method fall back to peeking at the legacy WRT secret, which only email-window sign-ins ever stored. Manually linking a different secret in settings clears the recorded method so it cannot misdescribe an unknown credential. The decision is a pure function (reconnect-routing.ts) with unit tests, including the legacy fallback and reader-failure cases. Verified by a four-agent adversarial pass (orphans, spec compliance, behavior walks, prose drift) plus Codex review; all findings addressed. 998 tests green; strict unused-symbol tsc sweep clean.
2664 lines
95 KiB
TypeScript
2664 lines
95 KiB
TypeScript
import {
|
|
App,
|
|
MarkdownView,
|
|
Modal,
|
|
Notice,
|
|
setIcon,
|
|
TFile,
|
|
} from 'obsidian';
|
|
import type {
|
|
PlaudRecordingId,
|
|
PlaudClient,
|
|
Recording,
|
|
TranscriptAndSummary,
|
|
} from './plaud-client';
|
|
import {
|
|
NoteWriter,
|
|
NoteWriterError,
|
|
type DuplicatePolicy,
|
|
type DuplicatePromptCallback,
|
|
findTranscriptHeadingLine,
|
|
} from './note-writer';
|
|
import { runImport } from './import-runner';
|
|
import { buildPlaudIdIndex, type ImportedRecord } from './vault-index';
|
|
import { AttachmentImporter } from './attachment-importer';
|
|
import {
|
|
classifyError,
|
|
categoryAllowsReauth,
|
|
isPlaudUnprocessedError,
|
|
formatDate,
|
|
formatDuration,
|
|
tallyImportResults,
|
|
formatImportNotice,
|
|
formatErrorForClipboard,
|
|
mergeRecordings,
|
|
filterVisibleRecordings,
|
|
filterListView,
|
|
isUpdateAvailable,
|
|
PAGE_SIZE,
|
|
type ImportModalOptions,
|
|
type ImportViewStatePatch,
|
|
type ListViewFilter,
|
|
type ErrorCategory,
|
|
type ErrorClassification,
|
|
type ImportResult,
|
|
type ImportTally,
|
|
type ArtifactSelection,
|
|
type DuplicateDecisionChoice,
|
|
} from './import-core';
|
|
|
|
// The pure helpers and shared types now live in import-core.ts (no `obsidian`
|
|
// dependency, which breaks the former import-modal <-> attachment-importer
|
|
// import cycle). Re-export them here so existing import paths (main.ts, the
|
|
// test suite) keep resolving from import-modal.ts unchanged.
|
|
export {
|
|
classifyError,
|
|
categoryAllowsReauth,
|
|
isPlaudUnprocessedError,
|
|
formatDate,
|
|
formatDuration,
|
|
tallyImportResults,
|
|
formatImportNotice,
|
|
formatErrorForClipboard,
|
|
mergeRecordings,
|
|
filterVisibleRecordings,
|
|
PAGE_SIZE,
|
|
};
|
|
export type {
|
|
ImportModalOptions,
|
|
ErrorCategory,
|
|
ErrorClassification,
|
|
ImportResult,
|
|
ImportTally,
|
|
ArtifactSelection,
|
|
DuplicateDecisionChoice,
|
|
};
|
|
|
|
// Button and label text kept as module consts. The obsidianmd sentence-case
|
|
// lint only inspects string literals written directly at a createEl/setText
|
|
// call, so reading a proper-noun-bearing label from a const (Google / Apple)
|
|
// satisfies the rule while keeping the visible text accurate.
|
|
const IMPORT_BUTTON_LABEL = 'Import selected (defaults)';
|
|
const SIGN_IN_LABEL = 'Sign in';
|
|
const OTHER_SIGNIN_LABEL = 'Other sign-in methods (Google / Apple)';
|
|
const SETUP_BOOKMARK_LABEL = 'Set up bookmark';
|
|
const BROWSER_SIGNIN_LABEL = 'Sign in via browser';
|
|
const PASTE_TOKEN_LABEL = 'Paste token from clipboard';
|
|
|
|
/**
|
|
* Copy text to the clipboard and show a brief Notice confirming success.
|
|
* Falls back gracefully on platforms where the clipboard API is blocked.
|
|
*/
|
|
async function copyToClipboard(text: string): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
new Notice('Plaud importer: error details copied to clipboard.');
|
|
} catch (err) {
|
|
console.error('Plaud importer: clipboard write failed', err);
|
|
new Notice(
|
|
'Plaud importer: could not copy to clipboard — see the developer console (Ctrl+Shift+I) for the full error.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Progressive paging helpers
|
|
// -----------------------------------------------------------------------------
|
|
|
|
const AUTO_LOAD_ROOT_MARGIN_PX = 240;
|
|
const AUTO_LOAD_THROTTLE_MS = 300;
|
|
const AUTO_LOAD_SILENT_RETRY_DELAY_MS = 350;
|
|
|
|
type LoadMoreTrigger = 'auto' | 'manual' | 'retry' | 'auto-advance';
|
|
|
|
interface ArtifactAvailability {
|
|
readonly selectedCount: number;
|
|
readonly summaryCount: number;
|
|
readonly transcriptCount: number;
|
|
readonly attachmentsCount: number;
|
|
readonly mindmapCount: number;
|
|
readonly cardCount: number;
|
|
// Unlike the other artifacts, audio is not discovered from the preflight
|
|
// bundle: Plaud serves original audio for every recording, and the
|
|
// temp-url is resolved lazily at import time (and null-handled if absent).
|
|
// So this equals the selected count rather than a per-bundle probe.
|
|
readonly audioCount: number;
|
|
}
|
|
|
|
class ArtifactSelectionModal extends Modal {
|
|
private readonly availability: ArtifactAvailability;
|
|
private selection: ArtifactSelection;
|
|
private readonly onDone: (selection: ArtifactSelection | null) => void;
|
|
private resolved = false;
|
|
|
|
constructor(
|
|
app: App,
|
|
availability: ArtifactAvailability,
|
|
initialSelection: ArtifactSelection,
|
|
onDone: (selection: ArtifactSelection | null) => void,
|
|
) {
|
|
super(app);
|
|
this.availability = availability;
|
|
this.selection = { ...initialSelection };
|
|
this.onDone = onDone;
|
|
}
|
|
|
|
onOpen(): void {
|
|
this.setTitle('Choose artifacts to import');
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl('p', {
|
|
text: `Availability across ${this.availability.selectedCount} selected recording${
|
|
this.availability.selectedCount === 1 ? '' : 's'
|
|
}:`,
|
|
cls: 'plaud-importer-intro',
|
|
});
|
|
contentEl.createEl('p', {
|
|
text: 'Transcript includes chapters automatically when available.',
|
|
cls: 'plaud-importer-intro',
|
|
});
|
|
this.renderOption(contentEl, 'Summary', 'includeSummary', this.availability.summaryCount);
|
|
this.renderOption(contentEl, 'Transcript', 'includeTranscript', this.availability.transcriptCount);
|
|
this.renderOption(contentEl, 'Mindmap', 'includeMindmap', this.availability.mindmapCount);
|
|
this.renderOption(contentEl, 'Card', 'includeCard', this.availability.cardCount);
|
|
this.renderOption(
|
|
contentEl,
|
|
'Other attachments',
|
|
'includeAttachments',
|
|
this.availability.attachmentsCount,
|
|
);
|
|
this.renderOption(contentEl, 'Audio (large)', 'includeAudio', this.availability.audioCount);
|
|
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
const importButton = buttonRow.createEl('button', {
|
|
text: 'Import selected',
|
|
cls: 'mod-cta',
|
|
});
|
|
importButton.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone({ ...this.selection });
|
|
this.close();
|
|
});
|
|
const cancelButton = buttonRow.createEl('button', { text: 'Cancel' });
|
|
cancelButton.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone(null);
|
|
this.close();
|
|
});
|
|
}
|
|
|
|
onClose(): void {
|
|
this.contentEl.empty();
|
|
if (!this.resolved) {
|
|
this.onDone(null);
|
|
}
|
|
}
|
|
|
|
private renderOption(
|
|
contentEl: HTMLElement,
|
|
label: string,
|
|
key: keyof ArtifactSelection,
|
|
availableCount: number,
|
|
): void {
|
|
const row = contentEl.createDiv({ cls: 'plaud-importer-row' });
|
|
const checkbox = row.createEl('input', {
|
|
type: 'checkbox',
|
|
cls: 'plaud-importer-checkbox',
|
|
});
|
|
checkbox.checked = this.selection[key];
|
|
checkbox.disabled = availableCount === 0;
|
|
const labelWrap = row.createDiv({ cls: 'plaud-importer-label' });
|
|
const detail =
|
|
this.availability.selectedCount <= 1
|
|
? availableCount > 0
|
|
? 'Available'
|
|
: 'Not available'
|
|
: `${availableCount} of ${this.availability.selectedCount} available`;
|
|
labelWrap.createDiv({
|
|
text: `${label}: ${detail}`,
|
|
cls: 'plaud-importer-title',
|
|
});
|
|
checkbox.addEventListener('change', () => {
|
|
this.selection = {
|
|
...this.selection,
|
|
[key]: checkbox.checked,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
class OverwriteConfirmationModal extends Modal {
|
|
private readonly selectedCount: number;
|
|
private readonly onDone: (choice: 'overwrite' | 'skip' | 'cancel') => void;
|
|
private resolved = false;
|
|
|
|
constructor(
|
|
app: App,
|
|
selectedCount: number,
|
|
onDone: (choice: 'overwrite' | 'skip' | 'cancel') => void,
|
|
) {
|
|
super(app);
|
|
this.selectedCount = selectedCount;
|
|
this.onDone = onDone;
|
|
}
|
|
|
|
onOpen(): void {
|
|
this.setTitle('Overwrite existing notes and assets?');
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl('p', {
|
|
text: `You are importing ${this.selectedCount} recording${
|
|
this.selectedCount === 1 ? '' : 's'
|
|
} with duplicate handling set to overwrite.`,
|
|
});
|
|
contentEl.createEl('p', {
|
|
text: 'If a note already exists, the note content and its imported files/images in the matching -assets folder will be replaced. Any manual edits to that note or those imported attachments can be lost.',
|
|
});
|
|
contentEl.createEl('p', {
|
|
text: 'Choose how to continue:',
|
|
});
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
const overwriteButton = buttonRow.createEl('button', {
|
|
text: 'Continue with overwrite',
|
|
cls: 'mod-warning',
|
|
});
|
|
overwriteButton.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone('overwrite');
|
|
this.close();
|
|
});
|
|
const skipButton = buttonRow.createEl('button', {
|
|
text: 'Do not overwrite existing notes',
|
|
cls: 'mod-cta',
|
|
});
|
|
skipButton.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone('skip');
|
|
this.close();
|
|
});
|
|
const cancelButton = buttonRow.createEl('button', { text: 'Cancel import' });
|
|
cancelButton.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone('cancel');
|
|
this.close();
|
|
});
|
|
}
|
|
|
|
onClose(): void {
|
|
this.contentEl.empty();
|
|
if (!this.resolved) {
|
|
this.onDone('cancel');
|
|
}
|
|
}
|
|
}
|
|
|
|
class DuplicateDecisionModal extends Modal {
|
|
private readonly recordingTitle: string;
|
|
private readonly targetPath: string;
|
|
private readonly showBatchOptions: boolean;
|
|
private readonly onDone: (choice: DuplicateDecisionChoice) => void;
|
|
private resolved = false;
|
|
|
|
constructor(
|
|
app: App,
|
|
recordingTitle: string,
|
|
targetPath: string,
|
|
showBatchOptions: boolean,
|
|
onDone: (choice: DuplicateDecisionChoice) => void,
|
|
) {
|
|
super(app);
|
|
this.recordingTitle = recordingTitle;
|
|
this.targetPath = targetPath;
|
|
this.showBatchOptions = showBatchOptions;
|
|
this.onDone = onDone;
|
|
}
|
|
|
|
onOpen(): void {
|
|
this.setTitle('Existing note found — overwrite?');
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
contentEl.createEl('p', {
|
|
text: `A note for "${this.recordingTitle}" already exists at:`,
|
|
});
|
|
contentEl.createEl('p', { text: this.targetPath, cls: 'plaud-importer-mono' });
|
|
const warning = contentEl.createEl('p', { cls: 'mod-warning' });
|
|
warning.createEl('strong', { text: 'Warning: ' });
|
|
warning.appendText(
|
|
'Continuing with overwrite will replace the existing note content and clear its matching -assets folder. Any manual edits to that note or its imported attachments will be lost.',
|
|
);
|
|
contentEl.createEl('p', { text: 'Choose how to handle this note:' });
|
|
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
const addButton = (text: string, cls: string, choice: DuplicateDecisionChoice): void => {
|
|
const btn = buttonRow.createEl('button', { text, cls });
|
|
btn.addEventListener('click', () => {
|
|
this.resolved = true;
|
|
this.onDone(choice);
|
|
this.close();
|
|
});
|
|
};
|
|
addButton('Overwrite', 'mod-warning', 'overwrite');
|
|
addButton('Skip', 'mod-cta', 'skip');
|
|
// "All remaining" and "Cancel import" only make sense when more
|
|
// than one duplicate could still arrive in this batch. For a
|
|
// single-item import Skip and Cancel are functionally identical
|
|
// (both leave the existing note untouched), so rendering both
|
|
// is user-hostile — hide the escalation set entirely.
|
|
if (this.showBatchOptions) {
|
|
addButton('Overwrite all remaining', 'mod-warning', 'overwrite-all');
|
|
addButton('Skip all remaining', '', 'skip-all');
|
|
addButton('Cancel import', '', 'cancel');
|
|
}
|
|
}
|
|
|
|
onClose(): void {
|
|
this.contentEl.empty();
|
|
if (!this.resolved) {
|
|
this.onDone('cancel');
|
|
}
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Modal
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export class ImportModal extends Modal {
|
|
private readonly client: PlaudClient;
|
|
private readonly noteWriterOptions: ImportModalOptions;
|
|
// Owns the attachment download/classify/persist pipeline. Created once
|
|
// per modal with the same auth and logging surface the modal received.
|
|
private readonly attachments: AttachmentImporter;
|
|
private readonly selectedIds = new Set<string>();
|
|
private importButton: HTMLButtonElement | null = null;
|
|
private reviewArtifactsButton: HTMLButtonElement | null = null;
|
|
// Mutable accumulator across Load More clicks. Starts empty on each
|
|
// refresh() (first open or Retry), then grows as loadMore() appends
|
|
// new pages via mergeRecordings().
|
|
private currentRecordings: Recording[] = [];
|
|
// Live reference to the list container DOM node so loadMore() can
|
|
// append new rows incrementally without re-rendering the whole list
|
|
// (which would flash, reset scroll position, and throw away the
|
|
// checkbox DOM state for rows the user is still looking at).
|
|
private listEl: HTMLElement | null = null;
|
|
// Tail status area inside the scrollable list. Shows loading/error/end
|
|
// hints and a manual action button when auto-loading is paused or fails.
|
|
// Footer container that sits BELOW the scroll list (a sibling of it),
|
|
// holding the load-more status/spinner so they stay pinned in view
|
|
// instead of scrolling away with the rows.
|
|
private progressFooterEl: HTMLElement | null = null;
|
|
private progressEl: HTMLElement | null = null;
|
|
private progressTextEl: HTMLElement | null = null;
|
|
private progressSpinnerEl: HTMLElement | null = null;
|
|
private progressActionButton: HTMLButtonElement | null = null;
|
|
// Message shown inside the list when the filters hide every loaded row, so an
|
|
// empty list explains itself (scanning / capped / everything imported) rather
|
|
// than reading as a broken blank panel.
|
|
private emptyStateEl: HTMLElement | null = null;
|
|
// Invisible sentinel watched by IntersectionObserver. When it enters view
|
|
// (near list bottom), we auto-fetch another page.
|
|
private autoLoadSentinelEl: HTMLElement | null = null;
|
|
private autoLoadObserver: IntersectionObserver | null = null;
|
|
private scrollFallbackHandler: ((event: Event) => void) | null = null;
|
|
// Intro line ("N recordings available…") — updated in place after
|
|
// each successful loadMore() so the count stays accurate.
|
|
private introEl: HTMLElement | null = null;
|
|
// Whether Plaud probably has more recordings beyond what we've fetched.
|
|
// Set from mergeRecordings() on every page. When false, the Load More
|
|
// button is removed.
|
|
private hasMore = false;
|
|
// Auto-advance: when the filters hide every loaded row, page forward on our
|
|
// own so the user reaches importable (un-imported) recordings without having
|
|
// to scroll through invisible rows. Needed because auto-sync imports the
|
|
// newest recordings, so with "Hide already-processed" on the first pages are
|
|
// usually all hidden and the scroll-driven auto-loader has nothing to scroll.
|
|
// Bounded per burst so a large imported history does not trigger an unbounded
|
|
// fetch storm; the footer then offers a manual "Load more".
|
|
private static readonly MAX_AUTO_ADVANCE_PAGES = 10;
|
|
private autoAdvancing = false;
|
|
// Set true once a burst hits the page cap with still nothing visible, so the
|
|
// footer shows a manual continue instead of looping. Reset when a fetch
|
|
// surfaces a visible row or a filter toggle reveals hidden rows.
|
|
private autoAdvanceCapped = false;
|
|
// Guard against re-entry from rapid Load More clicks — the button is
|
|
// also disabled while this is true, but this belt-and-suspenders guard
|
|
// also prevents an in-flight fetch from being duplicated by keyboard
|
|
// activation.
|
|
private loadingMore = false;
|
|
private loadMoreErrorMessage: string | null = null;
|
|
private lastAutoLoadAt = 0;
|
|
// Last non-throttle reason maybeAutoLoad refused to page, used to
|
|
// deduplicate the scroll debug log (scroll fires many events per second).
|
|
private lastAutoLoadBlockReason: string | null = null;
|
|
// First-scroll gate: we do not auto-load on initial modal open. Once the
|
|
// user starts interacting with the list, auto-loading and background
|
|
// prefetch are enabled.
|
|
private userStartedScrolling = false;
|
|
// Background next-page cache populated after first scroll so the next
|
|
// bottom-reach can use already-fetched rows.
|
|
private prefetchedRecordings: readonly Recording[] | null = null;
|
|
private prefetchInFlight = false;
|
|
private listInteractionHandler: (() => void) | null = null;
|
|
private preparingCustomization = false;
|
|
private readonly artifactCache = new Map<string, TranscriptAndSummary>();
|
|
// Monotonic counter that increments on every refresh() call. Each
|
|
// in-flight fetch captures the current value and bails before rendering
|
|
// if it has changed — prevents the "click Retry while slow fetch is
|
|
// still running" race from overwriting newer state with stale results.
|
|
// loadMore() reads this value without incrementing so a fresh refresh()
|
|
// during a Load More fetch invalidates the in-flight page.
|
|
private fetchGeneration = 0;
|
|
// Set by onClose so a running import loop can detect cancellation and
|
|
// stop writing to the vault without continuing through the rest of the
|
|
// selected recordings. Checked between iterations in onImportClick.
|
|
private aborted = false;
|
|
// The in-flight import loop, if one is running. onClose sets `aborted` (which
|
|
// only stops the loop BETWEEN recordings, so the current write can still be
|
|
// finishing) and then defers the shared-gate release (onClosed) until this
|
|
// settles, so a background auto-sync tick cannot start mid-write and defeat
|
|
// the single-flight guarantee. Null when no import is running.
|
|
private activeImportRun: Promise<void> | null = null;
|
|
// Interval id for the summary auto-close countdown. Held on the modal
|
|
// so onClose can clear it whether the countdown finished, the user
|
|
// clicked Done, or they dismissed the modal some other way.
|
|
private autoCloseTimer: number | null = null;
|
|
// Sticky choice for the "Ask each time" duplicate policy. Set when
|
|
// the user picks "Overwrite all remaining" or "Skip all remaining"
|
|
// from the per-file prompt, so subsequent duplicates in the same
|
|
// batch resolve without re-prompting. Reset to null at the start of
|
|
// every onImportClick invocation so decisions do not leak between
|
|
// import runs.
|
|
private stickyDuplicateDecision: 'overwrite' | 'skip' | null = null;
|
|
// Number of recordings in the current import batch. Consulted by the
|
|
// per-file duplicate prompt to decide whether to render the
|
|
// "all remaining" escalation buttons — they are hidden for
|
|
// single-item imports where the option is meaningless.
|
|
private currentBatchSize = 0;
|
|
// Index of plaud-ids already present in the configured output folder.
|
|
// Built once per refresh() and refreshed after each successful import
|
|
// so the badge state stays accurate without re-scanning the vault on
|
|
// every render. Empty map when no notes match — never null so callers
|
|
// can `.has()` without guarding.
|
|
private importedIndex: Map<PlaudRecordingId, ImportedRecord> = new Map();
|
|
|
|
// Dialog view state: the three filter-bar toggles and the ignore set.
|
|
// Snapshotted from the options at open, mutated locally when the user
|
|
// toggles a filter or ignores/unignores a row, and persisted back to plugin
|
|
// settings via onViewStateChange so the choice survives reopen and auto-sync
|
|
// sees the updated ignore set. The Set mirrors the persisted array for O(1)
|
|
// per-row membership checks.
|
|
private showTrashed: boolean;
|
|
private hideProcessed: boolean;
|
|
private hideUpdates: boolean;
|
|
private hideIgnored: boolean;
|
|
private readonly ignoredIds: Set<PlaudRecordingId>;
|
|
|
|
constructor(app: App, client: PlaudClient, noteWriterOptions: ImportModalOptions) {
|
|
super(app);
|
|
this.client = client;
|
|
this.noteWriterOptions = noteWriterOptions;
|
|
this.showTrashed = noteWriterOptions.showTrashedRecordings === true;
|
|
// The two hide-prefs default ON when the host omits them (a fresh install
|
|
// or a pre-0.26.0 data.json), matching DEFAULT_SETTINGS.
|
|
this.hideProcessed = noteWriterOptions.hideProcessedRecordings !== false;
|
|
// Updates default to SHOWN (hide off): an update is actionable work.
|
|
this.hideUpdates = noteWriterOptions.hideUpdatesRecordings === true;
|
|
this.hideIgnored = noteWriterOptions.hideIgnoredRecordings !== false;
|
|
this.ignoredIds = new Set(noteWriterOptions.ignoredRecordingIds ?? []);
|
|
this.attachments = new AttachmentImporter({
|
|
app,
|
|
getAuthToken: noteWriterOptions.getAuthToken,
|
|
getApiBaseUrl: noteWriterOptions.getApiBaseUrl,
|
|
debugLogger: noteWriterOptions.debugLogger,
|
|
});
|
|
}
|
|
|
|
onOpen(): void {
|
|
this.modalEl.addClass('plaud-importer-modal');
|
|
this.setTitle('Import plaud recordings');
|
|
this.refresh().catch((err) => {
|
|
// refresh() has its own try/catch around the fetch and always
|
|
// calls renderError internally. This outer catch is purely
|
|
// defense-in-depth against a future bug that throws outside the
|
|
// fetch try/catch (e.g., a render function throwing).
|
|
console.error('Plaud importer: unexpected error in onOpen/refresh', err);
|
|
this.renderError(classifyError(err));
|
|
});
|
|
}
|
|
|
|
onClose(): void {
|
|
// Signal any in-flight import loop to stop writing. `aborted` is only
|
|
// checked BETWEEN recordings, so the current write can still be finishing
|
|
// after this returns; the gate release is therefore deferred until the
|
|
// loop actually settles (below), so a background auto-sync tick cannot
|
|
// start mid-write. `aborted = true` is a plain assignment that cannot
|
|
// throw. The loop fires a partial Notice if it was interrupted.
|
|
this.aborted = true;
|
|
// Invalidate any in-flight page fetch: loadMore captures fetchGeneration
|
|
// and bails (before mutating currentRecordings/hasMore or rendering) when
|
|
// it changes, so a close during an awaited fetch cannot repopulate a
|
|
// torn-down modal or let the auto-advance loop keep paging.
|
|
this.fetchGeneration += 1;
|
|
// Release the shared gate (onClosed) only after any in-flight import loop
|
|
// settles; if none is running, release now. onClosed is an injected
|
|
// callback, so a throw from it must not escape teardown.
|
|
const releaseGate = (): void => {
|
|
try {
|
|
this.noteWriterOptions.onClosed?.();
|
|
} catch (err) {
|
|
console.error(
|
|
'Plaud importer: onClosed callback threw during teardown',
|
|
err,
|
|
);
|
|
}
|
|
};
|
|
const run = this.activeImportRun;
|
|
if (run) {
|
|
void run.then(releaseGate, releaseGate);
|
|
} else {
|
|
releaseGate();
|
|
}
|
|
this.cancelAutoClose();
|
|
this.contentEl.empty();
|
|
this.selectedIds.clear();
|
|
this.teardownAutoLoadObserver();
|
|
this.importButton = null;
|
|
this.reviewArtifactsButton = null;
|
|
this.currentRecordings = [];
|
|
this.listEl = null;
|
|
this.progressFooterEl = null;
|
|
this.progressEl = null;
|
|
this.progressTextEl = null;
|
|
this.progressSpinnerEl = null;
|
|
this.progressActionButton = null;
|
|
this.autoLoadSentinelEl = null;
|
|
this.introEl = null;
|
|
this.hasMore = false;
|
|
this.loadingMore = false;
|
|
this.loadMoreErrorMessage = null;
|
|
this.lastAutoLoadAt = 0;
|
|
this.userStartedScrolling = false;
|
|
this.prefetchedRecordings = null;
|
|
this.prefetchInFlight = false;
|
|
this.listInteractionHandler = null;
|
|
this.preparingCustomization = false;
|
|
this.artifactCache.clear();
|
|
}
|
|
|
|
private async refresh(): Promise<void> {
|
|
// Full reset on every refresh — this covers both the initial open
|
|
// and the error-state Retry click. Any pending Load More from a
|
|
// previous render is invalidated via the generation bump below.
|
|
this.selectedIds.clear();
|
|
this.teardownAutoLoadObserver();
|
|
this.importButton = null;
|
|
this.reviewArtifactsButton = null;
|
|
this.listEl = null;
|
|
this.progressFooterEl = null;
|
|
this.progressEl = null;
|
|
this.progressTextEl = null;
|
|
this.progressSpinnerEl = null;
|
|
this.progressActionButton = null;
|
|
this.autoLoadSentinelEl = null;
|
|
this.introEl = null;
|
|
this.currentRecordings = [];
|
|
this.hasMore = false;
|
|
this.loadingMore = false;
|
|
this.loadMoreErrorMessage = null;
|
|
this.lastAutoLoadAt = 0;
|
|
this.userStartedScrolling = false;
|
|
this.prefetchedRecordings = null;
|
|
this.prefetchInFlight = false;
|
|
this.listInteractionHandler = null;
|
|
this.preparingCustomization = false;
|
|
this.artifactCache.clear();
|
|
// Snapshot which recordings already have a note in the vault so
|
|
// every row rendered below can render the "imported" badge.
|
|
// Refreshed again after each successful write so re-entering the
|
|
// modal isn't required to see new badges.
|
|
this.refreshImportedIndex();
|
|
const generation = ++this.fetchGeneration;
|
|
this.renderLoading();
|
|
try {
|
|
const recordings = await this.client.listRecordings({
|
|
skip: 0,
|
|
limit: PAGE_SIZE,
|
|
});
|
|
if (generation !== this.fetchGeneration) {
|
|
// A newer refresh() started while we were waiting. Drop the
|
|
// stale result on the floor.
|
|
return;
|
|
}
|
|
const { merged, hasMore } = mergeRecordings([], recordings, PAGE_SIZE);
|
|
this.currentRecordings = [...merged];
|
|
this.hasMore = hasMore;
|
|
if (this.currentRecordings.length === 0) {
|
|
this.renderEmpty();
|
|
} else {
|
|
this.renderList();
|
|
}
|
|
} catch (err) {
|
|
if (generation !== this.fetchGeneration) {
|
|
return;
|
|
}
|
|
console.error('Plaud importer: listRecordings failed', err);
|
|
this.renderError(classifyError(err));
|
|
}
|
|
}
|
|
|
|
private async loadMore(trigger: LoadMoreTrigger): Promise<void> {
|
|
// Re-entry guard: fast double-clicks or keyboard activations can
|
|
// fire the click handler twice before the button is visually
|
|
// disabled. The flag is the source of truth; the disabled state
|
|
// is just visual feedback.
|
|
if (this.loadingMore || !this.hasMore) {
|
|
return;
|
|
}
|
|
if (trigger === 'auto' && this.prefetchInFlight && this.prefetchedRecordings === null) {
|
|
return;
|
|
}
|
|
if (trigger === 'auto' && this.loadMoreErrorMessage !== null) {
|
|
return;
|
|
}
|
|
this.loadingMore = true;
|
|
this.loadMoreErrorMessage = null;
|
|
const generation = this.fetchGeneration;
|
|
const skip = this.currentRecordings.length;
|
|
this.updateProgressUi();
|
|
|
|
try {
|
|
let incoming: readonly Recording[];
|
|
const fromPrefetch = this.prefetchedRecordings !== null;
|
|
this.logScrollDebug('loadMore start', {
|
|
trigger,
|
|
skip,
|
|
source: fromPrefetch ? 'prefetch-cache' : 'live-fetch',
|
|
});
|
|
if (this.prefetchedRecordings !== null) {
|
|
incoming = this.prefetchedRecordings;
|
|
this.prefetchedRecordings = null;
|
|
} else {
|
|
incoming = await this.fetchPageWithSilentRetry(skip, trigger);
|
|
}
|
|
if (generation !== this.fetchGeneration) {
|
|
// A refresh() fired while we were waiting — the list has
|
|
// been torn down. Drop the stale page.
|
|
return;
|
|
}
|
|
const { merged, hasMore } = mergeRecordings(
|
|
this.currentRecordings,
|
|
incoming,
|
|
PAGE_SIZE,
|
|
);
|
|
// Figure out which rows are actually new so we can append only
|
|
// those instead of re-rendering the whole list. Uses ID equality
|
|
// against the pre-merge accumulator — this is correct regardless
|
|
// of how mergeRecordings handles dedupe, because we're comparing
|
|
// the post-merge list to what was on screen before.
|
|
const existingIds = new Set(this.currentRecordings.map((r) => r.id));
|
|
const newRows = merged.filter((r) => !existingIds.has(r.id));
|
|
this.currentRecordings = [...merged];
|
|
this.hasMore = hasMore;
|
|
|
|
if (this.listEl !== null) {
|
|
// Render only the newly-arrived rows that pass the current filter
|
|
// bar; everything stays in currentRecordings (for paging) but the
|
|
// filtered-out rows are not shown. Same predicate as the full
|
|
// render via filterListView, so incremental append and a full
|
|
// re-render agree on visibility.
|
|
const visibleNew = filterListView(newRows, this.listViewFilter());
|
|
for (const rec of visibleNew) {
|
|
this.renderRow(this.listEl, rec);
|
|
}
|
|
}
|
|
this.updateIntroCount();
|
|
this.updateProgressUi();
|
|
this.logScrollDebug('loadMore done', {
|
|
trigger,
|
|
skip,
|
|
newRows: newRows.length,
|
|
received: incoming.length,
|
|
totalLoaded: this.currentRecordings.length,
|
|
hasMore: this.hasMore,
|
|
});
|
|
this.startPrefetchIfNeeded();
|
|
} catch (err) {
|
|
if (generation !== this.fetchGeneration) {
|
|
return;
|
|
}
|
|
console.error('Plaud importer: loadMore failed', err);
|
|
// Show a Notice rather than tearing down the list — the user
|
|
// still has their selections and their already-loaded pages,
|
|
// and losing them on a transient network blip would be rude.
|
|
const classification = classifyError(err);
|
|
new Notice(`Plaud importer: could not load more — ${classification.message}`);
|
|
this.loadMoreErrorMessage = classification.message;
|
|
this.logScrollDebug('loadMore error', {
|
|
trigger,
|
|
skip,
|
|
error: classification.message,
|
|
category: classification.category,
|
|
});
|
|
this.updateProgressUi();
|
|
} finally {
|
|
this.loadingMore = false;
|
|
this.updateProgressUi();
|
|
}
|
|
}
|
|
|
|
// Page forward while the filters hide every loaded row, so an empty view is
|
|
// only ever the true "nothing to import" state, not "the newest N are all
|
|
// imported and the un-imported ones are deeper than page 1". Fire-and-forget
|
|
// from render/loadMore; the loop awaits each page so it never overlaps a
|
|
// fetch, and the re-entry guard keeps a filter toggle mid-burst from stacking
|
|
// a second loop. Stops at the first visible row, exhaustion, an error, or the
|
|
// page cap (after which the footer offers a manual "Load more").
|
|
private async pageUntilVisibleOrCapped(): Promise<void> {
|
|
if (this.autoAdvancing) {
|
|
return;
|
|
}
|
|
this.autoAdvancing = true;
|
|
// Capture the fetch generation so a refresh()/onClose during an awaited
|
|
// page fetch stops the burst instead of paging a torn-down or closed
|
|
// modal (onClose sets `aborted`; refresh() bumps fetchGeneration).
|
|
const generation = this.fetchGeneration;
|
|
try {
|
|
let pages = 0;
|
|
while (
|
|
!this.aborted &&
|
|
generation === this.fetchGeneration &&
|
|
this.visibleRecordings().length === 0 &&
|
|
this.hasMore &&
|
|
this.loadMoreErrorMessage === null &&
|
|
pages < ImportModal.MAX_AUTO_ADVANCE_PAGES
|
|
) {
|
|
const before = this.currentRecordings.length;
|
|
await this.loadMore('auto-advance');
|
|
// Stop if the modal was closed or refreshed while we awaited.
|
|
if (this.aborted || generation !== this.fetchGeneration) {
|
|
return;
|
|
}
|
|
// Count only real progress. loadMore is a no-op if another load is
|
|
// already in flight (concurrent scroll/prefetch) or the page was
|
|
// entirely duplicates; incrementing pages on a no-op would burn the
|
|
// cap without fetching. Break instead — the manual "Load more"
|
|
// button (shown in the capped/empty state) lets the user resume.
|
|
if (this.currentRecordings.length === before) {
|
|
break;
|
|
}
|
|
pages += 1;
|
|
}
|
|
// Capped only if we stopped with rows still hidden and more to fetch.
|
|
this.autoAdvanceCapped =
|
|
this.visibleRecordings().length === 0 &&
|
|
this.hasMore &&
|
|
this.loadMoreErrorMessage === null;
|
|
} finally {
|
|
this.autoAdvancing = false;
|
|
if (!this.aborted && generation === this.fetchGeneration) {
|
|
this.updateIntroCount();
|
|
this.updateProgressUi();
|
|
}
|
|
}
|
|
}
|
|
|
|
private renderLoading(): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
box.createEl('p', {
|
|
text: 'Loading recordings from plaud.ai…',
|
|
cls: 'plaud-importer-loading',
|
|
});
|
|
}
|
|
|
|
private renderEmpty(): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
box.createEl('p', {
|
|
text: 'No recordings found in your Plaud.AI account.',
|
|
cls: 'plaud-importer-empty',
|
|
});
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
const closeButton = buttonRow.createEl('button', { text: 'Close' });
|
|
closeButton.addEventListener('click', () => this.close());
|
|
}
|
|
|
|
private renderError(classification: ErrorClassification): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
box.createEl('p', {
|
|
text: classification.message,
|
|
cls: 'plaud-importer-error-message',
|
|
});
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
|
|
// An expired/revoked token or one never configured is otherwise a dead
|
|
// end here: those categories are canRetry:false, so without inline
|
|
// re-auth the user has to leave for Settings. When the host wired the
|
|
// re-auth callbacks, offer a "Sign in" CTA (and the SSO expander below)
|
|
// instead. The CTA leads so it reads as the primary next action.
|
|
const options = this.noteWriterOptions;
|
|
const reauthAvailable = this.appendSignInCta(
|
|
buttonRow,
|
|
classification.category,
|
|
() => this.refresh(),
|
|
);
|
|
|
|
const copyButton = buttonRow.createEl('button', { text: 'Copy error' });
|
|
copyButton.addEventListener('click', () => {
|
|
const payload = formatErrorForClipboard(classification);
|
|
void copyToClipboard(payload);
|
|
});
|
|
|
|
if (classification.canRetry) {
|
|
const retryButton = buttonRow.createEl('button', {
|
|
text: 'Retry',
|
|
cls: 'mod-cta',
|
|
});
|
|
retryButton.addEventListener('click', () => {
|
|
this.refresh().catch((err) => {
|
|
console.error('Plaud importer: retry failed', err);
|
|
this.renderError(classifyError(err));
|
|
});
|
|
});
|
|
}
|
|
const closeButton = buttonRow.createEl('button', { text: 'Close' });
|
|
closeButton.addEventListener('click', () => this.close());
|
|
|
|
if (reauthAvailable && options.onReauthSso) {
|
|
this.renderReauthSsoExpander(contentEl, options.onReauthSso, () =>
|
|
this.refresh(),
|
|
);
|
|
}
|
|
}
|
|
|
|
// Adds the leading "Sign in" CTA to an error/interrupt button row when the
|
|
// host wired re-auth and the category is auth-related. onSuccess runs after a
|
|
// token is captured (the error screen reloads the list; the auth-interrupted
|
|
// screen advances to the resume prompt). Returns whether the CTA was added,
|
|
// so the caller can gate the SSO expander on the same condition.
|
|
private appendSignInCta(
|
|
buttonRow: HTMLElement,
|
|
category: ErrorCategory,
|
|
onSuccess: () => Promise<void>,
|
|
): boolean {
|
|
const reauthAvailable =
|
|
categoryAllowsReauth(category) &&
|
|
this.noteWriterOptions.onReauth !== undefined;
|
|
if (!reauthAvailable) {
|
|
return false;
|
|
}
|
|
const signInButton = buttonRow.createEl('button', {
|
|
text: SIGN_IN_LABEL,
|
|
cls: 'mod-cta',
|
|
});
|
|
signInButton.addEventListener('click', () => {
|
|
void this.handleReauth(signInButton, onSuccess);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Builds the collapsed "Other sign-in methods" disclosure used for Google /
|
|
// Apple SSO. A native <details>/<summary> needs no JS or CSS to toggle, so
|
|
// it adds no new styles; the inner button row reuses the existing class. The
|
|
// three buttons delegate to the host-provided callbacks (which reuse the
|
|
// settings tab's bookmarklet flow), keeping the modal free of Electron and
|
|
// clipboard plumbing.
|
|
private renderReauthSsoExpander(
|
|
parent: HTMLElement,
|
|
sso: NonNullable<ImportModalOptions['onReauthSso']>,
|
|
onSuccess: () => Promise<void>,
|
|
): void {
|
|
const details = parent.createEl('details');
|
|
details.createEl('summary', { text: OTHER_SIGNIN_LABEL });
|
|
const row = details.createDiv({ cls: 'plaud-importer-buttons' });
|
|
|
|
const setupButton = row.createEl('button', { text: SETUP_BOOKMARK_LABEL });
|
|
setupButton.addEventListener('click', () => sso.setupBookmark());
|
|
|
|
const browserButton = row.createEl('button', {
|
|
text: BROWSER_SIGNIN_LABEL,
|
|
});
|
|
browserButton.addEventListener('click', () => sso.signIn());
|
|
|
|
const pasteButton = row.createEl('button', { text: PASTE_TOKEN_LABEL });
|
|
pasteButton.addEventListener('click', () => {
|
|
void this.handleSsoPaste(onSuccess);
|
|
});
|
|
}
|
|
|
|
// Runs the inline email/password re-auth from an error/interrupt screen. On
|
|
// success the caller-supplied onSuccess runs: the error screen reloads the
|
|
// list, the auth-interrupted screen advances to the resume prompt. Token
|
|
// reads are live closures, so no client reconstruction is needed. On a
|
|
// closed window or an unavailable login API, a Notice makes clear nothing
|
|
// changed.
|
|
private async handleReauth(
|
|
button: HTMLButtonElement,
|
|
onSuccess: () => Promise<void>,
|
|
): Promise<void> {
|
|
const onReauth = this.noteWriterOptions.onReauth;
|
|
if (!onReauth) {
|
|
return;
|
|
}
|
|
button.disabled = true;
|
|
try {
|
|
const ok = await onReauth();
|
|
if (ok) {
|
|
await onSuccess();
|
|
} else {
|
|
new Notice('Plaud sign-in closed — no token captured.');
|
|
}
|
|
} catch (err) {
|
|
console.error('Plaud importer: inline re-auth failed', err);
|
|
this.renderError(classifyError(err));
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Stores a clipboard token from the SSO expander and runs onSuccess on
|
|
// success (the error screen reloads the list; the auth-interrupted screen
|
|
// advances to the resume prompt). The failure Notices for an invalid/missing
|
|
// token live in the host's pasteToken callback, so a false result is already
|
|
// explained. A thrown error (a saveSettings or refresh failure) is routed
|
|
// through renderError like the inline re-auth and retry paths, since the
|
|
// click handler fires this with `void` and would otherwise drop the rejection.
|
|
private async handleSsoPaste(onSuccess: () => Promise<void>): Promise<void> {
|
|
const sso = this.noteWriterOptions.onReauthSso;
|
|
if (!sso) {
|
|
return;
|
|
}
|
|
try {
|
|
const ok = await sso.pasteToken();
|
|
if (ok) {
|
|
await onSuccess();
|
|
}
|
|
} catch (err) {
|
|
console.error('Plaud importer: SSO token paste failed', err);
|
|
this.renderError(classifyError(err));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Recordings to display: `currentRecordings` (the full paged accumulator)
|
|
* with the filter-bar toggles applied. Everything stays in
|
|
* `currentRecordings` for pagination; this is the filtered view over it.
|
|
*/
|
|
private visibleRecordings(): readonly Recording[] {
|
|
return filterListView(this.currentRecordings, this.listViewFilter());
|
|
}
|
|
|
|
// Remove from selectedIds any recording not in the visible set, so the
|
|
// selection stays a subset of what is shown. Called on every (re)render:
|
|
// the import batch and the import-button enabled state are both derived from
|
|
// selectedIds, so a hidden recording must be deselected to stay unimportable.
|
|
private reconcileSelectionToVisible(visible: readonly Recording[]): void {
|
|
if (this.selectedIds.size === 0) {
|
|
return;
|
|
}
|
|
const visibleIds = new Set<string>(visible.map((r) => r.id));
|
|
for (const id of [...this.selectedIds]) {
|
|
if (!visibleIds.has(id)) {
|
|
this.selectedIds.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Current filter-bar state as the pure `filterListView` argument. */
|
|
private listViewFilter(): ListViewFilter {
|
|
return {
|
|
showTrashed: this.showTrashed,
|
|
hideProcessed: this.hideProcessed,
|
|
hideUpdates: this.hideUpdates,
|
|
hideIgnored: this.hideIgnored,
|
|
index: this.importedIndex,
|
|
ignoredIds: this.ignoredIds,
|
|
};
|
|
}
|
|
|
|
private renderList(): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
this.introEl = contentEl.createEl('p', {
|
|
cls: 'plaud-importer-intro',
|
|
});
|
|
this.updateIntroCount();
|
|
|
|
this.renderFilterBar(contentEl);
|
|
|
|
const listEl = contentEl.createDiv({ cls: 'plaud-importer-list' });
|
|
this.listEl = listEl;
|
|
const visible = this.visibleRecordings();
|
|
// Drop selections for rows the current filters hide. A recording hidden
|
|
// by a filter (processed/ignored/trashed) or by the ignore toggle must
|
|
// not stay importable: onImportClick and the customization flow build
|
|
// their batch from selectedIds, so a stale selection would import a
|
|
// recording the user can no longer see.
|
|
this.reconcileSelectionToVisible(visible);
|
|
this.emptyStateEl = listEl.createDiv({
|
|
cls: 'plaud-importer-empty-state plaud-importer-hidden',
|
|
});
|
|
for (const rec of visible) {
|
|
this.renderRow(listEl, rec);
|
|
}
|
|
// Footer sits BELOW the scroll list (a sibling, not a child) so the
|
|
// load-more status and spinner stay pinned in view rather than
|
|
// scrolling out of sight with the rows.
|
|
this.progressFooterEl = contentEl.createDiv({
|
|
cls: 'plaud-importer-list-footer',
|
|
});
|
|
this.ensureProgressElements();
|
|
this.updateProgressUi();
|
|
this.setupAutoLoadObserver();
|
|
// Page past fully-hidden pages so the list is not left stuck empty when
|
|
// the newest recordings are all imported (the common case with auto-sync
|
|
// on). No-op when a row is already visible or nothing more can be fetched.
|
|
void this.pageUntilVisibleOrCapped();
|
|
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
this.importButton = buttonRow.createEl('button', {
|
|
text: IMPORT_BUTTON_LABEL,
|
|
cls: 'mod-cta',
|
|
});
|
|
this.importButton.disabled = true;
|
|
this.importButton.addEventListener('click', () => {
|
|
const selection = this.getDefaultArtifactSelection();
|
|
this.onImportClick(selection).catch((err) => {
|
|
// onImportClick has internal error handling around every
|
|
// write and the writer construction — this outer catch is
|
|
// defense-in-depth against a future bug that throws outside
|
|
// those try/catch blocks.
|
|
console.error('Plaud importer: unexpected error in onImportClick', err);
|
|
this.renderError(classifyError(err));
|
|
});
|
|
});
|
|
const customizeButton = buttonRow.createEl('button', {
|
|
text: 'Review artifacts first',
|
|
});
|
|
this.reviewArtifactsButton = customizeButton;
|
|
customizeButton.disabled = this.selectedIds.size === 0 || this.preparingCustomization;
|
|
customizeButton.addEventListener('click', () => {
|
|
this.beginCustomizationFlow().catch((err) => {
|
|
console.error('Plaud importer: customization preflight failed', err);
|
|
new Notice('Plaud importer: could not inspect artifacts.');
|
|
});
|
|
});
|
|
|
|
const cancelButton = buttonRow.createEl('button', { text: 'Cancel' });
|
|
cancelButton.addEventListener('click', () => this.close());
|
|
this.updateImportButtonState();
|
|
}
|
|
|
|
// Filter bar above the list: a compact "Hide:" group of four view toggles on
|
|
// one line. Each writes its setting through onViewStateChange (so it survives
|
|
// reopen and auto-sync sees the same ignore/show state) and re-renders the
|
|
// list in place.
|
|
private renderFilterBar(parent: HTMLElement): void {
|
|
// One "Hide:" group, short labels, full meaning in the tooltip so it fits
|
|
// a single line without wrapping. A checked box always means "hidden"
|
|
// (consistent polarity): "Trashed" is the inverse of the underlying
|
|
// showTrashedRecordings setting.
|
|
const bar = parent.createDiv({ cls: 'plaud-importer-filter-bar' });
|
|
bar.createSpan({ cls: 'plaud-importer-filter-label', text: 'Hide:' });
|
|
this.renderFilterToggle(
|
|
bar,
|
|
'Processed',
|
|
'Hide recordings already imported and unchanged',
|
|
this.hideProcessed,
|
|
(checked) => {
|
|
this.hideProcessed = checked;
|
|
this.persistViewState({ hideProcessedRecordings: checked });
|
|
},
|
|
);
|
|
this.renderFilterToggle(
|
|
bar,
|
|
'Updates',
|
|
'Hide imported recordings that changed in Plaud since import',
|
|
this.hideUpdates,
|
|
(checked) => {
|
|
this.hideUpdates = checked;
|
|
this.persistViewState({ hideUpdatesRecordings: checked });
|
|
},
|
|
);
|
|
this.renderFilterToggle(
|
|
bar,
|
|
'Ignored',
|
|
'Hide recordings you have ignored',
|
|
this.hideIgnored,
|
|
(checked) => {
|
|
this.hideIgnored = checked;
|
|
this.persistViewState({ hideIgnoredRecordings: checked });
|
|
},
|
|
);
|
|
this.renderFilterToggle(
|
|
bar,
|
|
'Trashed',
|
|
"Hide recordings in Plaud's trash",
|
|
!this.showTrashed,
|
|
(checked) => {
|
|
this.showTrashed = !checked;
|
|
this.persistViewState({ showTrashedRecordings: !checked });
|
|
},
|
|
);
|
|
}
|
|
|
|
private renderFilterToggle(
|
|
bar: HTMLElement,
|
|
labelText: string,
|
|
tooltip: string,
|
|
checked: boolean,
|
|
onChange: (checked: boolean) => void,
|
|
): void {
|
|
const label = bar.createEl('label', {
|
|
cls: 'plaud-importer-filter-toggle',
|
|
attr: { title: tooltip },
|
|
});
|
|
const box = label.createEl('input', {
|
|
type: 'checkbox',
|
|
attr: { 'aria-label': tooltip },
|
|
});
|
|
box.checked = checked;
|
|
label.createSpan({ text: labelText });
|
|
box.addEventListener('change', () => {
|
|
onChange(box.checked);
|
|
// Re-render the list against the new filter. currentRecordings is
|
|
// retained, so this re-filters without refetching.
|
|
this.renderList();
|
|
});
|
|
}
|
|
|
|
// Persist a view-state change back to plugin settings. Fire-and-forget: the
|
|
// host owns save-error handling and the in-memory state is already updated,
|
|
// so a failed save only means the choice does not survive a reopen.
|
|
private persistViewState(patch: ImportViewStatePatch): void {
|
|
this.noteWriterOptions.onViewStateChange?.(patch);
|
|
}
|
|
|
|
// Toggle a recording's ignore state from its row button. Immutable array
|
|
// update (never mutate the shared settings default), persist, then re-render
|
|
// so the row drops out when "Hide ignored" is on. Works on never-imported
|
|
// rows too: the ignore set is plugin state keyed by plaud-id, independent of
|
|
// whether a note exists.
|
|
private toggleIgnore(recId: PlaudRecordingId): void {
|
|
if (this.ignoredIds.has(recId)) {
|
|
this.ignoredIds.delete(recId);
|
|
} else {
|
|
this.ignoredIds.add(recId);
|
|
}
|
|
this.persistViewState({ ignoredRecordingIds: [...this.ignoredIds] });
|
|
this.renderList();
|
|
}
|
|
|
|
private renderRow(listEl: HTMLElement, rec: Recording): void {
|
|
// Keep newly appended rows above the auto-load sentinel so the
|
|
// sentinel stays the last child of the scroll list — its
|
|
// intersection with the bottom is what drives paging.
|
|
const row = listEl.createDiv({ cls: 'plaud-importer-row' });
|
|
// Stamp the recording id so updateRowBadge can find this row by id
|
|
// rather than by positional index. Index-matching breaks once trashed
|
|
// recordings live in currentRecordings but are not rendered.
|
|
row.dataset.recordingId = rec.id;
|
|
if (
|
|
this.autoLoadSentinelEl !== null &&
|
|
this.autoLoadSentinelEl.parentElement === listEl
|
|
) {
|
|
listEl.insertBefore(row, this.autoLoadSentinelEl);
|
|
}
|
|
|
|
const checkbox = row.createEl('input', {
|
|
type: 'checkbox',
|
|
cls: 'plaud-importer-checkbox',
|
|
});
|
|
// Initialize from selectedIds so a mid-stream re-render (or a
|
|
// future incremental render that reuses rows) reflects the
|
|
// user's current selection instead of always starting unchecked.
|
|
checkbox.checked = this.selectedIds.has(rec.id);
|
|
checkbox.addEventListener('change', () => {
|
|
if (checkbox.checked) {
|
|
this.selectedIds.add(rec.id);
|
|
} else {
|
|
this.selectedIds.delete(rec.id);
|
|
}
|
|
this.updateImportButtonState();
|
|
});
|
|
|
|
const labelWrap = row.createDiv({ cls: 'plaud-importer-label' });
|
|
const titleRow = labelWrap.createDiv({ cls: 'plaud-importer-title-row' });
|
|
titleRow.createDiv({ text: rec.title, cls: 'plaud-importer-title' });
|
|
|
|
const existing = this.importedIndex.get(rec.id);
|
|
if (existing !== undefined) {
|
|
this.renderImportedBadge(titleRow, existing);
|
|
// Manual-import cue: this note is stale (the recording changed in
|
|
// Plaud since it was imported). Re-importing it overwrites the note
|
|
// with Plaud's current version.
|
|
if (isUpdateAvailable(rec.versionMs, existing.versionMs)) {
|
|
this.renderUpdateAvailableBadge(titleRow);
|
|
}
|
|
}
|
|
// Mark trashed rows (only ever visible when "Hide trashed" is off) so they
|
|
// are visually distinct from live recordings instead of looking identical.
|
|
if (rec.isTrashed) {
|
|
this.renderTrashedBadge(titleRow);
|
|
}
|
|
|
|
const meta = labelWrap.createDiv({ cls: 'plaud-importer-meta' });
|
|
meta.createSpan({ text: formatDate(rec.createdAt) });
|
|
meta.createSpan({ text: ' · ', cls: 'plaud-importer-sep' });
|
|
meta.createSpan({ text: formatDuration(rec.durationSeconds) });
|
|
|
|
// Per-row ignore control. A direct child of the row (not the label) so it
|
|
// sits at the row's trailing edge. Toggling re-renders the list, so the
|
|
// icon state is set once here from the current ignore set.
|
|
const ignoreButton = row.createEl('button', {
|
|
cls: 'plaud-importer-ignore-button',
|
|
});
|
|
this.decorateIgnoreButton(ignoreButton, rec.id);
|
|
ignoreButton.addEventListener('click', (evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
this.toggleIgnore(rec.id);
|
|
});
|
|
}
|
|
|
|
// Set an ignore button's icon and labels from the recording's current ignore
|
|
// state. `eye-off` means "click to ignore"; `eye` means "click to unignore"
|
|
// (shown on an already-ignored row when "Hide ignored" is off). Both are real
|
|
// Lucide ids so scripts/check-icons.mjs passes.
|
|
private decorateIgnoreButton(button: HTMLElement, recId: PlaudRecordingId): void {
|
|
const ignored = this.ignoredIds.has(recId);
|
|
setIcon(button, ignored ? 'eye' : 'eye-off');
|
|
const label = ignored ? 'Unignore this recording' : 'Ignore this recording';
|
|
button.setAttribute('aria-label', label);
|
|
button.setAttribute('title', label);
|
|
}
|
|
|
|
// Refresh the vault index from scratch. Cheap — Obsidian's
|
|
// metadataCache returns parsed frontmatter without re-reading file
|
|
// bytes. Called at modal open (inside refresh()) and after every
|
|
// successful write so badge state stays accurate without forcing
|
|
// the user to close and reopen the modal.
|
|
private refreshImportedIndex(): void {
|
|
this.importedIndex = buildPlaudIdIndex(
|
|
this.app,
|
|
this.noteWriterOptions.outputFolder,
|
|
);
|
|
}
|
|
|
|
// Update or insert a single row's badge state after a successful
|
|
// import. Cheaper than a full re-render for the common "user
|
|
// imports one recording then imports another" path. The existing
|
|
// row stays where it is; we just append a badge to its title row.
|
|
private updateRowBadge(recId: PlaudRecordingId): void {
|
|
this.refreshImportedIndex();
|
|
if (this.listEl === null) return;
|
|
const existing = this.importedIndex.get(recId);
|
|
if (existing === undefined) return;
|
|
// Find the row by its stamped recording id. Robust to trashed
|
|
// recordings that sit in currentRecordings but are not rendered, which
|
|
// would otherwise desync a positional index from the DOM row order.
|
|
const row = this.listEl.querySelector(
|
|
`.plaud-importer-row[data-recording-id="${CSS.escape(recId)}"]`,
|
|
);
|
|
if (row === null) return;
|
|
const titleRow = row.querySelector('.plaud-importer-title-row');
|
|
if (titleRow === null) return;
|
|
// The note was just written, so it is current: drop any stale
|
|
// "update available" badge.
|
|
titleRow.querySelector('.plaud-importer-update-badge')?.remove();
|
|
const existingBadge = titleRow.querySelector('.plaud-importer-imported-badge');
|
|
if (existingBadge !== null) return;
|
|
this.renderImportedBadge(titleRow as HTMLElement, existing);
|
|
}
|
|
|
|
private renderImportedBadge(parent: HTMLElement, record: ImportedRecord): void {
|
|
const badge = parent.createEl('a', {
|
|
cls: 'plaud-importer-imported-badge',
|
|
text: 'Imported',
|
|
href: '#',
|
|
attr: {
|
|
'aria-label': `Open existing note at ${record.path}`,
|
|
title: `Already imported — click to open ${record.path}`,
|
|
},
|
|
});
|
|
badge.addEventListener('click', (evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
void this.app.workspace.openLinkText(record.path, '', false);
|
|
});
|
|
}
|
|
|
|
// A non-interactive "Update available" badge shown next to "Imported" when
|
|
// the recording changed in Plaud since import. Re-importing overwrites the
|
|
// note with Plaud's current version.
|
|
private renderUpdateAvailableBadge(parent: HTMLElement): void {
|
|
if (parent.querySelector('.plaud-importer-update-badge') !== null) return;
|
|
parent.createSpan({
|
|
cls: 'plaud-importer-update-badge',
|
|
text: 'Update available',
|
|
attr: {
|
|
'aria-label': 'This recording changed in Plaud since it was imported',
|
|
title: 'Changed in Plaud since import — re-importing overwrites this note',
|
|
},
|
|
});
|
|
}
|
|
|
|
// A "Trashed" badge on a recording that is in Plaud's trash. Only rendered
|
|
// when the row is visible at all, which is only when "Hide trashed" is off.
|
|
private renderTrashedBadge(parent: HTMLElement): void {
|
|
if (parent.querySelector('.plaud-importer-trashed-badge') !== null) return;
|
|
parent.createSpan({
|
|
cls: 'plaud-importer-trashed-badge',
|
|
text: 'Trashed',
|
|
attr: {
|
|
'aria-label': "This recording is in Plaud's trash",
|
|
title: "In Plaud's trash — showing because \"Hide trashed\" is off",
|
|
},
|
|
});
|
|
}
|
|
|
|
private updateImportButtonState(): void {
|
|
if (this.importButton) {
|
|
this.importButton.disabled = this.selectedIds.size === 0 || this.preparingCustomization;
|
|
}
|
|
if (this.reviewArtifactsButton) {
|
|
this.reviewArtifactsButton.disabled =
|
|
this.selectedIds.size === 0 || this.preparingCustomization;
|
|
}
|
|
}
|
|
|
|
private getDefaultArtifactSelection(): ArtifactSelection {
|
|
return {
|
|
includeSummary: this.noteWriterOptions.defaultIncludeSummary !== false,
|
|
includeTranscript: this.noteWriterOptions.includeTranscript !== false,
|
|
includeAttachments: this.noteWriterOptions.defaultIncludeAttachments !== false,
|
|
includeMindmap: this.noteWriterOptions.defaultIncludeMindmap !== false,
|
|
includeCard: this.noteWriterOptions.defaultIncludeCard !== false,
|
|
// Opt-in: audio defaults OFF unless the user turned the setting on,
|
|
// so use === true rather than the "on unless false" idiom above.
|
|
includeAudio: this.noteWriterOptions.defaultIncludeAudio === true,
|
|
};
|
|
}
|
|
|
|
private async beginCustomizationFlow(): Promise<void> {
|
|
// "Review artifacts first" stays clickable during a default import (its
|
|
// disabled state ignores the in-flight run), so guard here too: bail
|
|
// before the artifact preflight so a second import can never start.
|
|
if (this.activeImportRun !== null) {
|
|
new Notice('Plaud importer: an import is already running.');
|
|
return;
|
|
}
|
|
if (this.selectedIds.size === 0 || this.preparingCustomization) {
|
|
return;
|
|
}
|
|
this.preparingCustomization = true;
|
|
this.updateImportButtonState();
|
|
new Notice('Plaud importer: checking available artifacts...');
|
|
try {
|
|
const selected = this.currentRecordings.filter((r) => this.selectedIds.has(r.id));
|
|
for (const recording of selected) {
|
|
await this.ensureArtifactsForRecording(recording.id);
|
|
}
|
|
const availability = this.computeArtifactAvailability(selected);
|
|
const defaults = this.getDefaultArtifactSelection();
|
|
const initialSelection: ArtifactSelection = {
|
|
includeSummary: defaults.includeSummary && availability.summaryCount > 0,
|
|
includeTranscript: defaults.includeTranscript && availability.transcriptCount > 0,
|
|
includeAttachments:
|
|
defaults.includeAttachments && availability.attachmentsCount > 0,
|
|
includeMindmap: defaults.includeMindmap && availability.mindmapCount > 0,
|
|
includeCard: defaults.includeCard && availability.cardCount > 0,
|
|
includeAudio: defaults.includeAudio && availability.audioCount > 0,
|
|
};
|
|
const selection = await this.promptArtifactSelection(availability, initialSelection);
|
|
if (selection === null) {
|
|
return;
|
|
}
|
|
await this.onImportClick(selection);
|
|
} catch (err) {
|
|
const classification = classifyError(err);
|
|
new Notice(`Plaud importer: could not inspect artifacts — ${classification.message}`);
|
|
} finally {
|
|
this.preparingCustomization = false;
|
|
this.updateImportButtonState();
|
|
}
|
|
}
|
|
|
|
private async ensureArtifactsForRecording(
|
|
recordingId: PlaudRecordingId,
|
|
): Promise<TranscriptAndSummary> {
|
|
const cached = this.artifactCache.get(recordingId);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
const bundle = await this.client.getTranscriptAndSummary(recordingId);
|
|
this.artifactCache.set(recordingId, bundle);
|
|
return bundle;
|
|
}
|
|
|
|
private computeArtifactAvailability(selected: readonly Recording[]): ArtifactAvailability {
|
|
let summaryCount = 0;
|
|
let transcriptCount = 0;
|
|
let attachmentsCount = 0;
|
|
let mindmapCount = 0;
|
|
let cardCount = 0;
|
|
const diagnostics: Array<Record<string, unknown>> = [];
|
|
for (const recording of selected) {
|
|
const bundle = this.artifactCache.get(recording.id);
|
|
if (bundle === undefined) {
|
|
continue;
|
|
}
|
|
if (bundle.summary !== null) summaryCount += 1;
|
|
if (bundle.transcript !== null) transcriptCount += 1;
|
|
const assets = bundle.attachments ?? [];
|
|
if (assets.some((a) => this.attachments.classifyAttachmentKind(a) === 'generic')) {
|
|
attachmentsCount += 1;
|
|
}
|
|
if (assets.some((a) => this.attachments.classifyAttachmentKind(a) === 'mindmap')) {
|
|
mindmapCount += 1;
|
|
}
|
|
if (assets.some((a) => this.attachments.classifyAttachmentKind(a) === 'card')) {
|
|
cardCount += 1;
|
|
}
|
|
if (this.noteWriterOptions.debugLogger?.enabled === true) {
|
|
const kindCounts = { generic: 0, mindmap: 0, card: 0 };
|
|
for (const asset of assets) {
|
|
const kind = this.attachments.classifyAttachmentKind(asset);
|
|
kindCounts[kind] += 1;
|
|
}
|
|
diagnostics.push({
|
|
recordingId: recording.id,
|
|
recordingTitle: recording.title,
|
|
attachmentCount: assets.length,
|
|
kindCounts,
|
|
attachmentTypes: assets.map((a) => a.dataType),
|
|
});
|
|
}
|
|
}
|
|
if (diagnostics.length > 0) {
|
|
this.logImportDebug('artifact availability diagnostics', diagnostics);
|
|
}
|
|
return {
|
|
selectedCount: selected.length,
|
|
summaryCount,
|
|
transcriptCount,
|
|
attachmentsCount,
|
|
mindmapCount,
|
|
cardCount,
|
|
// Every recording has downloadable audio in Plaud; the temp-url is
|
|
// resolved lazily at import (and null-handled), so all selected
|
|
// recordings count as audio-available.
|
|
audioCount: selected.length,
|
|
};
|
|
}
|
|
|
|
private promptArtifactSelection(
|
|
availability: ArtifactAvailability,
|
|
initialSelection: ArtifactSelection,
|
|
): Promise<ArtifactSelection | null> {
|
|
return new Promise((resolve) => {
|
|
const modal = new ArtifactSelectionModal(
|
|
this.app,
|
|
availability,
|
|
initialSelection,
|
|
(selection) => resolve(selection),
|
|
);
|
|
modal.open();
|
|
});
|
|
}
|
|
|
|
private updateIntroCount(): void {
|
|
if (this.introEl === null) {
|
|
return;
|
|
}
|
|
const shown = this.visibleRecordings().length;
|
|
const loaded = this.currentRecordings.length;
|
|
const hidden = loaded - shown;
|
|
if (loaded === 0) {
|
|
this.introEl.setText('Loading your recordings...');
|
|
return;
|
|
}
|
|
if (shown === 0) {
|
|
// The empty-state block below carries the actionable explanation;
|
|
// the intro just states the filtered count honestly.
|
|
this.introEl.setText(
|
|
`Nothing to import right now (${loaded} loaded, all hidden by filters).`,
|
|
);
|
|
return;
|
|
}
|
|
const hiddenNote = hidden > 0 ? `, ${hidden} hidden by filters` : '';
|
|
const scroll = this.hasMore ? ' Scroll for older recordings.' : '';
|
|
this.introEl.setText(
|
|
`${shown} recording${shown === 1 ? '' : 's'} to import${hiddenNote}.${scroll}`,
|
|
);
|
|
}
|
|
|
|
private ensureProgressElements(): void {
|
|
if (
|
|
this.listEl === null ||
|
|
this.progressFooterEl === null ||
|
|
this.progressEl !== null
|
|
) {
|
|
return;
|
|
}
|
|
// Progress UI lives in the footer (below the scroll list); the
|
|
// sentinel stays inside the scroll list because the auto-load
|
|
// IntersectionObserver uses listEl as its root.
|
|
const progressEl = this.progressFooterEl.createDiv({
|
|
cls: 'plaud-importer-progress',
|
|
});
|
|
this.progressEl = progressEl;
|
|
// Status group holds an (animated) spinner next to the text so the
|
|
// "loading more" state reads as active work, not a frozen list, while
|
|
// a page is fetched mid-scroll. The spinner stays hidden until a fetch
|
|
// is in flight.
|
|
const statusEl = progressEl.createDiv({ cls: 'plaud-importer-progress-status' });
|
|
this.progressSpinnerEl = statusEl.createDiv({
|
|
cls: 'plaud-importer-spinner plaud-importer-hidden',
|
|
});
|
|
this.progressTextEl = statusEl.createDiv({ cls: 'plaud-importer-progress-text' });
|
|
this.progressActionButton = progressEl.createEl('button', {
|
|
cls: 'plaud-importer-progress-action plaud-importer-hidden',
|
|
});
|
|
this.progressActionButton.textContent = 'Load more recordings';
|
|
this.progressActionButton.addEventListener('click', () => {
|
|
this.onProgressActionClick();
|
|
});
|
|
this.autoLoadSentinelEl = this.listEl.createDiv({ cls: 'plaud-importer-sentinel' });
|
|
}
|
|
|
|
private onProgressActionClick(): void {
|
|
const trigger: LoadMoreTrigger =
|
|
this.loadMoreErrorMessage !== null ? 'retry' : 'manual';
|
|
this.userStartedScrolling = true;
|
|
this.startPrefetchIfNeeded();
|
|
// Clear the cap so one click resumes a full auto-advance burst rather than
|
|
// fetching a single page at a time when the whole head is filtered out.
|
|
this.autoAdvanceCapped = false;
|
|
void this.loadMore(trigger)
|
|
.then(() => this.pageUntilVisibleOrCapped())
|
|
.catch((err) => {
|
|
console.error('Plaud importer: unexpected error in loadMore', err);
|
|
new Notice('Plaud importer: could not load more — see the developer console.');
|
|
});
|
|
}
|
|
|
|
private updateProgressUi(): void {
|
|
this.ensureProgressElements();
|
|
if (
|
|
this.progressEl === null ||
|
|
this.progressTextEl === null ||
|
|
this.progressActionButton === null
|
|
) {
|
|
return;
|
|
}
|
|
this.progressEl.hidden = false;
|
|
this.setProgressActionButton(null);
|
|
this.updateEmptyState();
|
|
const shown = this.visibleRecordings().length;
|
|
// Spinner shows the moment a page fetch starts — including the
|
|
// background prefetch and an auto-advance burst, which is where the real
|
|
// network wait happens.
|
|
const fetching = this.loadingMore || this.prefetchInFlight || this.autoAdvancing;
|
|
this.progressSpinnerEl?.toggleClass('plaud-importer-hidden', !fetching);
|
|
if (this.loadMoreErrorMessage !== null) {
|
|
this.progressTextEl.setText(`Could not load more: ${this.loadMoreErrorMessage}`);
|
|
this.setProgressActionButton('Retry loading');
|
|
return;
|
|
}
|
|
if (this.loadingMore || this.autoAdvancing) {
|
|
// While auto-advancing past hidden pages the empty-state block carries
|
|
// the "looking..." message, so keep the footer text out of the way.
|
|
this.progressTextEl.setText(shown === 0 ? '' : 'Loading more recordings...');
|
|
return;
|
|
}
|
|
if (!this.hasMore) {
|
|
this.progressTextEl.setText(shown === 0 ? '' : 'You are all caught up.');
|
|
return;
|
|
}
|
|
// More recordings exist and nothing is visible: an auto-advance burst hit
|
|
// its page cap without surfacing a match. Offer a manual continue instead
|
|
// of looping the fetch forever.
|
|
if (shown === 0) {
|
|
this.progressTextEl.setText('');
|
|
this.setProgressActionButton('Load more recordings');
|
|
return;
|
|
}
|
|
if (this.prefetchInFlight) {
|
|
this.progressTextEl.setText('Caching next recordings...');
|
|
return;
|
|
}
|
|
if (this.prefetchedRecordings !== null) {
|
|
this.progressTextEl.setText('Next recordings cached. Keep scrolling.');
|
|
return;
|
|
}
|
|
this.progressTextEl.setText('Scroll to load more recordings.');
|
|
}
|
|
|
|
// Show or hide the in-list empty-state message. Only visible when the filters
|
|
// hide every loaded row; its wording distinguishes an active scan, a capped
|
|
// scan with more to fetch, and a genuine "nothing to import" end state.
|
|
private updateEmptyState(): void {
|
|
if (this.emptyStateEl === null) {
|
|
return;
|
|
}
|
|
if (this.visibleRecordings().length > 0) {
|
|
this.emptyStateEl.toggleClass('plaud-importer-hidden', true);
|
|
return;
|
|
}
|
|
this.emptyStateEl.toggleClass('plaud-importer-hidden', false);
|
|
this.emptyStateEl.empty();
|
|
if (this.loadingMore || this.autoAdvancing) {
|
|
this.emptyStateEl.createEl('p', {
|
|
text: `Looking for recordings to import... (${this.currentRecordings.length} scanned)`,
|
|
});
|
|
return;
|
|
}
|
|
if (this.loadMoreErrorMessage !== null) {
|
|
this.emptyStateEl.createEl('p', {
|
|
text: 'Could not load more recordings. Try again with the button below.',
|
|
});
|
|
return;
|
|
}
|
|
if (this.autoAdvanceCapped && this.hasMore) {
|
|
this.emptyStateEl.createEl('p', {
|
|
text: `Scanned ${this.currentRecordings.length} recordings, none match your filters yet.`,
|
|
});
|
|
this.emptyStateEl.createEl('p', {
|
|
cls: 'plaud-importer-empty-hint',
|
|
text: 'Keep looking with the button below, or turn off a filter above.',
|
|
});
|
|
return;
|
|
}
|
|
// Exhausted: nothing left to fetch and nothing visible.
|
|
this.emptyStateEl.createEl('p', { text: 'No recordings to import.' });
|
|
if (this.hideProcessed || this.hideUpdates || this.hideIgnored) {
|
|
this.emptyStateEl.createEl('p', {
|
|
cls: 'plaud-importer-empty-hint',
|
|
text: 'Everything available is already imported or hidden. Turn off a filter above to review or re-import existing recordings.',
|
|
});
|
|
}
|
|
}
|
|
|
|
private setProgressActionButton(label: string | null): void {
|
|
if (this.progressActionButton === null) {
|
|
return;
|
|
}
|
|
if (label === null) {
|
|
this.progressActionButton.toggleClass('plaud-importer-hidden', true);
|
|
this.progressActionButton.disabled = false;
|
|
return;
|
|
}
|
|
this.progressActionButton.toggleClass('plaud-importer-hidden', false);
|
|
this.progressActionButton.textContent = label;
|
|
this.progressActionButton.disabled = false;
|
|
}
|
|
|
|
private handleListInteraction(): void {
|
|
const firstInteraction = !this.userStartedScrolling;
|
|
if (firstInteraction) {
|
|
this.userStartedScrolling = true;
|
|
this.startPrefetchIfNeeded();
|
|
this.updateProgressUi();
|
|
this.logScrollDebug('first list interaction', this.scrollStateSnapshot());
|
|
}
|
|
// Re-check near-bottom on every interaction, not just the first. The
|
|
// IntersectionObserver fires only on transitions, and once we page
|
|
// before the sentinel scrolls out of view it can stay continuously
|
|
// intersecting and never fire again — which froze paging with the
|
|
// footer stuck on "Scroll to load more". A direct scroll-driven check
|
|
// keeps paging alive regardless of the observer. maybeAutoLoad's own
|
|
// throttle prevents this from firing on every scroll event.
|
|
if (this.isListNearBottom()) {
|
|
this.maybeAutoLoad('interaction');
|
|
}
|
|
}
|
|
|
|
private startPrefetchIfNeeded(): void {
|
|
if (!this.userStartedScrolling || !this.hasMore) {
|
|
return;
|
|
}
|
|
if (this.loadingMore || this.prefetchInFlight || this.prefetchedRecordings !== null) {
|
|
return;
|
|
}
|
|
const generation = this.fetchGeneration;
|
|
const skip = this.currentRecordings.length;
|
|
this.prefetchInFlight = true;
|
|
this.updateProgressUi();
|
|
this.logScrollDebug('prefetch start', { skip, ...this.scrollStateSnapshot() });
|
|
void this.client
|
|
.listRecordings({
|
|
skip,
|
|
limit: PAGE_SIZE,
|
|
})
|
|
.then((incoming) => {
|
|
if (generation !== this.fetchGeneration) {
|
|
this.logScrollDebug('prefetch resolved (stale, dropped)', { skip });
|
|
return;
|
|
}
|
|
this.prefetchedRecordings = incoming;
|
|
this.logScrollDebug('prefetch resolved', {
|
|
skip,
|
|
received: incoming.length,
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
if (generation !== this.fetchGeneration) {
|
|
return;
|
|
}
|
|
console.warn('Plaud importer: next-page prefetch failed', err);
|
|
this.logScrollDebug('prefetch failed', {
|
|
skip,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
// Prefetch failures are intentionally silent; regular loadMore
|
|
// still handles user-visible error messages.
|
|
})
|
|
.finally(() => {
|
|
if (generation !== this.fetchGeneration) {
|
|
return;
|
|
}
|
|
this.prefetchInFlight = false;
|
|
this.updateProgressUi();
|
|
if (this.isListNearBottom()) {
|
|
this.maybeAutoLoad('prefetch-finally');
|
|
}
|
|
});
|
|
}
|
|
|
|
private setupAutoLoadObserver(): void {
|
|
this.teardownAutoLoadObserver();
|
|
if (this.listEl === null || this.autoLoadSentinelEl === null) {
|
|
return;
|
|
}
|
|
this.listInteractionHandler = () => this.handleListInteraction();
|
|
this.listEl.addEventListener('wheel', this.listInteractionHandler, { passive: true });
|
|
this.listEl.addEventListener('touchmove', this.listInteractionHandler, {
|
|
passive: true,
|
|
});
|
|
this.listEl.addEventListener('scroll', this.listInteractionHandler, {
|
|
passive: true,
|
|
});
|
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
this.autoLoadObserver = new IntersectionObserver(
|
|
(entries) => {
|
|
const intersecting = entries.some((entry) => entry.isIntersecting);
|
|
this.logScrollDebug('sentinel observer fired', {
|
|
intersecting,
|
|
...this.scrollStateSnapshot(),
|
|
});
|
|
if (!intersecting) {
|
|
return;
|
|
}
|
|
this.maybeAutoLoad('observer');
|
|
},
|
|
{
|
|
root: this.listEl,
|
|
rootMargin: `0px 0px ${AUTO_LOAD_ROOT_MARGIN_PX}px 0px`,
|
|
},
|
|
);
|
|
this.autoLoadObserver.observe(this.autoLoadSentinelEl);
|
|
return;
|
|
}
|
|
this.scrollFallbackHandler = () => {
|
|
if (this.listEl === null) {
|
|
return;
|
|
}
|
|
if (this.isListNearBottom()) {
|
|
this.maybeAutoLoad('scroll-fallback');
|
|
}
|
|
};
|
|
this.listEl.addEventListener('scroll', this.scrollFallbackHandler);
|
|
}
|
|
|
|
private teardownAutoLoadObserver(): void {
|
|
if (this.autoLoadObserver !== null) {
|
|
this.autoLoadObserver.disconnect();
|
|
this.autoLoadObserver = null;
|
|
}
|
|
if (this.listEl !== null && this.listInteractionHandler !== null) {
|
|
this.listEl.removeEventListener('wheel', this.listInteractionHandler);
|
|
this.listEl.removeEventListener('touchmove', this.listInteractionHandler);
|
|
this.listEl.removeEventListener('scroll', this.listInteractionHandler);
|
|
}
|
|
this.listInteractionHandler = null;
|
|
if (this.listEl !== null && this.scrollFallbackHandler !== null) {
|
|
this.listEl.removeEventListener('scroll', this.scrollFallbackHandler);
|
|
}
|
|
this.scrollFallbackHandler = null;
|
|
}
|
|
|
|
private maybeAutoLoad(source: string): void {
|
|
const reason = this.autoLoadBlockReason();
|
|
if (reason !== null) {
|
|
// Throttle blocks are the common case on a fast scroll — logging
|
|
// them would bury the signal. Log every other block reason, but
|
|
// only once per distinct reason so a held state (e.g. a stuck
|
|
// prefetch) doesn't spam a line per scroll event.
|
|
if (reason !== 'throttle' && reason !== this.lastAutoLoadBlockReason) {
|
|
this.lastAutoLoadBlockReason = reason;
|
|
this.logScrollDebug('auto-load blocked', {
|
|
source,
|
|
reason,
|
|
...this.scrollStateSnapshot(),
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
this.lastAutoLoadBlockReason = null;
|
|
this.lastAutoLoadAt = Date.now();
|
|
this.logScrollDebug('auto-load firing', {
|
|
source,
|
|
...this.scrollStateSnapshot(),
|
|
});
|
|
void this.loadMore('auto').catch((err) => {
|
|
console.error('Plaud importer: unexpected auto-load error', err);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* First gate that currently prevents an auto-load, or null if a load may
|
|
* proceed. Centralized (and named) so the scroll debug log can report
|
|
* exactly why paging is or is not advancing.
|
|
*/
|
|
private autoLoadBlockReason(): string | null {
|
|
if (!this.hasMore) {
|
|
return 'no-more';
|
|
}
|
|
if (this.loadingMore) {
|
|
return 'already-loading';
|
|
}
|
|
if (!this.userStartedScrolling) {
|
|
return 'not-scrolled-yet';
|
|
}
|
|
if (this.prefetchInFlight && this.prefetchedRecordings === null) {
|
|
return 'prefetch-in-flight';
|
|
}
|
|
if (this.loadMoreErrorMessage !== null) {
|
|
return 'previous-error';
|
|
}
|
|
if (Date.now() - this.lastAutoLoadAt < AUTO_LOAD_THROTTLE_MS) {
|
|
return 'throttle';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private isListNearBottom(): boolean {
|
|
if (this.listEl === null) {
|
|
return false;
|
|
}
|
|
const remaining =
|
|
this.listEl.scrollHeight - this.listEl.scrollTop - this.listEl.clientHeight;
|
|
return remaining <= AUTO_LOAD_ROOT_MARGIN_PX;
|
|
}
|
|
|
|
/**
|
|
* Snapshot of the scroll/paging state machine for the debug log. Captures
|
|
* every gate maybeAutoLoad consults plus the raw scroll geometry, so a
|
|
* frozen-list report can be diagnosed from the log alone.
|
|
*/
|
|
private scrollStateSnapshot(): Record<string, unknown> {
|
|
const remainingPx =
|
|
this.listEl !== null
|
|
? this.listEl.scrollHeight -
|
|
this.listEl.scrollTop -
|
|
this.listEl.clientHeight
|
|
: null;
|
|
return {
|
|
loaded: this.currentRecordings.length,
|
|
hasMore: this.hasMore,
|
|
loadingMore: this.loadingMore,
|
|
prefetchInFlight: this.prefetchInFlight,
|
|
hasPrefetchedPage: this.prefetchedRecordings !== null,
|
|
userStartedScrolling: this.userStartedScrolling,
|
|
loadMoreError: this.loadMoreErrorMessage,
|
|
nearBottom: this.isListNearBottom(),
|
|
remainingPx,
|
|
rootMarginPx: AUTO_LOAD_ROOT_MARGIN_PX,
|
|
msSinceLastAutoLoad: Date.now() - this.lastAutoLoadAt,
|
|
throttleMs: AUTO_LOAD_THROTTLE_MS,
|
|
observerActive: this.autoLoadObserver !== null,
|
|
};
|
|
}
|
|
|
|
private logScrollDebug(message: string, payload?: unknown): void {
|
|
const logger = this.noteWriterOptions.debugLogger;
|
|
if (!logger || !logger.enabled) {
|
|
return;
|
|
}
|
|
logger.log({
|
|
kind: 'note',
|
|
endpoint: '/scroll',
|
|
message,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
private async fetchPageWithSilentRetry(
|
|
skip: number,
|
|
trigger: LoadMoreTrigger,
|
|
): Promise<readonly Recording[]> {
|
|
const fetchOnce = async (): Promise<readonly Recording[]> => {
|
|
return this.client.listRecordings({
|
|
skip,
|
|
limit: PAGE_SIZE,
|
|
});
|
|
};
|
|
try {
|
|
return await fetchOnce();
|
|
} catch (firstErr) {
|
|
const classification = classifyError(firstErr);
|
|
const shouldRetry = trigger === 'auto' && classification.canRetry;
|
|
if (!shouldRetry) {
|
|
throw firstErr;
|
|
}
|
|
await this.sleep(AUTO_LOAD_SILENT_RETRY_DELAY_MS);
|
|
return fetchOnce();
|
|
}
|
|
}
|
|
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
window.setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
private async onImportClick(selection: ArtifactSelection): Promise<void> {
|
|
// Refuse to start a second import while one is already running. Without
|
|
// this, "Review artifacts first" (which is not disabled during a default
|
|
// import) could launch a second runImportBatch; trackImportRun would then
|
|
// overwrite activeImportRun, orphaning the first loop and letting two
|
|
// writers hit the same notes concurrently.
|
|
if (this.activeImportRun !== null) return;
|
|
const selected = this.currentRecordings.filter((r) =>
|
|
this.selectedIds.has(r.id),
|
|
);
|
|
if (selected.length === 0) {
|
|
return;
|
|
}
|
|
const duplicatePolicy = await this.resolveDuplicatePolicyForImport(selected.length);
|
|
if (duplicatePolicy === null) {
|
|
return;
|
|
}
|
|
|
|
// Reset sticky state so choices from a prior run do not leak into
|
|
// this batch. Only 'prompt' mode consumes this field, but
|
|
// resetting unconditionally keeps the invariant simple. currentBatchSize
|
|
// is set inside runImportBatch so it tracks the slice actually being run.
|
|
this.stickyDuplicateDecision = null;
|
|
|
|
// Disable the Import button so rapid double-clicks don't queue a
|
|
// second run against the same selection.
|
|
if (this.importButton) {
|
|
this.importButton.disabled = true;
|
|
this.importButton.textContent = `Importing 0 of ${selected.length}…`;
|
|
}
|
|
|
|
// Delegate the per-recording loop AND the terminal UI to the shared
|
|
// batch runner. isInitial=true keeps the issue #12 cancelled-path button
|
|
// reset (the modal stays on the selection screen here) and starts the
|
|
// run with an empty prior-results accumulator.
|
|
await this.trackImportRun(
|
|
this.runImportBatch(selected, selection, duplicatePolicy, [], true),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Record `run` as the active import loop for its lifetime so onClose can wait
|
|
* for it to settle before releasing the shared gate. Self-clears on settle
|
|
* (only if it is still the current run, so a resume that replaced it wins).
|
|
*/
|
|
private trackImportRun(run: Promise<void>): Promise<void> {
|
|
// Declare with `let` and assign after, so the finally closure does not
|
|
// reference `tracked` from inside its own initializer.
|
|
let tracked: Promise<void>;
|
|
tracked = run.finally(() => {
|
|
if (this.activeImportRun === tracked) this.activeImportRun = null;
|
|
});
|
|
this.activeImportRun = tracked;
|
|
return tracked;
|
|
}
|
|
|
|
/**
|
|
* Run the import loop over `recordings` and render the terminal UI. Shared
|
|
* by the initial Import click (`isInitial` true: a live Import button, the
|
|
* selection screen stays put on a cancel) and the post-re-auth resume
|
|
* (`isInitial` false: a state screen, no button). `priorResults` carries the
|
|
* results of earlier sub-batches so the final summary and the partial Notice
|
|
* count the whole run, not just this slice. On a mid-batch token rejection
|
|
* the runner returns stop:'auth-failed'; the modal then surfaces A1's inline
|
|
* re-auth and offers to resume the unprocessed tail.
|
|
*/
|
|
private async runImportBatch(
|
|
recordings: Recording[],
|
|
selection: ArtifactSelection,
|
|
duplicatePolicy: DuplicatePolicy,
|
|
priorResults: ImportResult[],
|
|
isInitial: boolean,
|
|
): Promise<void> {
|
|
// Track the size of THIS batch (the full selection initially, the tail on
|
|
// a resume). The duplicate prompt gates its batch-level "all remaining"
|
|
// options on currentBatchSize > 1, so a resumed run must refresh it or a
|
|
// single-item tail would wrongly offer "overwrite/skip all remaining".
|
|
this.currentBatchSize = recordings.length;
|
|
const writer = this.buildImportWriter(duplicatePolicy);
|
|
if (!writer) {
|
|
return;
|
|
}
|
|
|
|
// The observer keeps every UI concern: it updates the (initial-screen)
|
|
// button text and refreshes row badges. fetchArtifacts reuses the warm
|
|
// artifact cache so a prior "review artifacts" preflight is not
|
|
// re-fetched; applyFold persists the post-write transcript fold.
|
|
const outcome = await runImport({
|
|
recordings,
|
|
selection,
|
|
writer,
|
|
attachments: this.attachments,
|
|
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) => {
|
|
if (this.importButton) {
|
|
this.importButton.textContent = `Importing ${index} of ${total}…`;
|
|
}
|
|
},
|
|
onRecordingWritten: (recording) => {
|
|
this.updateRowBadge(recording.id);
|
|
},
|
|
shouldAbort: () => this.aborted,
|
|
},
|
|
});
|
|
|
|
// Accumulate across resumes so the summary and the partial Notice
|
|
// reflect the whole run. Every processed recording yields exactly one
|
|
// result (the auth-failed recording yields none), so combined.length is
|
|
// the running processed count and combined.length + tail.length equals
|
|
// the original total.
|
|
const combined = [...priorResults, ...outcome.results];
|
|
|
|
if (outcome.stop === 'auth-failed') {
|
|
// The token was rejected mid-batch. recordings[processed] onward
|
|
// were never attempted; carry that tail so a successful re-auth can
|
|
// resume exactly where the run stopped.
|
|
const tail = recordings.slice(outcome.processed);
|
|
this.renderAuthInterrupted(tail, selection, duplicatePolicy, combined);
|
|
return;
|
|
}
|
|
|
|
if (outcome.stop !== 'completed') {
|
|
// 'aborted' (modal closed mid-run) and 'cancelled' (per-file
|
|
// duplicate prompt dismissed) both surface the same partial Notice.
|
|
const total = priorResults.length + recordings.length;
|
|
new Notice(
|
|
`${formatImportNotice(tallyImportResults(combined))} (cancelled at ${combined.length}/${total})`,
|
|
);
|
|
if (outcome.stop === 'cancelled') {
|
|
// issue #12: on the initial selection screen the loop left the
|
|
// Import button disabled and labeled "Importing X of Y…"; restore
|
|
// the initial label and re-enable it.
|
|
if (isInitial && this.importButton) {
|
|
this.importButton.textContent = IMPORT_BUTTON_LABEL;
|
|
this.updateImportButtonState();
|
|
} else if (!isInitial) {
|
|
// A resumed batch has no selection screen to fall back to. The
|
|
// user stopped at a duplicate prompt, so re-offer the still
|
|
// unprocessed tail (resume or go back to the list) rather than
|
|
// rendering a success summary, which would auto-close the modal
|
|
// and fire a second Notice. recordings[processed] (the one they
|
|
// cancelled on) is included so resuming re-attempts it.
|
|
const remaining = recordings.slice(outcome.processed);
|
|
this.renderResumeReady(
|
|
remaining,
|
|
selection,
|
|
duplicatePolicy,
|
|
combined,
|
|
`Import stopped. ${remaining.length} recordings remain.`,
|
|
);
|
|
}
|
|
}
|
|
// 'aborted' is the modal closing; nothing to render in either case.
|
|
return;
|
|
}
|
|
|
|
this.renderSummary(tallyImportResults(combined));
|
|
}
|
|
|
|
/**
|
|
* Build the per-run NoteWriter. A NoteWriterError means the user's config is
|
|
* bad ("..", invalid onDuplicate): surface it via the error screen pointing
|
|
* at Settings (not "unknown error please report this") and return null so
|
|
* the caller stops. Anything else is a real code bug; re-throw so the outer
|
|
* click handler's .catch reports it honestly. Shared by the initial import
|
|
* and a resumed batch so both wire the same duplicate policy, prompt
|
|
* callback, and cross-folder dedup lookup.
|
|
*/
|
|
private buildImportWriter(duplicatePolicy: DuplicatePolicy): NoteWriter | null {
|
|
try {
|
|
return new NoteWriter(this.app.vault, {
|
|
...this.noteWriterOptions,
|
|
onDuplicate: duplicatePolicy,
|
|
promptOnDuplicate:
|
|
duplicatePolicy === 'prompt' ? this.handleDuplicatePrompt : undefined,
|
|
// Cross-folder dedup: let the writer find a prior import of the
|
|
// same recording that lives in a different subfolder (for example
|
|
// after the subfolder template changed) so it never writes a
|
|
// second copy. Backed by the vault index, read live so it reflects
|
|
// notes written earlier in this same run.
|
|
existingPathForPlaudId: (id) =>
|
|
this.importedIndex.get(id as PlaudRecordingId)?.path ?? null,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof NoteWriterError) {
|
|
console.error('Plaud importer: NoteWriter construction failed', err);
|
|
this.renderError(classifyError(err));
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The token was rejected mid-batch (an expired or revoked session). Show
|
|
* how much completed and surface A1's inline re-auth (the Sign-in CTA and the
|
|
* SSO expander). A successful sign-in does NOT auto-resume; it advances to
|
|
* renderResumeReady so the user explicitly chooses to resume the unprocessed
|
|
* tail or go back to the list.
|
|
*/
|
|
private renderAuthInterrupted(
|
|
tail: Recording[],
|
|
selection: ArtifactSelection,
|
|
duplicatePolicy: DuplicatePolicy,
|
|
priorResults: ImportResult[],
|
|
): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
const processed = priorResults.length;
|
|
const total = processed + tail.length;
|
|
box.createEl('p', {
|
|
text: `Plaud rejected your sign-in during the import. ${processed} of ${total} recordings were processed before it stopped. Sign in to resume the remaining ${tail.length}.`,
|
|
cls: 'plaud-importer-error-message',
|
|
});
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
|
|
const onSuccess = (): Promise<void> => {
|
|
this.renderResumeReady(
|
|
tail,
|
|
selection,
|
|
duplicatePolicy,
|
|
priorResults,
|
|
`Signed back in. ${tail.length} recordings remain.`,
|
|
);
|
|
return Promise.resolve();
|
|
};
|
|
const reauthAvailable = this.appendSignInCta(
|
|
buttonRow,
|
|
'token-rejected',
|
|
onSuccess,
|
|
);
|
|
|
|
const closeButton = buttonRow.createEl('button', { text: 'Close' });
|
|
closeButton.addEventListener('click', () => this.close());
|
|
|
|
const sso = this.noteWriterOptions.onReauthSso;
|
|
if (reauthAvailable && sso) {
|
|
this.renderReauthSsoExpander(contentEl, sso, onSuccess);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* After a successful re-auth on the auth-interrupted screen, offer the
|
|
* explicit choice: resume the unprocessed tail, or go back to the list
|
|
* (re-run). Explicit rather than auto-resume so the user confirms before
|
|
* another network batch starts.
|
|
*/
|
|
private renderResumeReady(
|
|
tail: Recording[],
|
|
selection: ArtifactSelection,
|
|
duplicatePolicy: DuplicatePolicy,
|
|
priorResults: ImportResult[],
|
|
headline: string,
|
|
): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
box.createEl('p', {
|
|
text: `${headline} Resume the import, or go back to the list.`,
|
|
cls: 'plaud-importer-loading',
|
|
});
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
|
|
const resumeButton = buttonRow.createEl('button', {
|
|
text: `Resume remaining (${tail.length})`,
|
|
cls: 'mod-cta',
|
|
});
|
|
resumeButton.addEventListener('click', () => {
|
|
resumeButton.disabled = true;
|
|
// The resumed batch has no Import button to carry the progress
|
|
// counter, so show a lightweight progress state in its place.
|
|
this.renderImporting(tail.length);
|
|
this.trackImportRun(
|
|
this.runImportBatch(
|
|
tail,
|
|
selection,
|
|
duplicatePolicy,
|
|
priorResults,
|
|
false,
|
|
),
|
|
).catch((err) => {
|
|
console.error('Plaud importer: resume import failed', err);
|
|
this.renderError(classifyError(err));
|
|
});
|
|
});
|
|
|
|
const backButton = buttonRow.createEl('button', { text: 'Back to list' });
|
|
backButton.addEventListener('click', () => {
|
|
this.refresh().catch((err) => {
|
|
console.error('Plaud importer: reload after re-auth failed', err);
|
|
this.renderError(classifyError(err));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Minimal work-in-progress state shown while a resumed batch runs. The
|
|
* resume path has no Import button to carry the "Importing i of n" counter,
|
|
* so this stands in until the summary replaces it.
|
|
*/
|
|
private renderImporting(count: number): void {
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
const box = contentEl.createDiv({ cls: 'plaud-importer-state' });
|
|
box.createEl('p', {
|
|
text: `Importing ${count} remaining recordings…`,
|
|
cls: 'plaud-importer-loading',
|
|
});
|
|
}
|
|
|
|
private async resolveDuplicatePolicyForImport(
|
|
selectedCount: number,
|
|
): Promise<DuplicatePolicy | null> {
|
|
// 'skip' and 'prompt' do not require a batch-level confirmation.
|
|
// 'prompt' defers the decision to each duplicate at write time.
|
|
if (this.noteWriterOptions.onDuplicate !== 'overwrite') {
|
|
return this.noteWriterOptions.onDuplicate;
|
|
}
|
|
const choice = await this.promptOverwriteConfirmation(selectedCount);
|
|
if (choice === 'cancel') {
|
|
return null;
|
|
}
|
|
if (choice === 'skip') {
|
|
new Notice('Plaud importer: using "skip existing" for this import run.');
|
|
return 'skip';
|
|
}
|
|
return 'overwrite';
|
|
}
|
|
|
|
/**
|
|
* Callback handed to NoteWriter when the duplicate policy is
|
|
* 'prompt'. Honors the sticky decision first so "Overwrite all
|
|
* remaining" / "Skip all remaining" short-circuits subsequent
|
|
* prompts in the same batch. Cancel bubbles back as 'cancel' which
|
|
* the writer translates into NoteWriterCancelledError.
|
|
*/
|
|
private readonly handleDuplicatePrompt: DuplicatePromptCallback = async (ctx) => {
|
|
if (this.aborted) {
|
|
return 'cancel';
|
|
}
|
|
if (this.stickyDuplicateDecision !== null) {
|
|
return this.stickyDuplicateDecision;
|
|
}
|
|
const choice = await this.askDuplicateDecision(
|
|
ctx.recordingTitle,
|
|
ctx.targetPath,
|
|
this.currentBatchSize > 1,
|
|
);
|
|
switch (choice) {
|
|
case 'overwrite':
|
|
return 'overwrite';
|
|
case 'skip':
|
|
return 'skip';
|
|
case 'overwrite-all':
|
|
this.stickyDuplicateDecision = 'overwrite';
|
|
new Notice('Plaud importer: overwriting all remaining duplicates in this run.');
|
|
return 'overwrite';
|
|
case 'skip-all':
|
|
this.stickyDuplicateDecision = 'skip';
|
|
new Notice('Plaud importer: skipping all remaining duplicates in this run.');
|
|
return 'skip';
|
|
case 'cancel':
|
|
return 'cancel';
|
|
}
|
|
};
|
|
|
|
private askDuplicateDecision(
|
|
recordingTitle: string,
|
|
targetPath: string,
|
|
showBatchOptions: boolean,
|
|
): Promise<DuplicateDecisionChoice> {
|
|
return new Promise((resolve) => {
|
|
const modal = new DuplicateDecisionModal(
|
|
this.app,
|
|
recordingTitle,
|
|
targetPath,
|
|
showBatchOptions,
|
|
resolve,
|
|
);
|
|
modal.open();
|
|
});
|
|
}
|
|
|
|
private promptOverwriteConfirmation(
|
|
selectedCount: number,
|
|
): Promise<'overwrite' | 'skip' | 'cancel'> {
|
|
return new Promise((resolve) => {
|
|
const modal = new OverwriteConfirmationModal(this.app, selectedCount, resolve);
|
|
modal.open();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Persist fold state for the wrapping transcript heading in a
|
|
* freshly-written note so the chaptered transcript renders collapsed by
|
|
* default while the summary, template outputs, and chapters callout above
|
|
* it stay visible. The `## Template outputs` block is deliberately NOT
|
|
* folded: it is an H2 and the transcript heading is deeper, so folding it
|
|
* would subsume the transcript. Uses Obsidian's undocumented but stable
|
|
* internal `app.foldManager.save` API (type-augmented in `types.d.ts`) plus
|
|
* a best-effort same-session apply via the active MarkdownView's
|
|
* `applyFoldInfo` when the file happens to already be open in a leaf.
|
|
*
|
|
* Failure is swallowed with a console warning: a missing
|
|
* foldManager (older Obsidian) or an applyFoldInfo rejection
|
|
* degrades to expanded-by-default, which is unfortunate but never
|
|
* breaks the import. This method must never throw into the
|
|
* import loop.
|
|
*/
|
|
private async applyNoteFolds(path: string): Promise<void> {
|
|
try {
|
|
const file = this.app.vault.getFileByPath(path);
|
|
if (!(file instanceof TFile)) {
|
|
return;
|
|
}
|
|
const body = await this.app.vault.read(file);
|
|
const headerLevel = this.noteWriterOptions.transcriptHeaderLevel ?? 4;
|
|
// Fold the wrapping transcript heading so the chaptered transcript
|
|
// opens collapsed. The transcript is the final section, so this
|
|
// single heading-fold collapses it to end-of-note. Template outputs
|
|
// stay expanded (folding their shallower H2 would subsume the
|
|
// deeper transcript heading).
|
|
const transcriptHeadingLine = findTranscriptHeadingLine(body, headerLevel);
|
|
if (transcriptHeadingLine === null) {
|
|
return;
|
|
}
|
|
const foldLines = [transcriptHeadingLine];
|
|
const totalLines = body.split('\n').length;
|
|
const foldInfo = {
|
|
folds: foldLines.map((line) => ({ from: line, to: line })),
|
|
lines: totalLines,
|
|
};
|
|
// Persist the fold state so the next file-open applies it.
|
|
// The foldManager API is not part of the documented
|
|
// Obsidian surface — guard its presence at runtime to stay
|
|
// compatible with future Obsidian versions that might move
|
|
// or rename it.
|
|
if (this.app.foldManager && typeof this.app.foldManager.save === 'function') {
|
|
await this.app.foldManager.save(file, foldInfo);
|
|
}
|
|
// Best-effort in-session apply: if the note is already open
|
|
// in an active MarkdownView, push the fold state into its
|
|
// current mode right now so the user sees the folds without
|
|
// having to close and reopen the tab.
|
|
const leaves = this.app.workspace.getLeavesOfType('markdown');
|
|
for (const leaf of leaves) {
|
|
if (
|
|
leaf.view instanceof MarkdownView &&
|
|
leaf.view.file?.path === path &&
|
|
leaf.view.currentMode &&
|
|
typeof leaf.view.currentMode.applyFoldInfo === 'function'
|
|
) {
|
|
leaf.view.currentMode.applyFoldInfo(foldInfo);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`Plaud importer: failed to apply fold state for ${path}`,
|
|
err,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
private logImportDebug(message: string, payload?: unknown): void {
|
|
const logger = this.noteWriterOptions.debugLogger;
|
|
if (!logger || !logger.enabled) {
|
|
return;
|
|
}
|
|
logger.log({
|
|
kind: 'note',
|
|
endpoint: '/import',
|
|
message,
|
|
payload,
|
|
});
|
|
}
|
|
|
|
|
|
private renderSummary(tally: ImportTally): void {
|
|
// Fire the Notice FIRST so a DOM-render failure cannot eat the
|
|
// batch result. The modal body render below can throw; the
|
|
// top-level toast is the last-line-of-defense feedback.
|
|
new Notice(formatImportNotice(tally));
|
|
|
|
const { contentEl } = this;
|
|
contentEl.empty();
|
|
|
|
const imported = tally.created + tally.overwritten;
|
|
const noContentClause =
|
|
tally.noContent > 0 ? `${tally.noContent} no content, ` : '';
|
|
const placeholderClause =
|
|
tally.placeholdersWritten > 0
|
|
? `${tally.placeholdersWritten} placeholder, `
|
|
: '';
|
|
const summaryLine =
|
|
`${imported} imported (${tally.created} new, ${tally.overwritten} overwritten), ` +
|
|
`${tally.skipped} skipped, ${noContentClause}${placeholderClause}${tally.failed} failed.`;
|
|
|
|
contentEl.createEl('p', {
|
|
text: summaryLine,
|
|
cls: 'plaud-importer-summary',
|
|
});
|
|
|
|
if (tally.failures.length > 0) {
|
|
const details = contentEl.createEl('details', {
|
|
cls: 'plaud-importer-failures',
|
|
});
|
|
// Pre-expand so the user sees the failures without having to
|
|
// click. The <details> element still provides the collapse
|
|
// affordance if they want to hide them.
|
|
details.setAttribute('open', '');
|
|
details.createEl('summary', {
|
|
text: `${tally.failures.length} failure${
|
|
tally.failures.length === 1 ? '' : 's'
|
|
}`,
|
|
});
|
|
const list = details.createEl('ul');
|
|
for (const f of tally.failures) {
|
|
if (f.kind !== 'failed') {
|
|
continue;
|
|
}
|
|
const li = list.createEl('li');
|
|
li.createEl('strong', { text: f.recording.title });
|
|
li.createSpan({
|
|
text: ` (${f.recording.id})`,
|
|
cls: 'plaud-importer-failure-id',
|
|
});
|
|
li.createEl('br');
|
|
li.createSpan({ text: f.reason });
|
|
}
|
|
|
|
// Also offer a one-click Copy-all-failures button for bug
|
|
// reporting.
|
|
const copyAllFailures = contentEl.createEl('button', {
|
|
text: 'Copy all failure details',
|
|
cls: 'plaud-importer-copy-failures',
|
|
});
|
|
copyAllFailures.addEventListener('click', () => {
|
|
const payload = tally.failures
|
|
.filter((f): f is ImportResult & { kind: 'failed' } => f.kind === 'failed')
|
|
.map((f) => {
|
|
return [
|
|
`Recording: ${f.recording.title} (${f.recording.id})`,
|
|
`Category: ${f.classification.category}`,
|
|
`Retryable: ${f.classification.canRetry}`,
|
|
`Message: ${f.reason}`,
|
|
].join('\n');
|
|
})
|
|
.join('\n\n---\n\n');
|
|
void copyToClipboard(
|
|
`Plaud importer: ${tally.failures.length} failure${
|
|
tally.failures.length === 1 ? '' : 's'
|
|
}\n\n${payload}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Informational (non-error) list of recordings Plaud had no content
|
|
// for. Collapsed by default since these are expected, not problems —
|
|
// the user selected them but Plaud never transcribed or summarized
|
|
// them, so there was nothing to write.
|
|
if (tally.noContentResults.length > 0) {
|
|
const noContent = contentEl.createEl('details', {
|
|
cls: 'plaud-importer-nocontent',
|
|
});
|
|
noContent.createEl('summary', {
|
|
text: `${tally.noContentResults.length} skipped — no content in Plaud`,
|
|
});
|
|
const list = noContent.createEl('ul');
|
|
for (const r of tally.noContentResults) {
|
|
if (r.kind !== 'skipped-no-content') {
|
|
continue;
|
|
}
|
|
const li = list.createEl('li');
|
|
li.createEl('strong', { text: r.recording.title });
|
|
li.createSpan({
|
|
text: ` (${r.recording.id})`,
|
|
cls: 'plaud-importer-failure-id',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Informational list of recordings Plaud has not processed yet, for
|
|
// which a placeholder note was written. Pre-expanded so the user notices
|
|
// the stubs and knows to re-import once Plaud finishes processing. These
|
|
// are not failures: the recording exists, Plaud just has no transcript
|
|
// or summary for it yet, and the placeholder keeps the link.
|
|
if (tally.placeholderResults.length > 0) {
|
|
const placeholders = contentEl.createEl('details', {
|
|
cls: 'plaud-importer-placeholders',
|
|
});
|
|
placeholders.setAttribute('open', '');
|
|
placeholders.createEl('summary', {
|
|
text: `${tally.placeholderResults.length} placeholder note${
|
|
tally.placeholderResults.length === 1 ? '' : 's'
|
|
} written, Plaud has not processed these yet`,
|
|
});
|
|
placeholders.createEl('p', {
|
|
text: 'These recordings have no transcript or summary available. A common cause is audio with no detectable speech; another is that processing has not finished. A placeholder note with a link was written for each, so you can re-run the import later if content becomes available.',
|
|
cls: 'plaud-importer-placeholders-note',
|
|
});
|
|
const list = placeholders.createEl('ul');
|
|
for (const r of tally.placeholderResults) {
|
|
if (r.kind !== 'placeholder-written') {
|
|
continue;
|
|
}
|
|
const li = list.createEl('li');
|
|
li.createEl('strong', { text: r.recording.title });
|
|
li.createSpan({
|
|
text: ` (${r.recording.id})`,
|
|
cls: 'plaud-importer-failure-id',
|
|
});
|
|
li.createEl('br');
|
|
li.createSpan({ text: r.reason });
|
|
}
|
|
}
|
|
|
|
const buttonRow = contentEl.createDiv({ cls: 'plaud-importer-buttons' });
|
|
const closeButton = buttonRow.createEl('button', {
|
|
text: 'Done',
|
|
cls: 'mod-cta',
|
|
});
|
|
closeButton.addEventListener('click', () => this.close());
|
|
|
|
this.maybeStartAutoClose(tally, buttonRow, closeButton);
|
|
}
|
|
|
|
/**
|
|
* Start the summary auto-close countdown when the setting allows it.
|
|
* Only a fully successful batch auto-closes: any failure keeps the
|
|
* summary open so the error list stays visible. Any click inside the
|
|
* modal cancels the countdown, on the assumption the user started
|
|
* reading or copying something.
|
|
*/
|
|
private maybeStartAutoClose(
|
|
tally: ImportTally,
|
|
buttonRow: HTMLElement,
|
|
closeButton: HTMLElement,
|
|
): void {
|
|
const enabled = this.noteWriterOptions.autoCloseSummary ?? true;
|
|
// Keep the summary open on any failure OR when placeholders were written.
|
|
// Both are things the user should see and act on (re-import later),
|
|
// not auto-dismiss.
|
|
if (!enabled || tally.failed > 0 || tally.placeholdersWritten > 0) {
|
|
return;
|
|
}
|
|
const configured = this.noteWriterOptions.autoCloseSummarySeconds ?? 20;
|
|
let remaining = Number.isFinite(configured)
|
|
? Math.max(1, Math.floor(configured))
|
|
: 20;
|
|
|
|
const countdownEl = buttonRow.createSpan({
|
|
cls: 'plaud-importer-autoclose',
|
|
text: `Auto-closes in ${remaining}s`,
|
|
});
|
|
// Capture phase so the cancel fires before any button's own click
|
|
// handler. Clicking Done is not a cancel, it is the close itself:
|
|
// stop the timer silently and let the button's handler close the
|
|
// modal without flashing the cancelled text first.
|
|
this.contentEl.addEventListener(
|
|
'pointerdown',
|
|
(event) => {
|
|
if (this.autoCloseTimer === null) {
|
|
return;
|
|
}
|
|
this.cancelAutoClose();
|
|
if (!(event.target instanceof Node) || !closeButton.contains(event.target)) {
|
|
countdownEl.setText('Auto-close cancelled');
|
|
}
|
|
},
|
|
{ capture: true },
|
|
);
|
|
this.autoCloseTimer = window.setInterval(() => {
|
|
remaining -= 1;
|
|
if (remaining <= 0) {
|
|
this.close();
|
|
return;
|
|
}
|
|
countdownEl.setText(`Auto-closes in ${remaining}s`);
|
|
}, 1000);
|
|
}
|
|
|
|
private cancelAutoClose(): void {
|
|
if (this.autoCloseTimer !== null) {
|
|
window.clearInterval(this.autoCloseTimer);
|
|
this.autoCloseTimer = null;
|
|
}
|
|
}
|
|
}
|