Phase 2: optional background auto-sync (0.18.0) (#22)

* Phase 2 step 1: thread version_ms + wait_pull through the client, add sortBy filter

Add sortBy?: 'start_time'|'edit_time' to RecordingFilter (query builder now uses filter.sortBy, default unchanged at start_time). Carry version_ms -> Recording.versionMs (the auto-sync change cursor) and wait_pull===1 -> Recording.waitPull through RawRecording and parseRecording; version_ms is only trusted when finite. Tests: edit_time sort in the query; version_ms/wait_pull mapping and absent-field defaults.

* Phase 2 step 2: emit plaud-version-ms in frontmatter, read it into the vault index

formatFrontmatter writes plaud-version-ms (raw number) from recording.versionMs when present. buildPlaudIdIndex reads it into ImportedRecord.versionMs via a new pickFrontmatterNumber (accepts number or numeric string, rejects non-finite so a malformed marker stays undefined = treated as current). Tests: index reads number/string/absent/malformed; frontmatter emits/omits the line.

* Phase 2 step 3: auto-sync.ts pure logic (candidate selection + state machine)

classifyRecording (new/changed/up-to-date-boundary/up-to-date-current/skipped-wait-pull) and selectAutoSyncCandidates (edit-time-desc page, stop at boundary, migration = missing marker never flagged changed, trash + wait_pull filtered). nextAutoSyncState reducer (auth pauses, transient counts, ok resumes), tickOutcomeForCategory (single-sources categoryAllowsReauth), coerceIntervalMinutes (floor 15 / cap 1440 / default 60). 20 unit tests, Obsidian-free.

* Phase 2 steps 4-5: auto-sync scheduler, settings, gate, auth pause/resume

auto-sync-runner.ts: testable tick (page edit_time list, stop at boundary, caps) with injected I/O (6 tests). main.ts: autoSyncEnabled + autoSyncIntervalMinutes settings in BOTH paths with the overwrite warning; reconcileAutoSync (registerInterval + own handle, deferred first tick, onunload cleanup); runAutoSyncTickSafe (never throws, cold-cache guard, single-flight importGate); headless importAutoSyncCandidates (skip-for-new / overwrite-for-changed, shared buildImportRuntimeOptions); auth state machine pauses on token-rejected, resumes on token re-save / Test connection / toggle. ImportModalOptions.onClosed releases the gate from the modal's onClose.

* Phase 2 steps 6-7: backfill command + 'update available' badge

Backfill command writes plaud-version-ms into legacy notes (frontmatter-only via processFrontMatter, no body rewrite; skips notes that already have a marker) so old notes become edit-detectable. Import dialog shows an 'Update available' badge on rows whose listed version_ms exceeds the stored marker (isUpdateAvailable, tested); cleared after a re-import. Manual-import cue, moot when auto-sync is on.

* Phase 2: release 0.18.0 (optional background auto-sync)

Version bump + CHANGELOG for the auto-sync feature (issue #5): scheduled background import of new + changed recordings, off by default with an overwrite warning, backfill command, and an update-available badge.

* Address Copilot review of PR #22: gate races + redundant index rebuild

1. Split the single import gate into two independent flags (importModalOpen, autoSyncTickInFlight) so the modal's open/close never clobbers a tick's in-flight state; a tick starts only when both are clear. 2. In the modal's onClose, set aborted=true before releasing the gate so a tick cannot start while the loop unwinds. 3. importAutoSyncCandidates now reuses the tick's already-built (and cold-cache-guarded) index instead of rebuilding, so classification and the writer's dedup share one snapshot.

* Address Copilot re-review of PR #22: folder normalization + write counting

1. Cold-cache guard now uses a shared outputFolderHasMarkdown() in vault-index that reuses the index's own normalizeFolder+fileIsUnder (Windows backslashes included), so the guard and the index can never disagree about which folder is meant. 2. Auto-sync notice counts only real writes (writeOutcome.status !== 'skipped'), so a duplicate-policy skip no longer inflates the imported/updated numbers. Tests for outputFolderHasMarkdown.

* Address Copilot 3rd review of PR #22: cold-cache guard + gate lifecycle

1. Replace the fragile 'folder has any markdown' cold-cache guard with a precise outputFolderCacheIsCold (any note under the folder with getFileCache===null); it no longer mis-fires for a root/shared folder and cannot permanently disable sync once the cache warms. 2. Wrap the modal open in try/catch so importModalOpen is released if construction throws (never sticks). 3. Backfill now participates in the single-flight gate and releases it in finally, so it cannot overlap a tick and never leaves the gate stuck. Tests updated for outputFolderCacheIsCold.

* Address Copilot 4th review of PR #22: backfill cold-cache guard + doc precision

1. Backfill now checks outputFolderCacheIsCold and asks the user to retry rather than building a partial index and silently reporting 'backfilled 0'. 2. Corrected the outputFolderCacheIsCold JSDoc to match actual behavior (all in-scope notes count, which is the correct warmth signal; root folder means the whole vault). 3. Documented why token_rejected is the correct reason on a mid-import auth-failed stop (the list already succeeded with a token).

* Fix three Copilot findings on auto-sync (write count, onClose guard, cold-cache single pass)

1. main.ts runBatch counted only 'written' non-skipped results, ignoring 'placeholder-written'. A tick that wrote only placeholder stubs reported 0 and showed no notice. Now counts placeholder writes too, excluding 'kept-existing' (a real note already existed, nothing written) on the same principle as a 'skipped' write.

2. import-modal.ts onClose called the injected onClosed callback unguarded; a throw would abort the rest of teardown and leak the shared gate. Wrapped in try/catch (log and continue). aborted=true stays before it.

3. vault-index.ts outputFolderCacheIsCold did filter+some (two passes, allocation) every tick, expensive at a root output folder. Replaced with a single-pass loop, identical semantics.

* Address 3 Copilot findings: interval leak, double vault scan, backfill cap

1. reconcileAutoSync used this.registerInterval(setInterval(...)) while also tracking and clearing the id itself; every reschedule left a stale id on the component cleanup list. Use plain window.setInterval (already cleared in reconcile and onunload).

2. Each tick scanned getMarkdownFiles twice (outputFolderCacheIsCold then buildPlaudIdIndex). New buildPlaudIdIndexWithColdCheck fuses both into one pass, early-returning isCold on the first unparsed note. +4 unit tests.

3. Backfill capped at 500 pages but reported 'backfilled N' as complete on cap-hit. Track reachedListEnd; the notice now says the scan stopped early and to run backfill again.

* Address 2 CodeRabbit + 2 Copilot findings: modal gate, re-auth resume, resume-timeout tracking, backfill message

CodeRabbit (both Major): (1) launchImportModal now refuses to open while importModalOpen or autoSyncTickInFlight is held, so a second modal or a modal over an in-flight tick/backfill cannot clobber the shared gate. (2) In-modal re-auth (onReauth and pasteToken) now clears the auth pause via resumeAutoSyncIfPaused on success, so background sync resumes without waiting for the settings tab.

Copilot: (1) resumeAutoSyncIfPaused scheduled an untracked setTimeout; it now uses the tracked autoSyncFirstRunTimeoutId slot, cleared by reconcileAutoSync and onunload. (2) Backfill cap notice no longer tells the user to 're-run to continue' (the scan restarts from the newest page, so it cannot progress past the cap); it now states plainly that the least recently updated recordings were not checked.

* Word auto-sync auth-pause notice by category (missing vs expired token)

The 'auth' tick outcome covers both token-rejected and not-configured, but the pause Notice hardcoded 'the session expired'. A user who never configured a token would be told it expired. Branch the message on classification.category: not-configured says no token is configured, otherwise the session expired.

* Close single-flight race: defer import gate release until in-flight loop settles

onClose set aborted=true then released the shared import gate (onClosed) synchronously. aborted is only checked between recordings, so the current write can still be finishing after onClose returns; with the gate already released, a background auto-sync tick could start and write concurrently, defeating single-flight. Now track the active import loop (activeImportRun) and defer the gate release until it settles; release immediately when no import is running. Reported by Copilot on import-modal.ts onClose.

* Move runImportBatch JSDoc back above its method (displaced by trackImportRun)

The trackImportRun insertion left the 'Run the import loop...' JSDoc stranded above trackImportRun instead of runImportBatch, so IDE hover documented the wrong method. Reordered so each JSDoc sits above its own method. Reported by Copilot.

* Claim auto-sync single-flight gate before the index scan, not after

runAutoSyncTickSafe set autoSyncTickInFlight only after buildPlaudIdIndexWithColdCheck, but the gate check runs before the scan. On a large vault the scan takes time, and a manual import modal opened mid-scan checks the same flag (still false), so it could slip past the gate and overlap the tick's import. Move the flag assignment to immediately after the gate check and wrap the scan + cold-cache skip + tick in one try/finally so the flag is always cleared. Reported by Copilot.

* Close remaining single-flight holes from concurrency self-audit

Proactive audit of the whole gate model (not one-per-review-round) surfaced three items; fixed the two reachable ones.

HIGH: 'Review artifacts first' stays clickable during a default import (its disabled state ignores the in-flight run), so clicking it mid-import started a second runImportBatch; trackImportRun then overwrote activeImportRun, orphaning the first loop and letting two writers hit the same notes concurrently (and, after close, letting a tick start while the orphan still wrote). Guard onImportClick and beginCustomizationFlow on activeImportRun so a second import can never start.

MEDIUM: onunload cleared timers and the client but did not stop an in-flight tick/backfill, which had captured the client locally; on disable/re-enable the old instance kept writing while the new instance started a tick (per-instance flags do not coordinate across instances). Add a disposed flag set in onunload; the tick's runImport now polls shouldAbort=()=>disposed and the backfill loop checks disposed, so both stop between iterations.

LOW (dismissed): a genuinely non-settling network promise would leave the deferred gate release pending and block future sync. requestUrl-based calls settle (resolve or reject); a watchdog that force-released the gate mid-write would reintroduce the concurrent-write risk just closed. Not worth the complexity.

Also: trackImportRun now declares 'tracked' with let and assigns after, so the finally closure does not self-reference its own initializer (Copilot).

* coerceIntervalMinutes: treat null/empty/whitespace as default, not zero-floored

Number(null), Number(''), Number('  ') all yield 0, which clamped to the 15-minute floor. A corrupt or absent autoSyncIntervalMinutes setting should fall back to the 60-minute default, not silently become the most frequent interval. Only a real number or a non-empty numeric string now goes through Number(); everything else maps to the default. A genuine 0 or '0' still floors to 15 (explicit too-low value). +1 test. Reported by Copilot.
This commit is contained in:
Charles Kelsoe 2026-07-02 12:46:39 -04:00 committed by GitHub
parent 4ebda53353
commit 3f4f87c4e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1689 additions and 61 deletions

View file

@ -4,6 +4,14 @@ All notable changes to Plaud Importer will be documented in this file.
## [Unreleased]
## [0.18.0] - 2026-07-01
### Added
- Optional background auto-sync (off by default). When enabled, the plugin checks Plaud on a schedule (15 minutes to once a day) and imports new recordings automatically using your default import options. It also re-imports a recording you changed in Plaud, such as corrected speaker names or an edited transcript, including old recordings edited today. **A re-import overwrites that note and its downloaded artifacts with Plaud's current version, so edits you made to a synced note are replaced.** Only recordings that actually changed are touched; unchanged notes are never modified. The background job pauses with one notice if your Plaud session expires and resumes when you reconnect (test connection, re-import, re-enter your token, or toggle it off and on). Desktop only.
- **Backfill version markers for auto-sync** command. Notes imported before this release do not carry the marker auto-sync compares against, so their edits are not detected until the marker exists. Run this once after enabling auto-sync to make your existing library edit-detectable. It only adds a frontmatter marker and never rewrites note content.
- The import window now shows an **Update available** badge next to already-imported recordings that changed in Plaud since you imported them, so you can spot and re-import a stale note by hand.
## [0.17.1] - 2026-07-01
### Fixed

View file

@ -0,0 +1,158 @@
import type { PlaudRecordingId, Recording } from '../plaud-client';
import type { ImportedRecord } from '../vault-index';
import { runAutoSyncTick, type AutoSyncTickDeps } from '../auto-sync-runner';
function rec(id: string, versionMs: number): Recording {
return {
id: id as PlaudRecordingId,
title: `t-${id}`,
createdAt: new Date('2026-06-01T00:00:00Z'),
durationSeconds: 60,
transcriptAvailable: true,
summaryAvailable: true,
isTrashed: false,
versionMs,
};
}
function idx(
entries: ReadonlyArray<readonly [string, number | undefined]>,
): Map<PlaudRecordingId, ImportedRecord> {
return new Map(
entries.map(([id, v]) => [
id as PlaudRecordingId,
{ path: `Plaud/${id}.md`, versionMs: v },
]),
);
}
function deps(
over: Partial<AutoSyncTickDeps> & { pages: readonly Recording[][] } & {
index?: Map<PlaudRecordingId, ImportedRecord>;
},
): {
deps: AutoSyncTickDeps;
importCalls: Array<{ newIds: string[]; changedIds: string[] }>;
listSkips: number[];
} {
const importCalls: Array<{ newIds: string[]; changedIds: string[] }> = [];
const listSkips: number[] = [];
const pages = over.pages;
const d: AutoSyncTickDeps = {
pageSize: over.pageSize ?? 10,
maxImportsPerTick: over.maxImportsPerTick ?? 100,
maxPagesPerTick: over.maxPagesPerTick ?? 10,
listPage: async (skip) => {
listSkips.push(skip);
const pageIndex = Math.floor(skip / (over.pageSize ?? 10));
return pages[pageIndex] ?? [];
},
buildIndex: () => over.index ?? new Map(),
importCandidates: async (newRecs, changedRecs) => {
importCalls.push({
newIds: newRecs.map((r) => r.id),
changedIds: changedRecs.map((r) => r.id),
});
return { imported: newRecs.length, updated: changedRecs.length };
},
};
return { deps: d, importCalls, listSkips };
}
describe('runAutoSyncTick', () => {
it('stops at the up-to-date boundary in a single page and splits new/changed', async () => {
const index = idx([
['changed', 500],
['boundary', 900],
]);
const { deps: d, importCalls } = deps({
index,
pageSize: 10,
pages: [
[
rec('new1', 1000),
rec('changed', 600), // > stored 500 -> changed
rec('boundary', 900), // == stored -> STOP
rec('never', 1),
],
],
});
const result = await runAutoSyncTick(d);
expect(result.reachedUpToDate).toBe(true);
expect(result.pagesScanned).toBe(1);
expect(importCalls).toEqual([{ newIds: ['new1'], changedIds: ['changed'] }]);
expect(result.imported).toBe(1);
expect(result.updated).toBe(1);
});
it('pages across multiple pages until the boundary', async () => {
const index = idx([['stop', 100]]);
const { deps: d, listSkips } = deps({
index,
pageSize: 2,
pages: [
[rec('a', 900), rec('b', 800)], // full page, keep going
[rec('c', 700), rec('stop', 100)], // boundary at 'stop'
[rec('should-not-load', 1)],
],
});
const result = await runAutoSyncTick(d);
expect(result.reachedUpToDate).toBe(true);
expect(result.pagesScanned).toBe(2);
expect(listSkips).toEqual([0, 2]); // never asked for page 3
});
it('caps by maxPagesPerTick on a cold index (nothing up to date)', async () => {
const { deps: d } = deps({
index: new Map(),
pageSize: 2,
maxPagesPerTick: 2,
pages: [
[rec('a', 9), rec('b', 8)],
[rec('c', 7), rec('d', 6)],
[rec('e', 5), rec('f', 4)],
],
});
const result = await runAutoSyncTick(d);
expect(result.cappedByPages).toBe(true);
expect(result.pagesScanned).toBe(2);
});
it('caps by maxImportsPerTick', async () => {
const { deps: d, importCalls } = deps({
index: new Map(),
pageSize: 10,
maxImportsPerTick: 3,
pages: [[rec('a', 9), rec('b', 8), rec('c', 7), rec('d', 6), rec('e', 5)]],
});
const result = await runAutoSyncTick(d);
expect(result.cappedByImports).toBe(true);
// only the first 3 accumulated
expect(importCalls[0].newIds).toEqual(['a', 'b', 'c']);
});
it('does not call importCandidates when there is nothing to do', async () => {
const index = idx([['a', 900]]);
const { deps: d, importCalls } = deps({
index,
pageSize: 10,
pages: [[rec('a', 900)]], // == stored -> boundary, no candidates
});
const result = await runAutoSyncTick(d);
expect(importCalls).toEqual([]);
expect(result.imported).toBe(0);
expect(result.updated).toBe(0);
expect(result.reachedUpToDate).toBe(true);
});
it('stops on a short page (remote exhausted) without a boundary', async () => {
const { deps: d } = deps({
index: new Map(),
pageSize: 10,
pages: [[rec('a', 9), rec('b', 8)]], // 2 < pageSize 10 -> exhausted
});
const result = await runAutoSyncTick(d);
expect(result.reachedUpToDate).toBe(false);
expect(result.pagesScanned).toBe(1);
});
});

215
__tests__/auto-sync.test.ts Normal file
View file

@ -0,0 +1,215 @@
import type { PlaudRecordingId, Recording } from '../plaud-client';
import type { ImportedRecord } from '../vault-index';
import {
classifyRecording,
selectAutoSyncCandidates,
nextAutoSyncState,
tickOutcomeForCategory,
coerceIntervalMinutes,
isUpdateAvailable,
INITIAL_AUTO_SYNC_STATE,
} from '../auto-sync';
function rec(overrides: Partial<Omit<Recording, 'id'>> & { id: string }): Recording {
return {
id: overrides.id as PlaudRecordingId,
title: overrides.title ?? `title-${overrides.id}`,
createdAt: overrides.createdAt ?? new Date('2026-06-01T00:00:00Z'),
durationSeconds: overrides.durationSeconds ?? 60,
transcriptAvailable: overrides.transcriptAvailable ?? true,
summaryAvailable: overrides.summaryAvailable ?? true,
isTrashed: overrides.isTrashed ?? false,
folderId: overrides.folderId,
tags: overrides.tags,
versionMs: overrides.versionMs,
waitPull: overrides.waitPull,
};
}
function idx(
entries: ReadonlyArray<readonly [string, ImportedRecord]>,
): Map<PlaudRecordingId, ImportedRecord> {
return new Map(entries.map(([id, r]) => [id as PlaudRecordingId, r]));
}
describe('classifyRecording', () => {
it('classifies a recording not in the index as new', () => {
expect(classifyRecording(rec({ id: 'a', versionMs: 100 }), idx([]))).toBe('new');
});
it('classifies a listed version_ms greater than stored as changed', () => {
const index = idx([['a', { path: 'p', versionMs: 100 }]]);
expect(classifyRecording(rec({ id: 'a', versionMs: 200 }), index)).toBe('changed');
});
it('classifies listed <= stored (with a marker) as the up-to-date boundary', () => {
const index = idx([['a', { path: 'p', versionMs: 200 }]]);
expect(classifyRecording(rec({ id: 'a', versionMs: 200 }), index)).toBe(
'up-to-date-boundary',
);
expect(classifyRecording(rec({ id: 'a', versionMs: 150 }), index)).toBe(
'up-to-date-boundary',
);
});
it('treats a missing stored marker as current, not changed (migration)', () => {
const index = idx([['a', { path: 'p' }]]); // no versionMs
expect(classifyRecording(rec({ id: 'a', versionMs: 999 }), index)).toBe(
'up-to-date-current',
);
});
it('treats a missing listed version_ms as current (cannot compare)', () => {
const index = idx([['a', { path: 'p', versionMs: 100 }]]);
expect(classifyRecording(rec({ id: 'a', versionMs: undefined }), index)).toBe(
'up-to-date-current',
);
});
it('skips a recording still syncing from the device (wait_pull)', () => {
expect(
classifyRecording(rec({ id: 'a', versionMs: 999, waitPull: true }), idx([])),
).toBe('skipped-wait-pull');
});
});
describe('selectAutoSyncCandidates', () => {
it('collects new and changed, and stops at the first up-to-date boundary', () => {
// edit-time-descending page: a changed-old recording sits ABOVE the
// boundary and must still be caught; everything after the boundary is
// skipped without classification.
const index = idx([
['changed-old', { path: 'c', versionMs: 100 }],
['boundary', { path: 'b', versionMs: 500 }],
['below', { path: 'x', versionMs: 10 }],
]);
const page = [
rec({ id: 'brand-new', versionMs: 900 }),
rec({ id: 'changed-old', versionMs: 800 }),
rec({ id: 'boundary', versionMs: 500 }), // == stored -> boundary, STOP
rec({ id: 'below', versionMs: 5 }), // never examined
rec({ id: 'also-new', versionMs: 1 }), // never examined
];
const { candidates, reachedUpToDate } = selectAutoSyncCandidates(page, index);
expect(reachedUpToDate).toBe(true);
expect(candidates.map((c) => `${c.recording.id}:${c.kind}`)).toEqual([
'brand-new:new',
'changed-old:changed',
]);
});
it('does not stop on a missing-marker note and never flags it changed', () => {
const index = idx([['legacy', { path: 'l' }]]); // no versionMs
const page = [
rec({ id: 'legacy', versionMs: 999 }), // migration: skip, do NOT stop
rec({ id: 'new-below', versionMs: 5 }), // must still be reached
];
const { candidates, reachedUpToDate } = selectAutoSyncCandidates(page, index);
expect(reachedUpToDate).toBe(false);
expect(candidates.map((c) => c.recording.id)).toEqual(['new-below']);
});
it('never imports trashed recordings', () => {
const page = [
rec({ id: 'trash', versionMs: 900, isTrashed: true }),
rec({ id: 'keep', versionMs: 800 }),
];
const { candidates } = selectAutoSyncCandidates(page, idx([]));
expect(candidates.map((c) => c.recording.id)).toEqual(['keep']);
});
it('skips wait_pull recordings but keeps scanning', () => {
const page = [
rec({ id: 'pending', versionMs: 900, waitPull: true }),
rec({ id: 'ready', versionMs: 800 }),
];
const { candidates, reachedUpToDate } = selectAutoSyncCandidates(page, idx([]));
expect(reachedUpToDate).toBe(false);
expect(candidates.map((c) => c.recording.id)).toEqual(['ready']);
});
});
describe('nextAutoSyncState', () => {
it('pauses on an auth outcome', () => {
expect(nextAutoSyncState(INITIAL_AUTO_SYNC_STATE, 'auth')).toEqual({
paused: true,
consecutiveTransientFailures: 0,
});
});
it('counts transient failures without pausing', () => {
const s1 = nextAutoSyncState(INITIAL_AUTO_SYNC_STATE, 'transient');
expect(s1).toEqual({ paused: false, consecutiveTransientFailures: 1 });
expect(nextAutoSyncState(s1, 'transient').consecutiveTransientFailures).toBe(2);
});
it('a transient failure while paused stays paused', () => {
const paused = { paused: true, consecutiveTransientFailures: 0 };
expect(nextAutoSyncState(paused, 'transient').paused).toBe(true);
});
it('ok resumes and resets the counter', () => {
const paused = { paused: true, consecutiveTransientFailures: 3 };
expect(nextAutoSyncState(paused, 'ok')).toEqual(INITIAL_AUTO_SYNC_STATE);
});
});
describe('tickOutcomeForCategory', () => {
it('maps auth categories to auth (pause)', () => {
expect(tickOutcomeForCategory('token-rejected')).toBe('auth');
expect(tickOutcomeForCategory('not-configured')).toBe('auth');
});
it('maps everything else to transient', () => {
for (const c of ['rate-limited', 'server-error', 'network-error', 'parse-error', 'api-error'] as const) {
expect(tickOutcomeForCategory(c)).toBe('transient');
}
});
});
describe('isUpdateAvailable', () => {
it('is true only when both versions are known and listed > stored', () => {
expect(isUpdateAvailable(200, 100)).toBe(true);
expect(isUpdateAvailable(100, 100)).toBe(false);
expect(isUpdateAvailable(50, 100)).toBe(false);
});
it('is false when either version is missing (legacy note or omitted)', () => {
expect(isUpdateAvailable(200, undefined)).toBe(false);
expect(isUpdateAvailable(undefined, 100)).toBe(false);
expect(isUpdateAvailable(undefined, undefined)).toBe(false);
});
});
describe('coerceIntervalMinutes', () => {
it('floors below 15 to 15', () => {
expect(coerceIntervalMinutes('5')).toBe(15);
expect(coerceIntervalMinutes(0)).toBe(15);
expect(coerceIntervalMinutes(-100)).toBe(15);
});
it('caps above 1440 to 1440', () => {
expect(coerceIntervalMinutes(99999)).toBe(1440);
});
it('passes through a valid preset (string or number)', () => {
expect(coerceIntervalMinutes('60')).toBe(60);
expect(coerceIntervalMinutes(240)).toBe(240);
});
it('falls back to 60 for non-numeric input', () => {
expect(coerceIntervalMinutes('abc')).toBe(60);
expect(coerceIntervalMinutes(undefined)).toBe(60);
expect(coerceIntervalMinutes(NaN)).toBe(60);
});
it('treats null/empty/whitespace as absent (60), not as 0 floored to 15', () => {
expect(coerceIntervalMinutes(null)).toBe(60);
expect(coerceIntervalMinutes('')).toBe(60);
expect(coerceIntervalMinutes(' ')).toBe(60);
// A genuine zero (number or string) is still an explicit too-low value
// and floors to 15, not the default.
expect(coerceIntervalMinutes(0)).toBe(15);
expect(coerceIntervalMinutes('0')).toBe(15);
});
});

View file

@ -503,6 +503,16 @@ describe('formatFrontmatter', () => {
expect(fm).not.toMatch(/tags:/);
});
it('emits plaud-version-ms as a raw number when versionMs is present (auto-sync cursor)', () => {
const fm = formatFrontmatter(makeRecording({ versionMs: 1782918853105 }), []);
expect(fm).toContain('plaud-version-ms: 1782918853105');
});
it('omits plaud-version-ms when versionMs is absent', () => {
const fm = formatFrontmatter(makeRecording({ versionMs: undefined }), []);
expect(fm).not.toMatch(/plaud-version-ms:/);
});
it('includes tags when recording.tags has values', () => {
const fm = formatFrontmatter(
makeRecording({ tags: ['meeting', 'q2'] }),

View file

@ -219,6 +219,30 @@ describe('listRecordings happy path', () => {
expect(r.isTrashed).toBe(false);
});
it('maps version_ms to versionMs and wait_pull===1 to waitPull (the auto-sync fields)', async () => {
const { fetcher } = captureFetcher(
ok(listEnvelope([record({ version_ms: 1744628400123, wait_pull: 1 })])),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
const [r] = await client.listRecordings();
expect(r.versionMs).toBe(1744628400123);
expect(r.waitPull).toBe(true);
});
it('leaves versionMs undefined and waitPull false when the fields are absent or non-1', async () => {
const { fetcher } = captureFetcher(
ok(listEnvelope([record({ version_ms: undefined, wait_pull: 0 })])),
);
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
const [r] = await client.listRecordings();
expect(r.versionMs).toBeUndefined();
expect(r.waitPull).toBe(false);
});
});
// listRecordings — request shape --------------------------------------------
@ -332,6 +356,17 @@ describe('listRecordings request shape', () => {
expect(url.searchParams.get('skip')).toBe('20');
expect(url.searchParams.get('limit')).toBe('10');
});
it('sends sort_by=edit_time when the filter requests it (auto-sync cursor)', async () => {
const { fetcher, lastRequest } = captureFetcher(ok(listEnvelope([])));
const client = new ReverseEngineeredPlaudClient(() => 'tok', fetcher);
await client.listRecordings({ sortBy: 'edit_time' });
const url = new URL(lastRequest()?.url ?? '');
expect(url.searchParams.get('sort_by')).toBe('edit_time');
// default stays start_time when unspecified (covered above)
});
});
// listRecordings — regional endpoint auto-detection -------------------------

View file

@ -5,7 +5,11 @@
import type { App, TFile } from 'obsidian';
import type { PlaudRecordingId } from '../plaud-client';
import { buildPlaudIdIndex } from '../vault-index';
import {
buildPlaudIdIndex,
buildPlaudIdIndexWithColdCheck,
outputFolderCacheIsCold,
} from '../vault-index';
interface FakeFile {
readonly path: string;
@ -82,6 +86,20 @@ describe('buildPlaudIdIndex', () => {
expect(buildPlaudIdIndex(app, '/Plaud/').size).toBe(1);
});
it('reads plaud-version-ms into versionMs; absent or malformed stays undefined', () => {
const app = makeApp([
['Plaud/num.md', { 'plaud-id': 'rec-num', 'plaud-version-ms': 1782918853105 }],
['Plaud/str.md', { 'plaud-id': 'rec-str', 'plaud-version-ms': '1744628400000' }],
['Plaud/none.md', { 'plaud-id': 'rec-none' }],
['Plaud/bad.md', { 'plaud-id': 'rec-bad', 'plaud-version-ms': 'not-a-number' }],
]);
const index = buildPlaudIdIndex(app, 'Plaud');
expect(index.get('rec-num' as PlaudRecordingId)?.versionMs).toBe(1782918853105);
expect(index.get('rec-str' as PlaudRecordingId)?.versionMs).toBe(1744628400000);
expect(index.get('rec-none' as PlaudRecordingId)?.versionMs).toBeUndefined();
expect(index.get('rec-bad' as PlaudRecordingId)?.versionMs).toBeUndefined();
});
it('normalizes Windows-style backslash outputFolder before matching', () => {
// Regression for #7: a "\Inbox" setting must match notes Obsidian
// stored under "Inbox", so the imported badge and duplicate detection
@ -170,3 +188,78 @@ describe('buildPlaudIdIndex', () => {
expect(index.size).toBe(0);
});
});
describe('outputFolderCacheIsCold', () => {
// makeApp's getFileCache returns null when the entry's frontmatter is null,
// modelling a not-yet-parsed (cold) note.
it('is cold when a note under the folder has no parsed cache yet', () => {
const app = makeApp([['Plaud/cold.md', null]]);
expect(outputFolderCacheIsCold(app, 'Plaud')).toBe(true);
});
it('is NOT cold when every note under the folder is parsed (even empty frontmatter)', () => {
const app = makeApp([
['Plaud/a.md', { 'plaud-id': 'rec-a' }],
['Plaud/b.md', {}],
]);
expect(outputFolderCacheIsCold(app, 'Plaud')).toBe(false);
});
it('is NOT cold when the folder has no notes (nothing to be cold about)', () => {
expect(outputFolderCacheIsCold(makeApp([]), 'Plaud')).toBe(false);
// a cold note OUTSIDE the folder does not make the folder cold
expect(outputFolderCacheIsCold(makeApp([['Other/x.md', null]]), 'Plaud')).toBe(false);
});
it('normalizes Windows backslashes like the index does', () => {
const app = makeApp([['Inbox/cold.md', null]]);
expect(outputFolderCacheIsCold(app, '\\Inbox')).toBe(true);
});
});
describe('buildPlaudIdIndexWithColdCheck', () => {
// The fused helper must match "outputFolderCacheIsCold then buildPlaudIdIndex"
// exactly, at one pass instead of two.
it('is cold (and yields no index) when a note under the folder is unparsed', () => {
const app = makeApp([
['Plaud/a.md', { 'plaud-id': 'rec-a' }],
['Plaud/cold.md', null],
]);
const state = buildPlaudIdIndexWithColdCheck(app, 'Plaud');
expect(state.isCold).toBe(true);
expect(outputFolderCacheIsCold(app, 'Plaud')).toBe(true);
});
it('is warm and returns the same index as buildPlaudIdIndex', () => {
const app = makeApp([
['Plaud/a.md', { 'plaud-id': 'rec-a', 'plaud-version-ms': 111 }],
['Plaud/sub/b.md', { 'plaud-id': 'rec-b' }],
['Other/outside.md', { 'plaud-id': 'rec-outside' }],
]);
const state = buildPlaudIdIndexWithColdCheck(app, 'Plaud');
expect(state.isCold).toBe(false);
if (state.isCold) return; // narrow the union for the assertions below
const reference = buildPlaudIdIndex(app, 'Plaud');
expect([...state.index.entries()]).toEqual([...reference.entries()]);
expect(state.index.get('rec-a' as PlaudRecordingId)?.versionMs).toBe(111);
expect(state.index.has('rec-outside' as PlaudRecordingId)).toBe(false);
});
it('is warm with an empty index when the folder has no notes', () => {
const state = buildPlaudIdIndexWithColdCheck(makeApp([]), 'Plaud');
expect(state.isCold).toBe(false);
if (state.isCold) return;
expect(state.index.size).toBe(0);
});
it('a cold note OUTSIDE the folder does not make it cold', () => {
const app = makeApp([
['Plaud/a.md', { 'plaud-id': 'rec-a' }],
['Other/cold.md', null],
]);
const state = buildPlaudIdIndexWithColdCheck(app, 'Plaud');
expect(state.isCold).toBe(false);
if (state.isCold) return;
expect(state.index.has('rec-a' as PlaudRecordingId)).toBe(true);
});
});

129
auto-sync-runner.ts Normal file
View file

@ -0,0 +1,129 @@
// -----------------------------------------------------------------------------
// auto-sync-runner.ts
//
// The auto-sync TICK: page the edit-time-descending list, accumulate the new
// and changed candidates (stopping at the up-to-date boundary or the caps),
// then hand them to the caller's importer. Orchestration only, with every I/O
// concern injected, so the paging / stop / cap logic is unit-testable with fake
// deps and no Obsidian or network. The pure per-recording classification lives
// in auto-sync.ts; the writer/attachment/network plumbing lives in main.ts.
// -----------------------------------------------------------------------------
import type { PlaudRecordingId, Recording } from './plaud-client';
import type { ImportedRecord } from './vault-index';
import { selectAutoSyncCandidates } from './auto-sync';
export interface AutoSyncTickDeps {
/** One page of the list, edit-time descending (sort_by=edit_time). */
listPage(skip: number, limit: number): Promise<readonly Recording[]>;
/** Fresh vault index (plaud-id -> {path, versionMs}) for this tick. */
buildIndex(): Map<PlaudRecordingId, ImportedRecord>;
/**
* Import the selected recordings. `newRecs` are created (skip-for-new),
* `changedRecs` overwrite the matched note. Returns how many of each landed.
*/
importCandidates(
newRecs: readonly Recording[],
changedRecs: readonly Recording[],
): Promise<{ imported: number; updated: number }>;
readonly pageSize: number;
/** Max candidates to import in one tick (bounds a large first run). */
readonly maxImportsPerTick: number;
/** Max pages to scan in one tick (bounds an empty-index first run). */
readonly maxPagesPerTick: number;
log?(message: string, payload?: unknown): void;
}
export interface AutoSyncTickResult {
readonly imported: number;
readonly updated: number;
readonly pagesScanned: number;
readonly reachedUpToDate: boolean;
readonly cappedByImports: boolean;
readonly cappedByPages: boolean;
}
/**
* Run one tick. Never throws for a benign empty result; propagates a thrown
* listPage/importCandidates error to the caller so the scheduler's state
* machine can classify it (auth pause vs transient).
*/
export async function runAutoSyncTick(deps: AutoSyncTickDeps): Promise<AutoSyncTickResult> {
const index = deps.buildIndex();
const newRecs: Recording[] = [];
const changedRecs: Recording[] = [];
let skip = 0;
let pagesScanned = 0;
let reachedUpToDate = false;
let cappedByImports = false;
let cappedByPages = false;
while (true) {
if (pagesScanned >= deps.maxPagesPerTick) {
cappedByPages = true;
break;
}
const page = await deps.listPage(skip, deps.pageSize);
pagesScanned += 1;
if (page.length === 0) {
break;
}
const { candidates, reachedUpToDate: hitBoundary } = selectAutoSyncCandidates(
page,
index,
);
for (const candidate of candidates) {
if (newRecs.length + changedRecs.length >= deps.maxImportsPerTick) {
cappedByImports = true;
break;
}
if (candidate.kind === 'new') {
newRecs.push(candidate.recording);
} else {
changedRecs.push(candidate.recording);
}
}
if (hitBoundary) {
reachedUpToDate = true;
break;
}
if (cappedByImports) {
break;
}
// A short page means the remote has no more rows to offer.
if (page.length < deps.pageSize) {
break;
}
skip += page.length;
}
deps.log?.('auto-sync candidates selected', {
newCount: newRecs.length,
changedCount: changedRecs.length,
pagesScanned,
reachedUpToDate,
cappedByImports,
cappedByPages,
});
if (newRecs.length === 0 && changedRecs.length === 0) {
return {
imported: 0,
updated: 0,
pagesScanned,
reachedUpToDate,
cappedByImports,
cappedByPages,
};
}
const { imported, updated } = await deps.importCandidates(newRecs, changedRecs);
return {
imported,
updated,
pagesScanned,
reachedUpToDate,
cappedByImports,
cappedByPages,
};
}

213
auto-sync.ts Normal file
View file

@ -0,0 +1,213 @@
// -----------------------------------------------------------------------------
// auto-sync.ts
//
// Pure, Obsidian-free logic for the optional background sync (issue #5). The
// scheduler in main.ts drives the timer and I/O; everything decision-shaped
// lives here so it is unit-testable with plain data.
//
// Detection model (verified live 2026-07-01): the list endpoint returns a
// per-recording `version_ms` edit cursor and can be sorted `sort_by=edit_time`
// descending. So a recording edited today (even an old one) sorts to the top,
// and the FIRST already-current recording marks the boundary below which
// everything is unchanged. Candidate selection is therefore O(new + changed)
// per tick, not O(vault).
// -----------------------------------------------------------------------------
import type { PlaudRecordingId, Recording } from './plaud-client';
import type { ImportedRecord } from './vault-index';
import {
categoryAllowsReauth,
filterVisibleRecordings,
type ErrorCategory,
} from './import-core';
/**
* Per-recording classification against the vault index.
* - `new`: not in the vault. Import it.
* - `changed`: in the vault with a stored marker, and the listed `version_ms`
* is strictly greater. Re-import (overwrite) it.
* - `up-to-date-boundary`: in the vault with a stored marker and listed
* `version_ms` <= stored. Proof it has not changed; because the list is
* edit-time-descending, everything BELOW it is also unchanged, so this is
* the stop signal.
* - `up-to-date-current`: in the vault but no comparable marker (imported
* before this feature, or the list omitted `version_ms`). Migration rule:
* treat as current so enabling auto-sync never mass-overwrites an existing
* library. NOT a stop signal (we cannot prove the boundary), so paging
* continues past it.
* - `skipped-wait-pull`: still syncing from the capture device
* (`wait_pull === 1`); skip and catch it on a later tick.
*/
export type AutoSyncClassification =
| 'new'
| 'changed'
| 'up-to-date-boundary'
| 'up-to-date-current'
| 'skipped-wait-pull';
export function classifyRecording(
recording: Recording,
index: ReadonlyMap<PlaudRecordingId, ImportedRecord>,
): AutoSyncClassification {
if (recording.waitPull === true) {
return 'skipped-wait-pull';
}
const existing = index.get(recording.id);
if (existing === undefined) {
return 'new';
}
// Migration / unknowable: no stored marker, or the list omitted version_ms
// this time. Cannot prove a change, so treat as current and keep paging.
if (existing.versionMs === undefined || recording.versionMs === undefined) {
return 'up-to-date-current';
}
return recording.versionMs > existing.versionMs
? 'changed'
: 'up-to-date-boundary';
}
/**
* True when an already-imported recording has changed in Plaud since import:
* both the listed and the stored `version_ms` are known and the listed one is
* greater. Used by the import dialog's "update available" badge (a manual-
* import cue) and mirrors the `changed` classification. A missing stored marker
* (legacy note) is NOT flagged, matching the migration rule.
*/
export function isUpdateAvailable(
listedVersionMs: number | undefined,
storedVersionMs: number | undefined,
): boolean {
return (
listedVersionMs !== undefined &&
storedVersionMs !== undefined &&
listedVersionMs > storedVersionMs
);
}
export interface AutoSyncCandidate {
readonly recording: Recording;
/** Drives the headless duplicate policy: skip-for-new, overwrite-for-changed. */
readonly kind: 'new' | 'changed';
}
export interface SelectCandidatesResult {
readonly candidates: readonly AutoSyncCandidate[];
/**
* True when a definitively up-to-date recording was reached (the boundary).
* The scheduler stops paging when this is set.
*/
readonly reachedUpToDate: boolean;
}
/**
* Classify one edit-time-descending page against the index and collect the
* importable candidates. Trashed recordings are removed first (auto-sync never
* imports trash regardless of the show-trash display setting). Iteration stops
* at the first `up-to-date-boundary`: everything after it in the page is older
* and unchanged.
*/
export function selectAutoSyncCandidates(
page: readonly Recording[],
index: ReadonlyMap<PlaudRecordingId, ImportedRecord>,
): SelectCandidatesResult {
// Never import trash. filterVisibleRecordings preserves order.
const visible = filterVisibleRecordings(page, false);
const candidates: AutoSyncCandidate[] = [];
let reachedUpToDate = false;
for (const recording of visible) {
const classification = classifyRecording(recording, index);
if (classification === 'up-to-date-boundary') {
reachedUpToDate = true;
break;
}
if (classification === 'new' || classification === 'changed') {
candidates.push({ recording, kind: classification });
}
// 'skipped-wait-pull' and 'up-to-date-current' fall through: skip, keep going.
}
return { candidates, reachedUpToDate };
}
// -----------------------------------------------------------------------------
// Auth-pause state machine
// -----------------------------------------------------------------------------
/**
* In-memory auto-sync run state. `paused` short-circuits ticks after an auth
* failure (a rejected/expired 24h token) until the user re-auths;
* `consecutiveTransientFailures` is available for optional backoff.
*/
export interface AutoSyncState {
readonly paused: boolean;
readonly consecutiveTransientFailures: number;
}
export const INITIAL_AUTO_SYNC_STATE: AutoSyncState = {
paused: false,
consecutiveTransientFailures: 0,
};
/** The bucket a tick's result falls into for the state machine. */
export type AutoSyncTickOutcome = 'ok' | 'auth' | 'transient';
/**
* Map an error category to a tick outcome. Auth (token-rejected /
* not-configured) pauses; everything else is transient (retry next tick, no
* pause). Single-sources the auth predicate with the import runner via
* `categoryAllowsReauth`.
*/
export function tickOutcomeForCategory(category: ErrorCategory): AutoSyncTickOutcome {
return categoryAllowsReauth(category) ? 'auth' : 'transient';
}
/** Pure reducer: auth pauses, transient increments the counter, ok resumes. */
export function nextAutoSyncState(
state: AutoSyncState,
outcome: AutoSyncTickOutcome,
): AutoSyncState {
switch (outcome) {
case 'auth':
return { paused: true, consecutiveTransientFailures: 0 };
case 'transient':
return {
paused: state.paused,
consecutiveTransientFailures: state.consecutiveTransientFailures + 1,
};
case 'ok':
return INITIAL_AUTO_SYNC_STATE;
}
}
// -----------------------------------------------------------------------------
// Interval coercion
// -----------------------------------------------------------------------------
export const AUTO_SYNC_INTERVAL_PRESETS = [15, 30, 60, 120, 240, 480, 1440] as const;
export const AUTO_SYNC_INTERVAL_FLOOR = 15;
export const AUTO_SYNC_INTERVAL_CEILING = 1440;
export const AUTO_SYNC_INTERVAL_DEFAULT = 60;
/**
* Coerce a settings value (number, or a string from a dropdown) into a valid
* interval in minutes: floor 15, ceiling 1440, non-numeric falls back to the
* 60-minute default. Keeps a background timer from ever being scheduled at an
* absurd or zero interval.
*/
export function coerceIntervalMinutes(value: unknown): number {
// Only a real number or a non-empty numeric string counts. null, undefined,
// '' and whitespace must NOT go through Number() (which maps them to 0 and
// then to the 15-minute floor); a corrupt/absent setting should land on the
// 60-minute default, not silently become the most frequent interval.
let n: number;
if (typeof value === 'number') {
n = value;
} else if (typeof value === 'string' && value.trim() !== '') {
n = Number(value);
} else {
n = NaN;
}
if (!Number.isFinite(n)) {
return AUTO_SYNC_INTERVAL_DEFAULT;
}
return Math.min(AUTO_SYNC_INTERVAL_CEILING, Math.max(AUTO_SYNC_INTERVAL_FLOOR, Math.floor(n)));
}

View file

@ -117,6 +117,12 @@ export interface ImportModalOptions extends NoteWriterOptions {
/** Read a token from the clipboard and store it; resolves true on success. */
readonly pasteToken: () => Promise<boolean>;
};
/**
* Fired once when the import modal closes. The plugin uses it to release the
* shared import gate so a background auto-sync tick can resume; auto-sync
* ticks are suppressed for the modal's whole open lifetime.
*/
readonly onClosed?: () => void;
}
// -----------------------------------------------------------------------------

View file

@ -20,6 +20,7 @@ import {
} from './note-writer';
import { runImport } from './import-runner';
import { buildPlaudIdIndex, type ImportedRecord } from './vault-index';
import { isUpdateAvailable } from './auto-sync';
import { AttachmentImporter } from './attachment-importer';
import {
classifyError,
@ -430,6 +431,12 @@ export class ImportModal extends Modal {
// 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.
@ -479,10 +486,32 @@ export class ImportModal extends Modal {
}
onClose(): void {
// Signal any in-flight import loop to stop writing. The loop
// checks `this.aborted` between iterations and fires a partial
// Notice if it was interrupted.
// 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;
// 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();
@ -960,6 +989,12 @@ export class ImportModal extends Modal {
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);
}
}
const meta = labelWrap.createDiv({ cls: 'plaud-importer-meta' });
@ -998,6 +1033,9 @@ export class ImportModal extends Modal {
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);
@ -1020,6 +1058,21 @@ export class ImportModal extends Modal {
});
}
// 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',
},
});
}
private updateImportButtonState(): void {
if (this.importButton) {
this.importButton.disabled = this.selectedIds.size === 0 || this.preparingCustomization;
@ -1044,6 +1097,13 @@ export class ImportModal extends Modal {
}
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;
}
@ -1548,6 +1608,12 @@ export class ImportModal extends Modal {
}
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),
);
@ -1576,7 +1642,25 @@ export class ImportModal extends Modal {
// 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.runImportBatch(selected, selection, duplicatePolicy, [], true);
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;
}
/**
@ -1800,12 +1884,14 @@ export class ImportModal extends Modal {
// 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.runImportBatch(
tail,
selection,
duplicatePolicy,
priorResults,
false,
this.trackImportRun(
this.runImportBatch(
tail,
selection,
duplicatePolicy,
priorResults,
false,
),
).catch((err) => {
console.error('Plaud importer: resume import failed', err);
this.renderError(classifyError(err));

610
main.ts
View file

@ -6,6 +6,7 @@ import {
PluginSettingTab,
SecretComponent,
Setting,
TFile,
type SettingDefinitionItem,
type ObsidianProtocolData,
requestUrl,
@ -14,6 +15,7 @@ import {
} from "obsidian";
import {
ReverseEngineeredPlaudClient,
PlaudAuthError,
type PlaudHttpFetcher,
} from "./plaud-client-re";
import { ImportModal, classifyError } from "./import-modal";
@ -23,7 +25,29 @@ import {
isAccessToken,
openPlaudLogin,
} from "./plaud-login";
import type { TagMode } from "./note-writer";
import { NoteWriter, type TagMode } from "./note-writer";
import { AttachmentImporter } from "./attachment-importer";
import {
buildPlaudIdIndex,
buildPlaudIdIndexWithColdCheck,
outputFolderCacheIsCold,
type ImportedRecord,
} from "./vault-index";
import { runImport } from "./import-runner";
import {
PAGE_SIZE,
type ArtifactSelection,
type ImportModalOptions,
} from "./import-core";
import type { PlaudRecordingId, Recording } from "./plaud-client";
import { runAutoSyncTick } from "./auto-sync-runner";
import {
coerceIntervalMinutes,
nextAutoSyncState,
tickOutcomeForCategory,
INITIAL_AUTO_SYNC_STATE,
type AutoSyncState,
} from "./auto-sync";
// Stable SecretStorage id for a token captured by the in-app sign-in flow.
// Re-running sign-in overwrites it, mirroring "replace my token".
@ -195,6 +219,14 @@ interface PlaudImporterSettings {
// default, matching the Plaud web UI which hides trash. Trashed recordings
// are short accidental clips with no transcript more often than not.
showTrashedRecordings: boolean;
// Auto-sync (issue #5): a background timer that imports new recordings and
// re-imports (overwrites) changed ones on an interval, using the saved
// default import options. OFF by default: the connection is reverse-
// engineered, the ~24h token forces periodic re-auth, and a detected change
// OVERWRITES the note and its artifacts (Plaud wins over local edits).
autoSyncEnabled: boolean;
// Minutes between auto-sync ticks. Coerced to [15, 1440]; default 60.
autoSyncIntervalMinutes: number;
}
const DEFAULT_SETTINGS: PlaudImporterSettings = {
@ -225,6 +257,8 @@ const DEFAULT_SETTINGS: PlaudImporterSettings = {
autoCloseSummarySeconds: 20,
writePlaceholderForUnprocessed: true,
showTrashedRecordings: false,
autoSyncEnabled: false,
autoSyncIntervalMinutes: 60,
};
// Adapt Obsidian's requestUrl to the PlaudHttpFetcher shape the client
@ -298,6 +332,24 @@ export default class PlaudImporterPlugin extends Plugin {
// pure icon swap requires detach + re-add vs. a no-op.
private ribbonIconId: string | null = null;
// Auto-sync timers and state. The interval id and the deferred first-run
// timeout id are kept so an interval/toggle change can clear and re-create
// them; onunload clears both.
private autoSyncIntervalId: number | undefined;
private autoSyncFirstRunTimeoutId: number | undefined;
private autoSyncState: AutoSyncState = INITIAL_AUTO_SYNC_STATE;
// Single-flight coordination between the manual modal and background ticks.
// Two independent flags rather than one shared boolean, so the modal's
// open/close never clobbers a tick's in-flight state and vice versa. An
// auto-sync tick starts only when BOTH are clear.
private importModalOpen = false;
private autoSyncTickInFlight = false;
// Set once in onunload. A tick or backfill captures `this.client` in a local
// before its awaits, so clearing the client alone does not stop an in-flight
// loop; the loops poll this flag and abort so a disable/re-enable cannot leave
// the old instance writing while the new instance starts a tick.
private disposed = false;
async onload() {
await this.loadSettings();
@ -319,6 +371,14 @@ export default class PlaudImporterPlugin extends Plugin {
callback: () => this.launchImportModal("command"),
});
this.addCommand({
id: "backfill-version-markers",
name: "Backfill version markers for auto-sync",
callback: () => {
void this.backfillVersionMarkers();
},
});
// Render the left-rail ribbon icon only when the user has opted
// in via settings. updateRibbonIcon() is idempotent and is also
// called from the settings toggle so enabling/disabling takes
@ -373,11 +433,28 @@ export default class PlaudImporterPlugin extends Plugin {
},
},
);
// The client exists now, so a scheduled tick can run. Starts the
// timer only when auto-sync is enabled; deferred first run is inside.
this.reconcileAutoSync();
});
}
onunload() {
// Signal any in-flight tick/backfill loop to stop between iterations. Set
// before clearing the client so a loop that already captured the client
// still sees the abort.
this.disposed = true;
this.client = undefined;
// Clear the auto-sync timers. Both the interval and the deferred first-run
// timeout are ours (plain setInterval/setTimeout), so we clear both here.
if (this.autoSyncIntervalId !== undefined) {
window.clearInterval(this.autoSyncIntervalId);
this.autoSyncIntervalId = undefined;
}
if (this.autoSyncFirstRunTimeoutId !== undefined) {
window.clearTimeout(this.autoSyncFirstRunTimeoutId);
this.autoSyncFirstRunTimeoutId = undefined;
}
// Obsidian auto-detaches ribbon icons on unload; clear our
// state so a subsequent onload starts from a known baseline.
this.ribbonIconEl = null;
@ -402,6 +479,8 @@ export default class PlaudImporterPlugin extends Plugin {
}
try {
const recordings = await client.listRecordings({ limit: 1 });
// A working token is a valid resume trigger for a paused auto-sync.
this.resumeAutoSyncIfPaused();
return {
ok: true,
message:
@ -414,6 +493,380 @@ export default class PlaudImporterPlugin extends Plugin {
}
}
// ---- Auto-sync (issue #5) -------------------------------------------
private logAutoSync(message: string, payload?: unknown): void {
if (!this.debugLogger.enabled) return;
this.debugLogger.log({ kind: "note", endpoint: "/auto-sync", message, payload });
}
/**
* Runtime import options shared by the manual modal and the headless
* auto-sync path, built from the current settings. Single-sourced so the two
* import paths never drift as settings are added.
*/
private buildImportRuntimeOptions(): ImportModalOptions {
return {
outputFolder: this.settings.outputFolder,
subfolderTemplate: this.settings.subfolderTemplate,
onDuplicate: this.settings.onDuplicate,
includeTranscript: this.settings.includeTranscript,
includeSummary: this.settings.defaultIncludeSummary,
foldTranscript: this.settings.foldTranscript,
transcriptHeaderLevel: this.settings.transcriptHeaderLevel,
defaultIncludeSummary: this.settings.defaultIncludeSummary,
defaultIncludeAttachments: this.settings.defaultIncludeAttachments,
defaultIncludeMindmap: this.settings.defaultIncludeMindmap,
defaultIncludeCard: this.settings.defaultIncludeCard,
defaultIncludeAudio: this.settings.defaultIncludeAudio,
tagMode: this.settings.tagMode,
customTags: this.settings.customTags,
aiKeywordsAsProperty: this.settings.aiKeywordsAsProperty,
autoCloseSummary: this.settings.autoCloseSummary,
autoCloseSummarySeconds: this.settings.autoCloseSummarySeconds,
writePlaceholderForUnprocessed:
this.settings.writePlaceholderForUnprocessed,
showTrashedRecordings: this.settings.showTrashedRecordings,
debugLogger: this.debugLogger,
getAuthToken: () =>
this.settings.secretId.length > 0
? this.app.secretStorage.getSecret(this.settings.secretId)
: null,
getApiBaseUrl: () => this.settings.apiBaseUrl,
};
}
/** Artifact selection for a headless auto-sync import, from settings. */
private autoSyncSelection(): ArtifactSelection {
return {
includeSummary: this.settings.defaultIncludeSummary !== false,
includeTranscript: this.settings.includeTranscript !== false,
includeAttachments: this.settings.defaultIncludeAttachments !== false,
includeMindmap: this.settings.defaultIncludeMindmap !== false,
includeCard: this.settings.defaultIncludeCard !== false,
includeAudio: this.settings.defaultIncludeAudio === true,
};
}
/**
* Headless import of a tick's candidates. New recordings are created
* (skip-for-new); changed recordings overwrite the matched note
* (overwrite-for-changed). Never a blanket-overwrite writer. Throws a
* PlaudAuthError when a batch stops on a mid-run auth failure so the tick's
* state machine pauses; other per-recording failures stay non-fatal.
*/
private async importAutoSyncCandidates(
newRecs: readonly Recording[],
changedRecs: readonly Recording[],
index: Map<PlaudRecordingId, ImportedRecord>,
): Promise<{ imported: number; updated: number }> {
const client = this.client;
if (client === undefined) return { imported: 0, updated: 0 };
const options = this.buildImportRuntimeOptions();
const selection = this.autoSyncSelection();
// Reuse the tick's index (passed in) rather than rebuilding: one snapshot
// backs both classification and the writer's cross-folder dedup, and a
// cold/partial metadataCache cannot diverge between the two.
const attachments = new AttachmentImporter({
app: this.app,
getAuthToken: options.getAuthToken,
getApiBaseUrl: options.getApiBaseUrl,
debugLogger: this.debugLogger,
});
const fetchArtifacts = (id: PlaudRecordingId) =>
client.getTranscriptAndSummary(id);
const fetchAudioUrl = selection.includeAudio
? (id: PlaudRecordingId) => client.getAudioTempUrl(id)
: undefined;
const makeWriter = (policy: "skip" | "overwrite"): NoteWriter =>
new NoteWriter(this.app.vault, {
...options,
onDuplicate: policy,
existingPathForPlaudId: (id) =>
index.get(id as PlaudRecordingId)?.path ?? null,
});
const runBatch = async (
recordings: readonly Recording[],
policy: "skip" | "overwrite",
): Promise<number> => {
if (recordings.length === 0) return 0;
const outcome = await runImport({
recordings,
selection,
writer: makeWriter(policy),
attachments,
options,
fetchArtifacts,
fetchAudioUrl,
// Stop between recordings if the plugin unloads mid-tick, so a
// disable/re-enable cannot leave this loop writing while a fresh
// instance starts its own tick.
observer: { shouldAbort: () => this.disposed },
});
if (outcome.stop === "auth-failed") {
// token_rejected (not not_configured) is correct here: this batch
// only runs after listPage already fetched a page with the stored
// token, so a mid-import auth failure is a rejected/expired token,
// not a missing one. Either way the state machine maps it to a
// pause via categoryAllowsReauth; the reason only sharpens the log.
throw new PlaudAuthError(
"token_rejected",
"Plaud session expired during auto-sync",
"/auto-sync",
);
}
// Count only real writes. A 'written' result whose writeOutcome is
// 'skipped' is a duplicate-policy skip (a note already existed), not a
// created/overwritten note, and must not inflate the notice counts.
// A 'placeholder-written' result is a real stub write too, so it must
// count; but its 'kept-existing' status means a real note already
// existed and nothing was written, so it is excluded on the same
// principle as a 'skipped' write.
return outcome.results.filter(
(r) =>
(r.kind === "written" && r.writeOutcome.status !== "skipped") ||
(r.kind === "placeholder-written" &&
r.outcome.status !== "kept-existing"),
).length;
};
const imported = await runBatch(newRecs, "skip");
const updated = await runBatch(changedRecs, "overwrite");
return { imported, updated };
}
/**
* One auto-sync tick, wrapped so it never throws into the timer. Skips when
* disabled, paused for re-auth, or an import is already in flight. Maps a
* failure through the state machine (auth pauses; transient retries).
*/
private async runAutoSyncTickSafe(): Promise<void> {
if (!this.settings.autoSyncEnabled) return;
if (this.autoSyncState.paused) {
this.logAutoSync("tick skipped: paused for re-auth");
return;
}
if (this.importModalOpen || this.autoSyncTickInFlight) {
this.logAutoSync("tick skipped: an import is already running");
return;
}
const client = this.client;
if (client === undefined) return;
// Claim the single-flight gate BEFORE the (potentially expensive) index
// scan below. The scan can take a while on a large vault, and a manual
// import modal opened mid-scan checks this same flag; setting it only
// after the scan would let that modal slip past the gate and overlap this
// tick. Every early return from here runs through the finally that clears
// the flag.
this.autoSyncTickInFlight = true;
try {
// One pass: cold-cache guard and index build fused (see
// buildPlaudIdIndexWithColdCheck). A cold cache would make the index
// incomplete and every existing note look new, so skip; a later tick
// with a warm cache proceeds.
const indexState = buildPlaudIdIndexWithColdCheck(
this.app,
this.settings.outputFolder,
);
if (indexState.isCold) {
this.logAutoSync("tick skipped: output-folder metadata cache is cold");
return;
}
const index = indexState.index;
const result = await runAutoSyncTick({
pageSize: PAGE_SIZE,
maxImportsPerTick: 25,
maxPagesPerTick: 5,
listPage: (skip, limit) =>
client.listRecordings({ sortBy: "edit_time", skip, limit }),
buildIndex: () => index,
// Reuse the index this tick already built (and cold-cache-guarded)
// so classification and the writer's dedup share one snapshot.
importCandidates: (n, c) => this.importAutoSyncCandidates(n, c, index),
log: (m, p) => this.logAutoSync(m, p),
});
this.autoSyncState = nextAutoSyncState(this.autoSyncState, "ok");
if (result.imported + result.updated > 0) {
new Notice(
`Plaud auto-sync: imported ${result.imported} new, updated ${result.updated}.`,
);
}
this.logAutoSync("tick complete", result);
} catch (err) {
const classification = classifyError(err);
const outcome = tickOutcomeForCategory(classification.category);
this.autoSyncState = nextAutoSyncState(this.autoSyncState, outcome);
if (outcome === "auth") {
// The auth outcome covers both a rejected/expired token and a
// missing one; word the pause Notice for the actual category so a
// user who never configured a token is not told it "expired".
const reason =
classification.category === "not-configured"
? "no Plaud token is configured. Add one to resume."
: "the session expired. Reconnect to resume.";
new Notice(`Plaud auto-sync paused: ${reason}`);
}
this.logAutoSync("tick failed", {
outcome,
message: classification.message,
});
} finally {
this.autoSyncTickInFlight = false;
}
}
/**
* Start, stop, or reschedule the auto-sync timer to match settings.
* Idempotent: clears the existing interval and deferred first-run timeout
* before (re)creating them. Called from onLayoutReady and on any auto-sync
* settings change.
*/
reconcileAutoSync(): void {
if (this.autoSyncIntervalId !== undefined) {
window.clearInterval(this.autoSyncIntervalId);
this.autoSyncIntervalId = undefined;
}
if (this.autoSyncFirstRunTimeoutId !== undefined) {
window.clearTimeout(this.autoSyncFirstRunTimeoutId);
this.autoSyncFirstRunTimeoutId = undefined;
}
if (!this.settings.autoSyncEnabled) return;
const minutes = coerceIntervalMinutes(this.settings.autoSyncIntervalMinutes);
// Plain setInterval, not registerInterval: this method reschedules on every
// settings change and clears the previous id itself (above) and on unload.
// registerInterval would push each id onto the component's cleanup list
// without ever removing the cleared ones, so they would accumulate.
this.autoSyncIntervalId = window.setInterval(() => {
void this.runAutoSyncTickSafe();
}, minutes * 60 * 1000);
// Deferred first tick (~2 min) so startup is not blocked and the vault
// metadata cache is warm before the first index build.
this.autoSyncFirstRunTimeoutId = window.setTimeout(() => {
this.autoSyncFirstRunTimeoutId = undefined;
void this.runAutoSyncTickSafe();
}, 2 * 60 * 1000);
this.logAutoSync("auto-sync scheduled", { minutes });
}
/** Clear an auth pause and run a tick soon. Called on token re-save / test / toggle. */
resumeAutoSyncIfPaused(): void {
if (!this.autoSyncState.paused) return;
this.autoSyncState = nextAutoSyncState(this.autoSyncState, "ok");
this.logAutoSync("auto-sync resumed after re-auth");
if (!this.settings.autoSyncEnabled) return;
// Track this deferred tick in the same slot as the scheduled first run so
// reconcileAutoSync() and onunload() clear it. An untracked setTimeout
// could otherwise fire after auto-sync is disabled/rescheduled or during
// unload and run an unexpected tick.
if (this.autoSyncFirstRunTimeoutId !== undefined) {
window.clearTimeout(this.autoSyncFirstRunTimeoutId);
}
this.autoSyncFirstRunTimeoutId = window.setTimeout(() => {
this.autoSyncFirstRunTimeoutId = undefined;
void this.runAutoSyncTickSafe();
}, 1000);
}
/**
* One-time backfill of `plaud-version-ms` into notes imported before
* auto-sync existed. Reads each recording's current `version_ms` from the
* list and writes ONLY the frontmatter marker (no body rewrite), so those
* notes become edit-detectable. Without it, auto-sync treats a legacy note's
* edits as un-detectable (missing marker = current). Safe to re-run: notes
* that already have a marker are skipped.
*/
private async backfillVersionMarkers(): Promise<void> {
const client = this.client;
if (client === undefined) {
new Notice("Plaud importer: still starting up. Try again in a moment.");
return;
}
// Participate in the single-flight gate: the backfill writes frontmatter
// across many notes, so it must not overlap a manual import or a tick.
if (this.importModalOpen || this.autoSyncTickInFlight) {
new Notice("Plaud importer: an import is running. Try backfill again shortly.");
return;
}
if (outputFolderCacheIsCold(this.app, this.settings.outputFolder)) {
// A cold cache would make buildPlaudIdIndex return a partial map, so
// the backfill would silently miss notes ("backfilled 0"). Ask the
// user to retry once Obsidian has finished loading.
new Notice(
"Plaud importer: still loading notes. Try backfill again in a moment.",
);
return;
}
this.autoSyncTickInFlight = true;
new Notice("Plaud importer: backfilling version markers...");
try {
// Build id -> version_ms from the full list (bounded page loop).
const MAX_BACKFILL_PAGES = 500;
const versionById = new Map<PlaudRecordingId, number>();
let skip = 0;
let reachedListEnd = false;
for (let page = 0; page < MAX_BACKFILL_PAGES; page++) {
// Stop if the plugin unloaded mid-scan (finally clears the gate).
if (this.disposed) return;
const recs = await client.listRecordings({
sortBy: "edit_time",
skip,
limit: PAGE_SIZE,
});
if (recs.length === 0) {
reachedListEnd = true;
break;
}
for (const r of recs) {
if (r.versionMs !== undefined) versionById.set(r.id, r.versionMs);
}
if (recs.length < PAGE_SIZE) {
reachedListEnd = true;
break;
}
skip += recs.length;
}
const index = buildPlaudIdIndex(this.app, this.settings.outputFolder);
let written = 0;
for (const [id, record] of index) {
// Stop writing frontmatter if the plugin unloaded mid-backfill.
if (this.disposed) return;
if (record.versionMs !== undefined) continue; // already has a marker
const versionMs = versionById.get(id);
if (versionMs === undefined) continue; // recording no longer listed
const file = this.app.vault.getFileByPath(record.path);
if (!(file instanceof TFile)) continue;
await this.app.fileManager.processFrontMatter(
file,
(fm: Record<string, unknown>) => {
fm["plaud-version-ms"] = versionMs;
},
);
written += 1;
}
// The scan always restarts from the newest page, so re-running does not
// advance past the cap; say what happened without promising a fix.
const capNote = reachedListEnd
? ""
: " Stopped at the scan limit; the least recently updated recordings were not checked, so a few legacy notes may still lack a marker.";
new Notice(
`Plaud importer: backfilled ${written} version marker${written === 1 ? "" : "s"}.${capNote}`,
);
this.logAutoSync("backfill complete", {
written,
listed: versionById.size,
reachedListEnd,
});
} catch (err) {
new Notice(`Plaud importer: backfill failed — ${classifyError(err).message}`);
} finally {
// Always release the gate so a failed or empty backfill never leaves
// auto-sync permanently blocked.
this.autoSyncTickInFlight = false;
}
}
/**
* Add, remove, or swap the left-rail ribbon icon based on the
* current settings. Safe to call repeatedly no-ops when the DOM
@ -465,48 +918,59 @@ export default class PlaudImporterPlugin extends Plugin {
message: `user invoked 'Import recent recordings' via ${source}`,
});
}
// Snapshot settings at invocation time so changes in the settings
// tab take effect on the next click without reinstantiation.
new ImportModal(this.app, this.client, {
outputFolder: this.settings.outputFolder,
subfolderTemplate: this.settings.subfolderTemplate,
onDuplicate: this.settings.onDuplicate,
includeTranscript: this.settings.includeTranscript,
includeSummary: this.settings.defaultIncludeSummary,
foldTranscript: this.settings.foldTranscript,
transcriptHeaderLevel: this.settings.transcriptHeaderLevel,
defaultIncludeSummary: this.settings.defaultIncludeSummary,
defaultIncludeAttachments: this.settings.defaultIncludeAttachments,
defaultIncludeMindmap: this.settings.defaultIncludeMindmap,
defaultIncludeCard: this.settings.defaultIncludeCard,
defaultIncludeAudio: this.settings.defaultIncludeAudio,
tagMode: this.settings.tagMode,
customTags: this.settings.customTags,
aiKeywordsAsProperty: this.settings.aiKeywordsAsProperty,
autoCloseSummary: this.settings.autoCloseSummary,
autoCloseSummarySeconds: this.settings.autoCloseSummarySeconds,
writePlaceholderForUnprocessed:
this.settings.writePlaceholderForUnprocessed,
showTrashedRecordings: this.settings.showTrashedRecordings,
debugLogger: this.debugLogger,
getAuthToken: () =>
this.settings.secretId.length > 0
? this.app.secretStorage.getSecret(this.settings.secretId)
: null,
getApiBaseUrl: () => this.settings.apiBaseUrl,
onReauth: () => this.reauthenticate(),
onReauthSso: {
setupBookmark: () => {
void this.openBookmarkSetupPage();
// Refuse to launch while another import is active: a second modal, or a
// modal opened over an in-flight auto-sync/backfill, would clobber the
// shared single-flight gate (importModalOpen / autoSyncTickInFlight).
if (this.importModalOpen) {
new Notice("Plaud importer: an import window is already open.");
return;
}
if (this.autoSyncTickInFlight) {
new Notice("Plaud importer: auto-sync is running. Try again shortly.");
return;
}
// Mark the modal open for its whole lifetime so a background auto-sync
// tick does not start alongside a manual import. Cleared from onClosed
// below. Uses its own flag (not the tick's) so the two never clobber.
this.importModalOpen = true;
try {
// Snapshot settings at invocation time (via buildImportRuntimeOptions,
// the same builder the headless auto-sync path uses) so changes in the
// settings tab take effect on the next click without reinstantiation.
new ImportModal(this.app, this.client, {
...this.buildImportRuntimeOptions(),
// After a successful in-modal re-auth, clear any auth pause so
// background sync resumes without waiting for the settings tab.
onReauth: async () => {
const ok = await this.reauthenticate();
if (ok) this.resumeAutoSyncIfPaused();
return ok;
},
signIn: () => {
new BrowserSignInModal(this.app, () =>
this.openPlaudInBrowser(),
).open();
onReauthSso: {
setupBookmark: () => {
void this.openBookmarkSetupPage();
},
signIn: () => {
new BrowserSignInModal(this.app, () =>
this.openPlaudInBrowser(),
).open();
},
pasteToken: async () => {
const ok = await this.pasteTokenFromClipboard();
if (ok) this.resumeAutoSyncIfPaused();
return ok;
},
},
pasteToken: () => this.pasteTokenFromClipboard(),
},
}).open();
onClosed: () => {
this.importModalOpen = false;
},
}).open();
} catch (err) {
// If constructing/opening the modal throws, onClosed never fires;
// release the flag here so a background tick is not blocked forever.
this.importModalOpen = false;
throw err;
}
}
async loadSettings() {
@ -946,6 +1410,29 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
"showTrashedRecordings",
);
new Setting(containerEl).setName("Automatic sync").setHeading();
this.addToggleRow(
containerEl,
"Enable automatic sync",
"Off by default. Runs a background import on a schedule using your default import options: new recordings are imported, and a recording you changed in Plaud (edited speaker names, corrected transcript, or finished processing) is re-imported. IMPORTANT: a re-import OVERWRITES that note and its downloaded artifacts with Plaud's current version, so edits you made to a synced note or its attachment files are lost on the next change. Only recordings that actually changed are touched; unchanged notes are never modified. Desktop only, and the ~24 hour token means the background job pauses for reconnection roughly daily.",
"autoSyncEnabled",
);
this.addDropdownRow(
containerEl,
"Sync interval",
"How often the background sync checks Plaud for new and changed recordings. Minimum 15 minutes.",
"autoSyncIntervalMinutes",
{
"15": "Every 15 minutes",
"30": "Every 30 minutes",
"60": "Every hour",
"120": "Every 2 hours",
"240": "Every 4 hours",
"480": "Every 8 hours",
"1440": "Once a day",
},
);
new Setting(containerEl).setName("Transcript rendering").setHeading();
this.addToggleRow(
containerEl,
@ -1006,6 +1493,9 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
.onChange(async (id) => {
this.plugin.settings.secretId = id;
await this.plugin.saveSettings();
// A freshly stored token is a resume trigger for a paused
// auto-sync.
this.plugin.resumeAutoSyncIfPaused();
refreshStatus();
});
this.tokenRefresh = () => {
@ -1504,6 +1994,34 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
},
],
},
{
type: "group",
heading: "Automatic sync",
items: [
{
name: "Enable automatic sync",
desc: "Off by default. Runs a background import on a schedule using your default import options: new recordings are imported, and a recording you changed in Plaud (edited speaker names, corrected transcript, or finished processing) is re-imported. IMPORTANT: a re-import OVERWRITES that note and its downloaded artifacts with Plaud's current version, so edits you made to a synced note or its attachment files are lost on the next change. Only recordings that actually changed are touched; unchanged notes are never modified. Desktop only, and the ~24 hour token means the background job pauses for reconnection roughly daily.",
control: { type: "toggle", key: "autoSyncEnabled" },
},
{
name: "Sync interval",
desc: "How often the background sync checks Plaud for new and changed recordings. Minimum 15 minutes.",
control: {
type: "dropdown",
key: "autoSyncIntervalMinutes",
options: {
"15": "Every 15 minutes",
"30": "Every 30 minutes",
"60": "Every hour",
"120": "Every 2 hours",
"240": "Every 4 hours",
"480": "Every 8 hours",
"1440": "Once a day",
},
},
},
],
},
{
type: "group",
heading: "Transcript rendering",
@ -1585,6 +2103,9 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
this.plugin.settings.autoCloseSummarySeconds = Number.isFinite(parsed)
? Math.min(600, Math.max(1, Math.floor(parsed)))
: 20;
} else if (key === "autoSyncIntervalMinutes") {
// Dropdown delivers a string; coerce to a valid interval [15, 1440].
this.plugin.settings.autoSyncIntervalMinutes = coerceIntervalMinutes(value);
} else {
(this.plugin.settings as unknown as Record<string, unknown>)[key] = value;
}
@ -1593,6 +2114,13 @@ class PlaudImporterSettingsTab extends PluginSettingTab {
// Side effects that the imperative onChange handlers used to run inline.
if (key === "showRibbonIcon") {
this.plugin.updateRibbonIcon();
} else if (key === "autoSyncEnabled" || key === "autoSyncIntervalMinutes") {
// Start/stop/reschedule the timer to match the new setting. Enabling
// is a deliberate action, so also clear any prior auth pause.
this.plugin.reconcileAutoSync();
if (key === "autoSyncEnabled" && this.plugin.settings.autoSyncEnabled) {
this.plugin.resumeAutoSyncIfPaused();
}
} else if (key === "debug") {
// Update the live logger's enabled flag in place so the change takes
// effect on the next API call without reinstantiating the client.

View file

@ -1,7 +1,7 @@
{
"id": "plaud-importer",
"name": "Plaud Importer",
"version": "0.17.1",
"version": "0.18.0",
"minAppVersion": "1.11.4",
"description": "Import meeting summaries, transcripts, and attachments from Plaud.AI into your vault.",
"author": "Charles Kelsoe (ckelsoe)",

View file

@ -807,6 +807,14 @@ export function formatFrontmatter(
lines.push(`keywords: ${yamlArray(keywords)}`);
}
lines.push('source: plaud');
// Auto-sync change cursor: the recording's edit version (unix ms). Stored so
// a later sync can compare the list's current version_ms against it and
// re-import only when Plaud actually changed the recording. Emitted as a raw
// number (no quoting) so metadataCache surfaces it as a number. Absent for
// recordings whose list payload omitted version_ms.
if (recording.versionMs !== undefined) {
lines.push(`plaud-version-ms: ${recording.versionMs}`);
}
// Optional Plaud summary extras. Each line is emitted only when the
// corresponding field is present on the Summary. Missing summary or

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-plaud-importer",
"version": "0.17.1",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-plaud-importer",
"version": "0.17.1",
"version": "0.18.0",
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.2",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-plaud-importer",
"version": "0.17.1",
"version": "0.18.0",
"description": "Import meeting summaries, transcripts, and attachments from Plaud.AI into an Obsidian vault.",
"main": "main.js",
"scripts": {

View file

@ -169,7 +169,7 @@ export class ReverseEngineeredPlaudClient implements PlaudClient {
skip: String(filter?.skip ?? 0),
limit: String(filter?.limit ?? DEFAULT_LIMIT),
is_trash: '2',
sort_by: 'start_time',
sort_by: filter?.sortBy ?? 'start_time',
is_desc: 'true',
});
@ -1069,6 +1069,12 @@ interface RawRecording {
// Treated as not-trashed when missing.
readonly is_trash?: boolean;
readonly filetag_id_list?: readonly string[];
// The recording's edit version in unix ms (equals edit_time * 1000).
// Present on current payloads; the auto-sync change cursor. Optional so an
// older payload without it still parses.
readonly version_ms?: number;
// 1 while the recording is still syncing from the capture device.
readonly wait_pull?: number;
}
function parseListResponse(raw: unknown, endpoint: string): readonly RawRecording[] {
@ -1177,6 +1183,16 @@ function parseRecording(raw: RawRecording, endpoint: string): Recording {
summaryAvailable: raw.is_summary,
isTrashed: raw.is_trash === true,
tags: raw.filetag_id_list,
// The change cursor for auto-sync. Only trust a finite number; the guard
// treats version_ms as optional, so a malformed value stays undefined
// rather than corrupting the stored marker.
versionMs:
typeof raw.version_ms === 'number' && Number.isFinite(raw.version_ms)
? raw.version_ms
: undefined,
// wait_pull === 1 means still syncing from the device; anything else
// (including absent) is treated as ready.
waitPull: raw.wait_pull === 1,
};
}

View file

@ -154,6 +154,13 @@ export interface RecordingFilter {
readonly until?: Date;
readonly folderId?: string;
readonly hasTranscript?: boolean;
/**
* Sort dimension for the list endpoint. `'start_time'` (default) is
* recording-creation order, newest first. `'edit_time'` is last-EDITED
* order, used by auto-sync so a recording edited today (even an old one)
* sorts to the top and its `version_ms` can be compared against the vault.
*/
readonly sortBy?: 'start_time' | 'edit_time';
}
export interface Recording {
@ -183,6 +190,21 @@ export interface Recording {
readonly isTrashed: boolean;
readonly folderId?: string;
readonly tags?: readonly string[];
/**
* Plaud's edit version for the recording, in unix milliseconds (the list's
* `version_ms`, equal to `edit_time * 1000`). Advances whenever the
* recording is edited or (re)processed in Plaud. Auto-sync stores this in
* frontmatter (`plaud-version-ms`) and compares it against the list to
* detect changed recordings. Optional: older list payloads may omit it.
*/
readonly versionMs?: number;
/**
* True when the recording is still syncing from the capture device
* (`wait_pull === 1`): its content may be incomplete, so auto-sync skips it
* and picks it up on a later tick. Defaults to false when the flag is
* absent.
*/
readonly waitPull?: boolean;
}
export interface Transcript {

View file

@ -73,6 +73,17 @@
text-decoration: none;
}
.plaud-importer-update-badge {
display: inline-block;
padding: 0.05em 0.5em;
font-size: 0.75em;
font-weight: 500;
color: var(--text-on-accent);
background: var(--color-orange);
border-radius: 0.7em;
flex-shrink: 0;
}
.plaud-importer-footer {
text-align: center;
opacity: 0.8;

View file

@ -26,14 +26,17 @@ import type { PlaudRecordingId } from './plaud-client';
/**
* Lightweight pointer back to an imported note. The path is the only
* load-bearing field `openLinkText` uses it for click-through. The
* summary metadata is captured for future "update available" detection
* but not used in phase 1.
* load-bearing field `openLinkText` uses it for click-through.
* `versionMs` is the stored auto-sync cursor (`plaud-version-ms`): a listed
* recording whose `version_ms` exceeds it is a CHANGED note to re-import; a
* missing `versionMs` is treated as current (no re-import) so enabling
* auto-sync never mass-overwrites a pre-existing library.
*/
export interface ImportedRecord {
readonly path: string;
readonly summaryVersion?: string;
readonly summaryId?: string;
readonly versionMs?: number;
}
/**
@ -70,12 +73,54 @@ export function buildPlaudIdIndex(
path: file.path,
summaryVersion: pickFrontmatterString(rawFm['plaud-summary-version']),
summaryId: pickFrontmatterString(rawFm['plaud-summary-id']),
versionMs: pickFrontmatterNumber(rawFm['plaud-version-ms']),
};
out.set(id as PlaudRecordingId, record);
}
return out;
}
/**
* Cold-cache check and index build fused into ONE pass over the output folder.
* The auto-sync tick runs both every tick; done separately they each iterate
* `getMarkdownFiles()` (and, for a root output folder, the whole vault), so the
* per-tick cost doubles. This walks the files once: the first in-scope note with
* a null cache returns `{ isCold: true }` (matching `outputFolderCacheIsCold`)
* and the partial index is discarded; otherwise it returns the fully built index.
* Semantically identical to calling `outputFolderCacheIsCold` then
* `buildPlaudIdIndex`, at half the scan cost.
*/
export type OutputFolderIndexState =
| { readonly isCold: true }
| {
readonly isCold: false;
readonly index: Map<PlaudRecordingId, ImportedRecord>;
};
export function buildPlaudIdIndexWithColdCheck(
app: App,
outputFolder: string,
): OutputFolderIndexState {
const normalized = normalizeFolder(outputFolder);
const index = new Map<PlaudRecordingId, ImportedRecord>();
for (const file of app.vault.getMarkdownFiles()) {
if (!fileIsUnder(file, normalized)) continue;
const cache = app.metadataCache.getFileCache(file);
if (cache === null) return { isCold: true };
const rawFm: unknown = cache.frontmatter;
if (!isRecord(rawFm)) continue;
const id = pickFrontmatterString(rawFm['plaud-id']);
if (id === undefined) continue;
index.set(id as PlaudRecordingId, {
path: file.path,
summaryVersion: pickFrontmatterString(rawFm['plaud-summary-version']),
summaryId: pickFrontmatterString(rawFm['plaud-summary-id']),
versionMs: pickFrontmatterNumber(rawFm['plaud-version-ms']),
});
}
return { isCold: false, index };
}
function normalizeFolder(folder: string): string {
// Match the note writer's normalization: a Windows-style "\Inbox" must
// resolve to "Inbox" so the imported-note index finds files under the
@ -96,6 +141,35 @@ function fileIsUnder(file: TFile, folder: string): boolean {
return file.path.startsWith(prefix);
}
/**
* True when the metadata cache has NOT finished parsing the notes under the
* output folder, i.e. at least one note there has no parsed cache yet
* (getFileCache === null). buildPlaudIdIndex relies on the cache, so a cold
* cache makes it return empty and every note look new; auto-sync uses this to
* skip that tick and avoid a mass re-import.
*
* It checks cache warmth per file (any note under the folder with a null
* cache), not "the folder has any markdown". Every note under the folder
* counts, Plaud or not, which is correct: if ANY in-scope note is still
* unparsed the cache is not ready and buildPlaudIdIndex is unreliable. For a
* root output folder ('') "under the folder" means the whole vault, so a single
* unparsed note anywhere reports cold until the vault finishes loading. Once
* every in-scope note is parsed it returns false, so it cannot permanently
* disable sync. Uses the SAME folder normalization + matching as the index
* (Windows backslashes included). Returns false when the folder has no notes.
*/
export function outputFolderCacheIsCold(app: App, outputFolder: string): boolean {
const normalized = normalizeFolder(outputFolder);
// Single pass: this runs every auto-sync tick and, for a root output folder,
// scans the whole vault. Return as soon as an in-scope note has a null cache;
// falling through the loop covers both "no in-scope notes" and "all warm".
for (const file of app.vault.getMarkdownFiles()) {
if (!fileIsUnder(file, normalized)) continue;
if (app.metadataCache.getFileCache(file) === null) return true;
}
return false;
}
// YAML frontmatter values can be parsed as strings or as numbers
// depending on shape. Only accept strings (after trim + non-empty
// check); reject everything else so badge state never depends on an
@ -106,6 +180,21 @@ function pickFrontmatterString(value: unknown): string | undefined {
return trimmed.length > 0 ? trimmed : undefined;
}
// plaud-version-ms is written as a raw number, so metadataCache usually
// surfaces it as a number. Accept a numeric string too (a user or a YAML
// quirk could quote it), and reject anything non-finite so a malformed marker
// stays undefined (treated as "current", never a spurious re-import).
function pickFrontmatterNumber(value: unknown): number | undefined {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
if (typeof value === 'string') {
const n = Number(value.trim());
return value.trim().length > 0 && Number.isFinite(n) ? n : undefined;
}
return undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -32,5 +32,6 @@
"0.15.0": "1.11.4",
"0.16.0": "1.11.4",
"0.17.0": "1.11.4",
"0.17.1": "1.11.4"
"0.17.1": "1.11.4",
"0.18.0": "1.11.4"
}