* Complete browser reconnect from the deep link, not just the paste button (#75)
During a browser reconnect, a token returning via the bookmarklet's
obsidian:// deep link stored correctly but skipped the follow-through the
paste button ran: paused auto-sync stayed paused, the onReconnected
continuation never fired, and the modal stayed open holding the
single-flight gate until manually cancelled.
Both channels now run one completion handler. The active flow is tracked as
an object (modal + continuation + deliveryInFlight); a delivery claims the
flow before its async token store (the other channel no-ops meanwhile), and
completion is keyed to that exact flow object, so a slow store outliving a
cancelled flow can never complete a newer one. The gate release in the
modal's onClose is once-guarded because Obsidian runs onClose on every
close() call and the modal can be closed twice (completion plus the paste
handler's close-on-success backstop); without the guard a stale second
close could release a newer sign-in's gate. onunload closes any waiting
reconnect modal so a stale surface cannot write through a dead plugin
instance. A deep-linked token outside a reconnect now also resumes paused
auto-sync, and an onReconnected failure reports accurately instead of
surfacing as a token-save error.
Three Codex review rounds drove the hardening (delivery race/ABA, unload
leak, double-close gate release); all findings addressed.
* Address CodeRabbit findings: gate release on modal failure, stale-paste store guard
- The single-flight gate now survives modal lifecycle failures: open() is
wrapped (a modal that never opened can never fire onClose), and completion
and onunload call an idempotent release exposed on the flow after their
close() attempt, so a throwing close() cannot wedge every later sign-in.
- pasteTokenFromClipboard takes an optional canStore guard re-checked after
the clipboard read, right before the store. The reconnect paste passes the
flow-identity guard, so a paste resolving after its flow was cancelled can
no longer overwrite a newer sign-in's token. Other callers keep the
default and are unchanged.
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.
* Adopt the long-lived Plaud user token; neutralize the 24h refresh (#68)
Capture the ~300-day user token from web.plaud.ai localStorage in all three
sign-in flows (email window, SSO bookmarklet, paste) instead of the 24h
workspace token. A shared capture guard (plaud-token.ts) accepts a value only
when its decoded payload carries a client_id and a still-future exp, and rejects
the refresh token (WRT) and the neighboring profile JWT with no exp.
Neutralize the silent refresh whenever a non-WT token is stored: a refresh mints
a 24h WT and would clobber the long-lived token, reinstating daily expiry. The
scheduler, the reactive path, and the manual command all gate on the token typ.
Route a dead-token -3900 in-band status to pause + Reconnect. The refresh
subsystem stays in the tree (inert) until 0.32.0 removes it.
Update the sign-in copy and README to reflect the ~yearly re-auth cadence for
both email and SSO accounts.
* Address CodeRabbit/CodeQL findings on the token-capture PR
- Parse JWTs by an exact three-segment split instead of an unanchored search
regex (removes the polynomial-regex/ReDoS class CodeQL flagged, and refuses a
prefix.<jwt>.suffix value a search would have accepted and stored).
- Restrict the localStorage token read to a Plaud origin: the email window gates
on the probe href host, and the SSO bookmarklet checks location.hostname.
- Update the two auto-sync toggle descriptions that still said the ~24h token
pauses sync roughly daily.
- Clarify in the README that Clear sign-in does not sign you out of Plaud in
your normal browser.
- Add regression tests for exact-segment JWT parsing.
* Guard the refresh write with an optimistic-concurrency check
A silent refresh does not hold a lock across its network round-trip, and the
capture paths (storeAccessToken, clearSignIn) do not take the refresh gate, so
the stored credential can change while a refresh is in flight. Snapshot the
credential the refresh is renewing and apply the refreshed WT only if that exact
token and secretId are still stored on return. Otherwise keep the current
credential and report whether the session is still healthy, so a concurrent
paste/deep-link (long-lived or another account) or sign-out is never silently
overwritten and the reactive path does not pause a session a capture just fixed.
Addresses CodeRabbit's token-capture race finding on PR #71.
* Address CodeRabbit findings on the merge commit
- Update the keep-session-alive setting description and settings-interface
comment: the current sign-in stores the long-lived token, so background
renewal only applies to legacy short-lived sessions. The old text still
promised a daily 24h renewal.
- Harden token capture: PROBE_JS now refuses to read localStorage on any
non-Plaud hostname, so a foreign page's token key is never even read. The
plugin-side isPlaudOrigin gate on the returned href stays authoritative;
the in-page check is defense in depth on the same page snapshot.
* Require HTTPS in both token-capture origin gates
CodeRabbit finding on PR #71: the gates checked hostname only, and the
capture guard validates JWT claims, not a signature, so a plain-http
plaud.ai page (MITM-able) could plant a crafted token. isPlaudOrigin and
the in-page PROBE_JS gate now both require https:. Adds an isPlaudOrigin
test covering https acceptance, http rejection, lookalike hosts, other
schemes, and malformed input (isPlaudOrigin exported for the test).
Obsidian 1.13 (first seen here as 1.13.1 on 2026-06-22) attaches a
main-process will-navigate listener to every top-level window that cancels
any navigation off Obsidian's own app:// origin and reroutes the URL to
shell.openExternal. In the plugin's sign-in window that turned Plaud's own
login redirect (web.plaud.ai -> /login) into a stray tab in the user's
default browser. The 0.30.2 popup fix was aimed at window.open and could not
help: the leak is page navigation, a path no prior fix addressed.
Fix: remove the listener from the plugin-owned window right after
construction (remote method calls run synchronously in the main process, and
the listener attaches during the BrowserWindow constructor, so this cannot
race), re-strip on each poll tick as defense in depth, and fail closed
(settle null, window closed) if removal is unavailable or incomplete.
Verified live against Obsidian 1.13.2 via CDP: with the listener, a page
navigation logs 'Opening URL:' in the main process and opens the default
browser; with the fix, the same redirect stays in-window, listenerCount
reads 0, and no external open occurs. Root-caused with Codex plus a
five-agent forensic workflow (version bisect: listener absent through
1.12.7, present in 1.13.x).
The sign-in BrowserWindow's setWindowOpenHandler deny was a plain renderer
callback. @electron/remote transports ordinary callbacks asynchronously, so
the {action: 'deny'} return value never reached Electron's main process and
every popup the Plaud page fired escaped to the system browser as a stray
tab. Wrap the deny in remote.createFunctionWithReturnValue, which serializes
a constant synchronous return into the main process. The plain callback stays
only as a last-resort fallback on builds without that API, with an error note.
Latent since 2d34f5a; masked for a while when c5c87b6 stopped the background
refresh from loading the page in a hidden window. Diagnosed with Codex.
* Fix SSO reconnect dead-end and stop futile refresh retries (#64)
Google and Apple (SSO) accounts sign in through the external browser and a
bookmarklet, so the plugin's Electron partition never holds their Plaud session
cookies. The windowless background refresh authenticates only via those cookies,
so it can never succeed for SSO accounts. On expiry the auth-pause Reconnect
notice opened the embedded email sign-in window, where Google and Apple logins
dead-end.
- Route Reconnect by account type: a session with no stored WRT (the SSO/paste
signature) opens the browser + bookmarklet + paste flow instead of the embedded
window. BrowserSignInModal gains an inline Paste button so the reconnect
completes from the notice. Email sign-ins are unchanged.
- Stop the proactive refresh re-arming for an exhausted, due session that has no
stored WRT, so an un-refreshable SSO session no longer retries hourly against
Plaud's rate-limited endpoint. Email sessions (WRT present) keep retrying, since
their failures may be transient.
- A post-reconnect continuation runs on both reconnect paths so the backfill
'Reconnect and retry' notice actually retries after an SSO paste.
- Guard the paste handler so a throwing token store cannot surface an unhandled
rejection.
- Document in the sign-in help and README why SSO accounts reconnect about daily.
Refs #64. Not a full resolution; unattended SSO refresh needs the official OAuth
path, tracked separately.
* Guard the SSO browser-reconnect with the single-flight sign-in gate
CodeRabbit finding on PR #67: openBrowserReconnect opened a BrowserSignInModal
with no concurrency guard, so a second Reconnect (a stacked notice, or the
settings tab) could open a rival modal and race the token write on the shared
partition. Reuse the existing reauthInFlight gate the embedded window and the
silent refresh already share: reject a concurrent open with the same 'Plaud
sign-in is already open.' notice, hold it for the modal's lifetime, and clear it
from the modal's onClose so paste-success, cancel, and dismiss all release it.
Adds the plaud-industry property (shipped in 0.30.0) to the enumerated optional frontmatter fields in the feature list; it was omitted when the field was added in #65.
Follow-up to #61: suppress plaud-category when it duplicates plaud-template, capture Plaud industry_category as a separate plaud-industry property. Closes#61.
* Fix session refresh setTimeout overflow; background refresh never opens a window
Root cause (debug-log proven): the proactive refresh timer computes its delay from the stored token's exp. A long-lived token (a 137-day exp was observed) produced a delay above the 32-bit setTimeout ceiling (2,147,483,647 ms), so the timer fired immediately instead of months out. The spurious refresh failed, retried on the 5/15/30/60-minute backoff, and every retry opened a hidden sign-in window whose login page leaked web.plaud.ai popup tabs into the default browser, roughly hourly.
Fixes:
- Clamp the scheduled delay to REFRESH_MAX_DELAY_MS (20 days), safely under the ceiling. New pure computeRefreshDelay in plaud-refresh.ts, unit tested.
- Skip the proactive refresh while the stored token still has life beyond the 5-minute lead (new isRefreshDue guard in runScheduledRefresh). An early clamped fire re-arms without refreshing and resets the failure streak.
- The background refresh is windowless-only: tryRefreshSession no longer falls back to a hidden openPlaudLogin window. On failure the plugin pauses and shows the one-click Reconnect notice. The dead headless machinery in plaud-login.ts is removed; only a user click can open the sign-in window.
Closes#41.
* Bound the windowless refresh POSTs with a 30s abort timeout
CodeRabbit finding on PR #63: session.fetch had no timeout, so a stalled request would hold refreshInFlight/reauthInFlight forever and block every later refresh plus the manual Sign in until restart. Now each POST carries AbortSignal.timeout(30s); an abort rejects the post, performNetRefresh catches it and returns null, and the refresh fails cleanly. Matters more now that the windowless path is the only background refresh.
Reads summary metadata (model, template, headline, category, summary-id) from the /file/detail response on the newer auto_sum_note path via a scoped by-name resolver, restoring the plaud-* frontmatter fields. Adds a drift debug-signal and a golden-fixture regression test.
Adds a global setting (default on) so an overwrite re-import keeps any frontmatter property the user or their downstream automation added that the plugin does not manage. Before this, formatFrontmatter rebuilt the block from reserved plugin keys plus declared extra-frontmatter rows, so an undeclared foreign key was dropped on overwrite. The 0.25.0 per-property preserve (#53) only covered declared keys (an allowlist); this closes the gap for keys never entered in settings.
Mechanism: a passthrough in formatFrontmatter after the custom-rows loop carries through any existing key that is neither reserved nor already emitted. Fires only on the overwrite path (existingValues present); reserved keys still refresh; a declared row still controls its key. To let the plugin manage a specific key instead, declare it as an extra-frontmatter row with preserve off.
Also fixes a pre-existing gap the passthrough surfaced: plaud-placeholder (the stub marker) was missing from RESERVED_FRONTMATTER_KEYS, so it was wrongly preserved onto real content. Now reserved.
Threaded settings -> buildImportRuntimeOptions -> NoteWriterOptions -> NoteWriter ctor -> FormatMarkdownOptions -> formatFrontmatter; toggle in both the imperative (1.12) and declarative (1.13+) settings paths. lint + build + 983 tests green (7 new). Codex pre-commit: no findings.
* Salvage #43 durability: shared duplicate-handling const, override marker, regression test
Non-functional follow-up to 0.25.2, which shipped the user-facing #43 copy fix. Adds the two durability pieces PR #51 had that 0.25.2 dropped, using 0.25.2's current wording (no copy revert):
- Extract DUPLICATE_HANDLING_NAME/DESC consts so the declarative (1.13+) and imperative (1.12) settings paths share one literal and cannot drift.
- Comment the makeWriter skip/overwrite override as the #43 safe fallback to preserve across refactors.
- Regression test asserting NoteWriter throws on onDuplicate='prompt' without a callback, so a future refactor routing 'prompt' to a headless writer fails loud instead of stalling.
No behavior change. lint + build + 976 tests green (1 new). Codex pre-commit: no findings.
* Fix code-comment path reference to __tests__/note-writer.test.ts
Addresses Copilot review nit on #57: the makeWriter comment referenced note-writer.test.ts but the file lives under __tests__/. Comment-only, no logic change.
README was not updated when the filter bar, per-recording ignore, empty-state, and Trashed badge shipped in 0.26.0-0.27.2. Document them in the features list, the Import dialog and trashed sections, and the Using it steps.
Plaud's per-record is_trash can arrive as 1/"1" (the list is queried with is_trash=2, a numeric enum), so the strict === true parse left every recording flagged not-trashed and the Trashed badge + Hide-trashed filter inert. Normalize the flag across boolean/number/string encodings.
Auto-advance past fully-hidden pages so the dialog is not left blank when auto-sync imported the newest recordings; honest empty-state message replacing the misleading scroll hint. Filter toggles collapse to one compact Hide: group. Trashed recordings show a Trashed badge when shown.
Adds a 'Hide updates available' toggle to the import dialog (default off; update-available rows are actionable so shown by default). Relabels 'Show trashed' to 'Hide trashed' so all four filter toggles share one polarity (checked = hidden).
Import dialog gains a filter bar (hide already-processed, hide ignored, show trashed) and a per-row ignore button. Ignored recording ids persist in settings and are excluded from background auto-sync. Closes#54.
Rename the Duplicate handling setting to 'Duplicate handling for manual imports' and state in both its description and the automatic sync description that background sync never prompts and ignores this setting. Copy-only change; auto-sync already runs headless with version-marker skip/update. No logic change.
* Fix card image showing 'could not be found' by repointing Plaud's inline embed (#52)
Plaud's AI summary markdown carries an inline embed authored by Plaud's
server () that only
resolves inside Plaud. The importer already downloads that asset into the
note's -assets folder and links it in the managed section, but copied the
summary body verbatim, leaving the broken inline embed alongside the good one.
- New exported pure fn rewriteInlineSummaryEmbeds(content, urlToLocalPath)
repoints an inline markdown image embed at its downloaded local copy as an
![[...]] wikilink on a map hit; unmapped embeds and existing wikilinks are
left alone.
- A summaryEmbedRewrites map (asset.url -> local path) is populated at the two
image-save sites and applied in the existing vault.process pass before the
managed section is rebuilt.
- Tests: the pure fn plus key-match (exact, query-suffix) and markdown-only
scope cases. The map-population + vault.process wiring is validated by a
hands-on smoke test in a real vault (the obsidian mock is intentionally
inert), NOT jest.
* Add one-time command to repair card links in older imports (#52)
The import-time fix only repoints card embeds on (re)import, so notes
imported before it keep the broken inline embed. Adds a user-invoked (never
automatic) command 'Repair card image links from older imports (one-time)'
that scans this plugin's notes and repoints each broken card poster embed at
the card image already in the note's -assets folder.
- attachment-importer.ts: pure isLocalCardImage() and repairLegacyCardEmbeds()
(conservative: only summary_poster embeds, only when exactly one local card
exists; idempotent). Clearly banner-marked for removal in a future version.
- main.ts: the command + a scan handler scoped to plaud-id notes; reports a
count and flags notes whose card was never downloaded for a re-import.
- Tests for both pure helpers (match boundaries, no-card, ambiguous, idempotent).
Vault scan is validated by hands-on smoke test (obsidian mock is inert).
* Address CodeRabbit on repair command: recompute inside vault.process, add disposed + single-flight guards
- The repair now recomputes the rewrite inside the vault.process callback on
the FRESH content, so a concurrent edit between read and write is never
clobbered; the read is used only to gate whether a write is needed.
- Added a this.disposed check at the top and inside the scan loop so an
unloaded plugin stops writing, and a repairInFlight single-flight guard so a
double-invoke cannot run two bulk scans at once.
* Fix and instrument the windowless session refresh (beta #5)
The silent-refresh net path failed silently and always fell back to a
hidden sign-in window; that window leaked a visible browser window and the
failure backoff reopened it hourly (10+ overnight).
- buildPartitionPost now sends credentials:'include' on session.fetch. The
two refresh POSTs authenticate only with the partition's httpOnly Plaud
cookies, and fetch's default same-origin credentials mode sent none, so
every refresh 401'd and fell back. Most-likely root cause.
- performNetRefresh takes an optional log sink and reports the exact failure
(which step, HTTP status, envelope status, body snippet), so one debug run
pinpoints the cause instead of failing silently. Never logs token values.
- tryNetRefresh logs when no session.fetch transport exists and passes the
logger through.
Not yet hands-on validated: needs a 'Refresh session now' run with debug on
to confirm the credentials fix works or surface the real cause.
* Address CodeRabbit: redact tokens in logged bodies, make log sink non-throwing
- bodySnippet redacts JWT-shaped substrings before logging, so a stray
token in a failing auth-endpoint body can never reach the debug log.
- performNetRefresh wraps the injected log sink in try/catch so a throwing
logger cannot break its documented never-throws contract (it runs in the
background refresh timer). Tests for both.
* Add extra frontmatter with token mapping and per-property preserve
New Extra frontmatter setting: an expanding rowset of key / value /
preserve properties written to each imported note. Values expand the
same {{ }} tokens as the other fields (date set, {{title}},
{{plaud-folder}}) plus content tokens from the recording and its AI
summary ({{category}}, {{headline}}, {{duration}}, {{language}},
{{template}}, {{model}}).
Per-property preserve: on a re-import, a preserve-on property keeps the
note's existing value; preserve-off refreshes it. plaud-id is locked out
of the system as the note's immutable identity.
- note-writer.ts: CustomFrontmatterRow/Context types,
expandCustomFrontmatterValue, extractFrontmatterValues, keyed-map
formatFrontmatter with merge, renderCustomFrontmatterPreview; reorder
NoteWriter.writeNote to read the existing note before building markdown
so the merge sees current values (shared buildNote helper).
- main.ts: customFrontmatter setting + default, renderCustomFrontmatterControl
rowset UI with token palette and live preview, registered in both
display paths, threaded through buildImportRuntimeOptions so manual and
auto-sync behave identically.
- styles.css: rowset layout; pre-wrap on the multi-line preview.
- Tests for the expander, frontmatter reader, merge/preserve, and preview.
Concept proposed by @jtsmith2 in PR #50 (closed unmerged); rebuilt with
tests, content tokens, and preserve.
* Address Codex review and rework the Extra frontmatter settings UI
Codex pre-PR review fixes (note-writer.ts):
- yamlScalar the frontmatter key so a custom name needing quoting (a
colon, a leading digit) cannot break the block; no-op for built-in keys.
- expandCustomFrontmatterValue now resolves tokens in a single pass, so a
substituted value that itself contains braces is not re-interpreted.
- extractFrontmatterValues captures block scalar (| / >) bodies so a
preserved multi-line value round-trips instead of collapsing to the
indicator. Tests added for all three.
Settings UI (main.ts, styles.css): the control defaulted to zero rows with
the add button buried among token buttons, so there was no visible way to
create a property. Now it always shows one editable row, adds column
headings, a prominent Add property button on its own line, and a labeled
token palette below. Blank rows are not persisted.
* Address CodeRabbit review: reserve plugin keys, parse quoted keys, clear focus ref
- note-writer.ts: a custom row can no longer override a plugin-managed
frontmatter field. RESERVED_FRONTMATTER_KEYS lists every key the plugin
emits; a matching custom row is skipped, so extra properties only add,
never override identity, the auto-sync cursor, or the built-in fields.
- extractFrontmatterValues now parses quoted keys (a name with a colon or
spaces, which yamlScalar emits) and unquoted keys containing spaces, so
preserve round-trips for those keys instead of silently regenerating.
Added parseLeadingQuotedScalar.
- main.ts: clear lastFocusedValue when the rows re-render, so a token
button never inserts into a detached input after add/remove.
- Tests for reserved-key skip, quoted/space key parsing, and preserve
round-trip for those keys.
* Polish the Extra frontmatter settings UI
- Seed a real, editable default row (Recording Source: Plaud Importer) so
the setting is self-documenting on a fresh install.
- Drop placeholder text; column headings (Property / Value / Preserve)
label the fields, so nothing reads as a fake default.
- Lay the header and every row's inputs out as direct cells of one CSS
grid, so each heading is left-aligned over its column (earlier separate
grids drifted). Preserve is a checkbox under its heading; blank rows are
never persisted.
The subfolder template defaulted to empty, filing every note flat into the output folder and surprising users who expected a dated layout. Default fresh installs to a year/month tree instead. Only new installs are affected: an existing saved value (including a deliberately cleared one) is untouched, and the note writer still supports a flat layout when the template is empty.
A recording is not importable until Plaud web/app has opened and its cloud has finalized it. Add a Troubleshooting bullet so users who hit the sync delay know it is a Plaud-side step, not a plugin fault.
First beta of Release A (one-click reconnect) + Release B (silent session refresh). Ships to BRAT testers before any production tag; the refresh path needs live Plaud cookies and is validated hands-on, not in CI.
Release B: silent Plaud session refresh. Direct windowless refresh (POST /auth/refresh-user-token then the workspace/token mint over the persist:plaud-importer partition) as primary, hidden-window re-capture as fallback. Shared fail-safe replaces the stored token only on a fresh typ-WT. 906 tests green. Not yet hands-on validated; ships to BETA first.
* Add one-click reconnect on auth-pause and backfill failure (issue #5)
When background auto-sync pauses on an expired or missing token, the pause notice now carries a Reconnect action that runs sign-in and resumes auto-sync on success, instead of pointing the user at settings. The Backfill version markers command does the same on an auth failure (Reconnect and retry).
showActionNotice builds a sticky, keyboard-activatable notice; a reauthInFlight guard blocks concurrent sign-in windows; onunload hides any open action notice so its handler cannot run after unload; reconnectFromNotice owns its result messaging for every outcome (success, closed, error).
* Address review: reconnect messaging, notice cleanup, focus style
CodeRabbit/Copilot findings on #39: suppress the misleading 'sign-in closed' notice when reauthenticate short-circuits on the concurrent-signin guard; drop notices from the tracking Set on manual dismiss (messageEl click); add a :focus-visible ring for the keyboard-focusable action span; correct the reauthenticate doc comment to note the single 'already open' notice it now shows.
scripts/check-icons.mjs validates every hardcoded Lucide icon id in main.ts (the RIBBON_ICON_CHOICES list, DEFAULT_RIBBON_ICON, and literal setIcon ids) against the offline lucide-static package, chained into npm run lint. An unknown id (like the shipped #34 bug 'tape' vs 'cassette-tape') now fails CI instead of rendering a blank icon. Strips comments before extraction and preflights missing main.ts / lucide-static with clear messages. Caveat noted in-script: catches ids that exist in no Lucide, not per-version drift vs Obsidian's bundled set.
Adds a {{plaud-folder}} token to the subfolder template that mirrors Plaud folders into the vault; unfiled recordings file under _unfiled (issue #16 follow-up).
* Add {{plaud-folder}} subfolder token (issue #16 follow-up)
The subfolder template supports a {{plaud-folder}} token that expands to the recording's Plaud folder name, so {{plaud-folder}}/{{YYYY}} mirrors Plaud folders into the vault tree. Plaud folders are flat, so a slash in a name is flattened to the replacement char, not treated as nesting; an unfiled recording files under _unfiled; a multi-folder recording uses the first.
Threads the resolved folder names (already on formatOptions.folders) into NoteWriter.resolveTargetPath. The placeholder path resolves no folders and lands in _unfiled; cross-folder dedup (existingPathForPlaudId + migrateExistingNote) migrates such a stub to the real folder on the later successful import (covered by a new integration test).
Build, lint, and 880 tests green. Codex review clean.
* Address PR #37 review: make {{plaud-folder}} expand to _unfiled inline
CodeRabbit/Copilot: the docs promised {{plaud-folder}} files an unfiled recording under _unfiled, but the old code only bucketed when the WHOLE subfolder resolved empty; {{plaud-folder}}/{{YYYY}} silently dropped the empty segment and mixed unfiled recordings into the date folder. An unfiled recording now expands the token to a literal _unfiled segment inline, so it is always visibly grouped (_unfiled, _unfiled/2026, 2026/_unfiled), matching the docs. Removed the now-redundant _unfiled result-empty bucket; the _untitled title bucket is unchanged.
Also: added a multi-folder writeNote test asserting folders[0] is used (CodeRabbit nitpick), and corrected two stale comments (resolveTargetPath same-path, note-name token vocabulary). Build, lint, 881 tests green. Codex review clean.
* Add {{title}} subfolder token and configurable forbidden-char replacement (issue #30 follow-up)
The subfolder template now supports {{title}} for folder-note layouts. The title has any leading date stripped (parity with the note name) and is sanitized into a single legal folder segment: path separators are flattened, forbidden characters replaced, reserved device names prefixed, trailing dot/space stripped, and length clamped, so an arbitrary recording title can never abort folder resolution. A title that reduces to empty files under _untitled instead of collapsing into the output root.
New forbidden-character replacement setting (default '-') chooses the stand-in for a character a file name or folder cannot hold, applied to both note names and folders. Validated to a safe single char via isValidReplacementChar, enforced in the settings UI, coerced on load (so a hand-edited data.json cannot feed an unsafe char to the rename command or imports), and guarded again in the NoteWriter constructor.
sanitizeFilename and sanitizeFolderSegment take a replacement param (function replacer, so a '$' is literal) plus an emptyFallback param. Build, lint, and 867 tests green. Codex review clean after fixes for title-segment assertion aborts, the rename-path validation gap, and preview/import char desync.
* Address PR #35 review: harden sanitizer replacement, align Notice text
Copilot: sanitizeFilename (exported) and sanitizeFolderSegment now coerce an unsafe replacement to '-' via isValidReplacementChar before use, so a caller that bypasses settings/NoteWriter validation cannot reintroduce a forbidden character. Production callers already pass a validated char; this is defense-in-depth. Adds a test.
Copilot: the invalid-replacement Notice now lists backslash and control characters, matching isValidReplacementChar's actual rejection set.
The ribbon icon choice labeled Cassette tape used the Lucide id 'tape', which does not exist in Lucide, so selecting it left the ribbon with no icon. Corrected to 'cassette-tape'.
Verified all 12 ids in RIBBON_ICON_CHOICES against the Lucide registry over the network: 'tape' returns 404, 'cassette-tape' and the other 11 all exist. Only this one id was wrong.
* Add configurable datetime frontmatter property (issue #32)
A new setting writes a datetime: property to each imported note, formatted with the same {{ }} Moment tokens as the folder and note-name fields. Empty by default (no property emitted); a non-empty template turns it on, mirroring the subfolder field's on/off model.
date: stays frozen at YYYY-MM-DD for Dataview stability; datetime: is a separate additive property so the time can be recorded in any format (24h, 12h, ISO 8601 with UTC offset via {{Z}}). Value is machine-local time, the same basis as date:.
Time tokens (Hour, Minute, Second, AM/PM, Offset) are added to the shared insert-token buttons, so they work in the subfolder and note-name templates too. New formatDatetime export is shared by the frontmatter builder and the settings live preview. The datetime line is also emitted in the placeholder-note path so stubs match real notes.
Build, lint (eslint + obsidianmd + check-submission), and 839 tests green. Codex review clean.
* Address PR #33 review: fix misleading ISO example, add placeholder datetime tests
Copilot: the ISO datetime example hard-coded a +00:00 offset, misleading for users outside UTC. Show it as a local-offset placeholder (±hh:mm) instead; the live preview renders the real offset.
CodeRabbit: add formatPlaceholderMarkdown datetime coverage (emit when set, quoted; omit when empty) so the placeholder path cannot silently desync from formatFrontmatter.
The developer-dashboard scan resolves Obsidian's re-exported moment as the
error/any type, tripping @typescript-eslint/no-unsafe-* on the date formatter.
Pin the factory to moment's own module type (typeof import('moment')) at that
one boundary, add moment as an explicit devDependency so its types resolve, and
turn off no-unnecessary-type-assertion in config (the cast is redundant only in
environments where moment already resolves). Also bump eslint-plugin-obsidianmd
to 0.4.1 (was stuck at 0.3.0 despite the ^0.4.0 pin). No behavior change; 834
tests pass. Verified the cast clears no-unsafe under a simulated any-moment.
The note-name section described rejection as forbidden characters only; align
it with isValidNoteNameTemplate and the in-app failure messages, which also
reject reserved device names, leading/trailing dot or space, and over-200-char
results. Docs-only.
Issue #30: both the subfolder and note-name templates now use real Moment.js date formatting via the externalized obsidian moment, with one shared vocabulary so every token works in both fields. Existing templates migrate output-preserving under a settingsVersion gate. Adds live preview + insert-token buttons, and hardens Windows path-component safety (forbidden chars, reserved device names including with extensions, control chars, trailing dot/space, and length) symmetrically across the note-name and subfolder paths.
Bundles the four merged features that had accumulated in [Unreleased]:
- Date-prefix for dateless recording titles.
- Configurable note-name template ({{...}} tokens + {{title}}).
- Rename cascade: rename a note and its -assets folder together (command,
context menu, file-explorer listener), and auto-migrate a note when the
recording is renamed in Plaud.
- Optional Plaud title write-back on rename (autoUpdatePlaudTitle, default off,
confirm-first), the plugin's only cloud write, with sign-in-and-retry on an
expired token.
README updated for all four, including the new opt-in write to Plaud.