Compare commits

...

4 commits

Author SHA1 Message Date
WiseGuru
76243ecdf4 Release flow: test on published alpha/beta artifacts, bump only at release
All release testing now happens against the published -alpha/-beta
prerelease assets installed in the test vault (attested CI builds), and
cutting the stable release is only the version bump + bare tag on the
same source. Doc-only commits may ride between the last prerelease and
the stable tag; anything affecting the built artifact means a new
prerelease round.

Updated: RELEASING.md (TL;DR, pre-flight, "The alpha/beta channel"),
release-checklist SKILL.md (Phases 2/3/5 install the prerelease via
gh release download; release:prep stays as the mid-development path),
CHECKLIST.md intro, CLAUDE.md summaries. Also corrected RELEASING.md's
stale description of version-bump.mjs (it records every version, not
only floor changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:34:32 -07:00
WiseGuru
42f0a43c62 Resolve the 1.2.1 review-bot no-unsafe-* warnings at the root (type parity)
All ~30 warnings traced to the bot's lint environment resolving types
differently from local, not to unsafe code (DEVCONFLICTS.md finding 10):

- tsconfig lib was ES2016 while the code uses ES2017-ES2019 APIs
  (Object.entries/values/fromEntries, padStart, Promise.finally); the
  test suite's auto-included @types/node masked it locally. lib is now
  ["DOM", "ES2019"] with types: [] so a mismatch fails npm run build.
- moment's typings don't resolve in the bot's environment (obsidian's
  re-export types via the moment package). All calls now go through
  formatMoment in src/time.ts, a narrow structural alias.
- secrets.ts needed as-BufferSource assertions on TS 5.7+ that older TS
  flags as unnecessary; restructured (inferred ArrayBuffer-backed byte
  helpers, BufferSource param, one 32-byte copy) to need none on any
  TS version. Crypto behavior unchanged.
- The two flagged as-Record casts became an isRecord type predicate.

Docs: CLAUDE.md "Review-bot type environment" gotcha, RELEASING.md
parity section (plus the pre-release docs from the workflow change),
DEVCONFLICTS.md finding 10, ROADMAP.md Unreleased entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:55:52 -07:00
WiseGuru
01e3fb8d75 Release workflow: publish suffixed tags (alpha/beta) as GitHub prereleases
A tag containing a "-" now stamps its version into the *published*
manifest.json only (master stays at the last stable version) and sets
prerelease: true, so the official updater and community review never see
it while BRAT / manual install can. Process docs: RELEASING.md
"Pre-release (alpha/beta) builds".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:55:24 -07:00
WiseGuru
437aa5e050 Lint: mirror the Obsidian review bot to catch what slipped past 1.2.0
Local npm run lint now enforces the three classes the community-review bot
caught only at submission:
- type-checked @typescript-eslint rules (no-deprecated, no-unsafe-*,
  no-unnecessary-type-assertion) scoped to src/
- no-unsupported-api (Obsidian API newer than manifest minAppVersion),
  cherry-picked from a 0.4.1 alias since the pinned 0.1.9 base lacks it
- noInlineConfig, so an eslint-disable can no longer silence a rule

Base plugin stays pinned at 0.1.9 (0.4.x's sentence-case diverges from the
bot); only the single no-unsupported-api rule is taken from the alias.
Docs: CLAUDE.md, RELEASING.md, release-checklist SKILL.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:42:06 -07:00
18 changed files with 346 additions and 68 deletions

View file

@ -1,6 +1,6 @@
# ReWrite (Voice Notes) release feature checklist
# ReWrite (Voice Notes) — release feature checklist
Manual pass to run in a **scratch** Obsidian vault against a fresh build (see `npm run release:prep`). This is the source of truth for pre-release verification; the old checklist in `obsidian-voice-notes-spec.md` is retired.
Manual pass to run in a **scratch** Obsidian vault against the artifact under test — for a release gate, the published `-alpha`/`-beta` prerelease assets installed into the vault (see `docs/RELEASING.md`, "The alpha/beta channel"); for mid-development iteration, a fresh local build via `npm run release:prep`. This is the source of truth for release verification; the old checklist in `obsidian-voice-notes-spec.md` is retired.
Runnable standalone (no Claude Code needed). For each item, do the action and record the outcome on its `Result:` line as PASS / FAIL / SKIP plus any notes. Scope the pass to what changed, but always run **Core** and the clean-load check.
@ -119,7 +119,7 @@ Keep this file current: when a feature is added or changed, add or update its it
- [ ] Settings "Auto-ingest folders": Add opens the rule popup; the template dropdown lists only newFile templates; rules list with enable toggle / Edit / Delete. Result: ____
- [ ] `Process auto-ingest folders` processes each audio file in an enabled folder with that rule's template, one at a time, behind a sticky Notice with Cancel. Result: ____
- [ ] On success each recording is moved to the attachments location and its note's `![[embed]]` still resolves; a re-run does not reprocess it. Result: ____
- [ ] A file that fails stays in the ingest folder and is retried next run; the summary Notice reports processed/failed counts **and** a second sticky Notice lists the concrete failure reason per file (first 5, then "and N more"). Result: ____
- [ ] A file that fails stays in the ingest folder and is retried next run; the summary Notice reports processed/failed counts **and** a second sticky Notice lists the concrete failure reason per file (first 5, then "…and N more"). Result: ____
- [ ] **Ingest folder == recordings/attachments folder** is caught UP FRONT: setting a rule's folder to the same folder recordings are stored in (either the ReWrite Attachments folder, or Obsidian's own attachment folder when ReWrite's is blank) skips the rule with a clear "this folder is also where recordings are stored; point the rule at a different folder" Notice BEFORE any note is created. Result: ____
- [ ] **Move-after-success failure**: if the note is created but the recording can't be moved out, the error says the note was created and to move/delete the source manually (so it isn't reprocessed). Result: ____

View file

@ -1,11 +1,11 @@
---
name: release-checklist
description: Run the ReWrite (Voice Notes) plugin's pre-release verification. Use before tagging a release, or when the user asks to smoke-test / verify the plugin in a real vault. Runs the automated pre-checks (build, lint, test, and the advisory local reviews), installs a fresh build into a test vault, then walks the feature-by-feature CHECKLIST.md so the human effort goes into clicking through features rather than copying files.
description: Run the ReWrite (Voice Notes) plugin's release verification. Use before cutting a stable release, or when the user asks to smoke-test / verify the plugin in a real vault. Runs the automated pre-checks (build, lint, test, and the advisory local reviews), installs the published -alpha/-beta prerelease artifact (or a fresh local build when no prerelease exists yet) into a test vault, then walks the feature-by-feature CHECKLIST.md so the human effort goes into clicking through features rather than copying files.
---
# Release checklist
This plugin has no headless UI test harness, so event-wiring and UI-lifecycle bugs are invisible to `npm run build` / `npm run lint` / `npm test`. This skill closes that gap: it runs every automated check available, installs a fresh build into a test Obsidian vault, then drives a human through a feature-by-feature manual pass.
This plugin has no headless UI test harness, so event-wiring and UI-lifecycle bugs are invisible to `npm run build` / `npm run lint` / `npm test`. This skill closes that gap: it runs every automated check available, installs the artifact under test into a test Obsidian vault (the published `-alpha`/`-beta` prerelease for a release gate; a fresh local build for mid-development iteration), then drives a human through a feature-by-feature manual pass.
It is the successor to the stale "Testing Checklist" in `obsidian-voice-notes-spec.md` and the token "manual smoke test" step in `docs/RELEASING.md`. The mechanics of each tool are in `docs/DEV_TOOLING.md`; the tag/push release steps are in `docs/RELEASING.md`. This skill is the front door that sequences them.
@ -19,22 +19,28 @@ npm run review # advisory local code review of the working-tree diff
npm run review:docs # advisory local doc-consistency review
```
- `build`/`lint`/`test` are release blockers if they fail.
- `build`/`lint`/`test` are release blockers if they fail. `npm run lint` is configured to mirror the Obsidian community-review bot for the classes that previously slipped through to submission: the type-checked `@typescript-eslint` rules (`no-deprecated` / `no-unsafe-*` / `no-unnecessary-type-assertion`), `no-unsupported-api` (an Obsidian API newer than `manifest.json`'s `minAppVersion`), and `noInlineConfig` (an `eslint-disable` can no longer silence a rule). If lint fails on one of these, it is doing its job; do not work around it with a disable comment. See RELEASING.md's guideline-conflict checklist.
- The two `review` runs are **advisory** (always exit 0). Read the reports in `docs/claude-scratch/local-review-<mode>-report.md` and surface anything real. They will not block, and they are imperfect (local, quantized model), so treat findings as a first-pass filter, not a gate.
- **If a review report says "(no findings returned)" or looks empty/garbled**, it is almost always a model/tooling issue, not a clean bill of health — see `docs/DEV_TOOLING.md` Gotchas (reasoning models need thinking disabled; the context must fit the prompt; a huge diff truncates). Fix the tooling or note it; do not read empty output as "no problems."
## Phase 2 — Setup check
1. Confirm `dev-tools.config.json` exists at the repo root with a non-empty `releaseVault.vaultPath`. If not, tell the user to copy `dev-tools.config.example.json` to `dev-tools.config.json` and fill it in. A dedicated **scratch** vault is ideal; a real vault is acceptable because `release:prep` only overwrites the three build artifacts (see Phase 3), but never point it anywhere you'd mind a plugin reload.
2. Confirm the working tree holds exactly what the user intends to ship. For a real release you also want the version already bumped (`npm version <patch|minor|major> --no-git-tag-version`) so the manual pass exercises the actual versioned artifact **before** you commit and tag — do not tag first.
1. Confirm `dev-tools.config.json` exists at the repo root with a non-empty `releaseVault.vaultPath`. If not, tell the user to copy `dev-tools.config.example.json` to `dev-tools.config.json` and fill it in. A dedicated **scratch** vault is ideal; a real vault is acceptable because the install only overwrites the three build artifacts (see Phase 3), but never point it anywhere you'd mind a plugin reload.
2. Confirm the working tree holds exactly what the user intends to ship, and that master carries **no version bump**: per `docs/RELEASING.md`, all testing happens on published `-alpha`/`-beta` prerelease artifacts while `manifest.json` stays at the last stable version. The bump + bare tag come only after this skill's go decision.
3. Identify the prerelease to test: the newest `-alpha`/`-beta` tag whose commit matches what the user intends to ship (`gh release list`). If the tree has moved past the latest prerelease, cut a new suffixed tag first (see RELEASING.md) — do not sign off on an artifact that doesn't match master.
## Phase 3 — Build and install
## Phase 3 — Install the artifact under test
**Release gate (a prerelease exists):** install the published, attested prerelease assets into the vault — that exact build is what the stable release will re-version:
```bash
npm run release:prep
gh release download <tag> --repo WiseGuru/ReWrite-Voice-Notes --clobber \
--dir "<vaultPath>/.obsidian/plugins/rewrite-voice-notes"
```
This runs `npm run build`, then copies `main.js` / `manifest.json` / `styles.css` into `<vaultPath>/.obsidian/plugins/rewrite-voice-notes/`. It fails loudly (non-zero exit) on a build error or a bad vault path; surface any failure and stop. It **only overwrites those three files**`data.json` and `secrets.json.nosync` in the target folder are left untouched, so a re-install keeps the user's settings and keys (this is why running against a vault with real data is safe).
**Local iteration (no prerelease yet, or mid-development):** `npm run release:prep` builds the working tree and copies the three files into the same folder. Fine for finding bugs fast; the final pass before a stable tag should still run against the prerelease artifact.
Both paths **only overwrite `main.js` / `manifest.json` / `styles.css`**`data.json` and `secrets.json.nosync` in the target folder are left untouched, so a re-install keeps the user's settings and keys (this is why running against a vault with real data is safe). `release:prep` fails loudly (non-zero exit) on a build error or a bad vault path; surface any failure and stop.
Then tell the user to open the vault and reload the plugin (Settings -> Community plugins -> toggle it off and on, or reload Obsidian), and confirm it loads with no errors in the developer console. **A clean load is itself the first checklist item** — it is the exact step that would have caught the regression that motivated this tooling.
@ -49,6 +55,6 @@ Open `CHECKLIST.md` (in this skill folder) and walk it area by area. For each it
## Phase 5 — Summary and hand-off to release
Save the filled-out run to `docs/claude-scratch/release-checklist-<version>.md` (read `<version>` from `manifest.json`; that folder is gitignored). Then give a clear **go / no-go**: list every FAIL with its symptom, note any SKIP and why, and state plainly whether the build is releasable. Do not soften a FAIL into a pass.
Save the filled-out run to `docs/claude-scratch/release-checklist-<version>.md` (name it after the tag under test, e.g. `1.3.0-alpha`; when testing a local `release:prep` build instead, use `manifest.json`'s version. The folder is gitignored). Then give a clear **go / no-go**: list every FAIL with its symptom, note any SKIP and why, and state plainly whether the build is releasable. Do not soften a FAIL into a pass.
On a **go**, the actual release (commit the bump + rolled `docs/ROADMAP.md`, tag the bare version, push, let CI build the attested assets) follows `docs/RELEASING.md` — this skill stops at the go/no-go decision; it does not tag or push.
On a **go**, the actual release (bump `npm version <patch|minor|major> --no-git-tag-version`, commit the bump + rolled `docs/ROADMAP.md`, tag the bare version, push, let CI build the attested assets) follows `docs/RELEASING.md`. The bump commit (plus doc-only commits) is the only permitted delta between the tested prerelease and the stable tag; any artifact-affecting change means a new prerelease round. This skill stops at the go/no-go decision; it does not tag or push.

View file

@ -2,6 +2,10 @@ name: Release
# Build, attest provenance, and publish release assets when a version tag is pushed.
# Obsidian tags are bare versions with no leading "v" (e.g. 1.0.0).
# A tag carrying a pre-release suffix (a "-", e.g. 1.3.0-alpha / 1.3.0-beta.1) is
# published as a GitHub *prerelease*: GitHub's "latest release" (what Obsidian's
# updater reads) excludes prereleases, so the official channel never sees it, while
# BRAT / manual install still can. See docs/RELEASING.md.
on:
push:
tags:
@ -28,6 +32,33 @@ jobs:
- name: Install dependencies
run: npm ci
# A tag with a "-" (e.g. 1.3.0-alpha) is a pre-release channel.
- name: Determine release channel
id: channel
shell: bash
run: |
if [[ "${{ github.ref_name }}" == *-* ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi
# Master's manifest.json stays at the last stable version during alpha/beta
# testing, so stamp the tag's version into the *published* manifest only.
# (Stable tags publish manifest.json as committed; this step is skipped.)
- name: Stamp pre-release version into manifest
if: steps.channel.outputs.prerelease == 'true'
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("manifest.json", "utf8"));
m.version = process.env.TAG;
fs.writeFileSync("manifest.json", JSON.stringify(m, null, "\t"));
'
- name: Build
run: npm run build
@ -44,6 +75,7 @@ jobs:
with:
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
prerelease: ${{ steps.channel.outputs.prerelease }}
files: |
main.js
manifest.json

View file

@ -59,7 +59,7 @@ npm run release:prep # build + copy main.js/manifest.json/styles.css into a scr
Verification is `npm run build && npm run lint && npm test` (CI parity) plus the manual feature pass driven by the **`release-checklist` skill** ([.claude/skills/release-checklist/](.claude/skills/release-checklist/); see [docs/DEV_TOOLING.md](docs/DEV_TOOLING.md)), which replaced the stale checklist in [obsidian-voice-notes-spec.md](obsidian-voice-notes-spec.md).
To publish a new version, follow [docs/RELEASING.md](docs/RELEASING.md) (version bump + bare-version tag + CI-built, attested assets; do not hand-upload).
To publish a new version, follow [docs/RELEASING.md](docs/RELEASING.md). The flow is alpha/beta-first: tag `<next-version>-alpha` (no version bump on master), test the published prerelease artifact in a real vault via the `release-checklist` skill, iterate with further suffixed tags as needed, and only on a green pass bump the version + push the bare-version tag (CI-built, attested assets; do not hand-upload). The bump commit (plus doc-only commits) is the only permitted delta between the tested prerelease and the stable tag; artifact-affecting changes mean a new prerelease round.
CI ([.github/workflows/lint.yml](.github/workflows/lint.yml)) runs `npm ci`, `npm run build`, `npm run lint`, and `npm test` on Node 20.x and 22.x for every push/PR.
@ -78,8 +78,8 @@ Two things make `src/` runnable under Vitest even though it targets the Obsidian
- Entry: [src/main.ts](src/main.ts) → bundled to `./main.js` at repo root (the file Obsidian loads). Kept minimal: settings load, ribbon icon, two commands, settings tab, plus a single `activeQuickRecord` ref so the Quick Record command can toggle.
- Bundler: [esbuild.config.mjs](esbuild.config.mjs). `obsidian`, `electron`, all `@codemirror/*`, all `@lezer/*`, and Node built-ins are marked `external`. Never import other runtime deps without bundling them in.
- Release artifacts are `main.js`, `manifest.json`, and `styles.css` at the repo root. Do not commit the generated `main.js`.
- TypeScript config ([tsconfig.json](tsconfig.json)) is strict: `noImplicitAny`, `strictNullChecks`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `baseUrl: "src"`. Target ES6, module ESNext, lib DOM + ES5/6/7 only. No Node lib, so don't reach for Node APIs in plugin code.
- ESLint ([eslint.config.mts](eslint.config.mts)) layers `eslint-plugin-obsidianmd`'s recommended rules on top of `typescript-eslint`. These rules encode Obsidian-specific correctness checks; respect them rather than disabling.
- TypeScript config ([tsconfig.json](tsconfig.json)) is strict: `noImplicitAny`, `strictNullChecks`, `noImplicitReturns`, `noUncheckedIndexedAccess`, `useUnknownInCatchVariables`, `baseUrl: "src"`. Target ES6, module ESNext, lib DOM + ES2019, `types: []`, `skipLibCheck`. `lib` must stay matched to the runtime APIs the code actually uses (ES2019 covers `Object.entries`/`values`/`fromEntries`, `padStart`, `Promise.finally`; raise it deliberately if you adopt a newer API — Obsidian's WebViews support far newer). `types: []` is load-bearing: without it tsc auto-includes the test suite's `@types/node`, which silently supplied the ES2017+ types the old ES7 lib lacked, and the mismatch surfaced only as `no-unsafe-*` warnings from the Obsidian review bot (see the "Review-bot type environment" gotcha). No Node lib or types, so don't reach for Node APIs in plugin code.
- ESLint ([eslint.config.mts](eslint.config.mts)) layers `eslint-plugin-obsidianmd`'s recommended rules on top of `typescript-eslint`. These rules encode Obsidian-specific correctness checks; respect them rather than disabling. The config is deliberately tuned to **mirror the Obsidian community-review bot** for the classes that once slipped through to submission (see [docs/RELEASING.md](docs/RELEASING.md)): it enables the type-checked `@typescript-eslint` rules (`no-deprecated`, `no-unsafe-*`, `no-unnecessary-type-assertion`) scoped to `src/`; cherry-picks `no-unsupported-api` from a `0.4.1` alias of the plugin (`obsidianmd-latest`, since the pinned `0.1.9` base predates that rule) so an Obsidian API newer than `manifest.json`'s `minAppVersion` fails lint locally; and sets `noInlineConfig` so an `eslint-disable` comment can never silence a rule (the bot forbids disables). The base plugin stays pinned at `0.1.9` because `0.4.x`'s recommended `ui/sentence-case` is over-aggressive and diverges from what the bot enforces; only the single `no-unsupported-api` rule is taken from the alias. To reach an API outside the typed/deprecated surface, use a local structural type-alias (e.g. `ScriptProcessorNodeLike` in [src/realtime/pcm.ts](src/realtime/pcm.ts)), never a disable comment.
## Source layout
@ -97,6 +97,7 @@ src/
├── audio-transcode.ts # WebAudio decode + resample to 16 kHz mono PCM WAV (shared by whisper-local and mistral-voxtral)
├── pipeline.ts # transcribe → cleanup → insert orchestrator
├── insert.ts # cursor/newFile/append + {{date}}/{{time}} expansion
├── time.ts # formatMoment: Obsidian's bundled moment behind a narrow structural alias (moment's types don't resolve in the review bot's environment; never call moment directly)
├── ingest.ts # Auto-ingest batch runner: scan rule folders, pipeline each audio file, move on success
├── whisper-host.ts # Spawns/stops a user-supplied whisper-server child process (desktop only; Phase B auto-start + idle-stop)
├── templates-folder.ts # Load templates from a vault folder + populate it with the 10 defaults
@ -148,7 +149,7 @@ src/
1. **Persist audio** (`audio` source only): writes the raw `Blob` to the vault via [src/audio-persist.ts](src/audio-persist.ts) before transcription, so the user keeps the recording even if later stages fail. Path resolution: when `settings.attachmentsFolderPath` is set, the file goes under that folder with manual de-collision (`-1`, `-2`, ...); when empty, the path comes from `app.fileManager.getAvailablePathForAttachment(filename)`, which respects Obsidian's own attachments setting. Filename is `ReWrite-YYYY-MM-DD-HHmmss.<ext>` with the extension derived from the blob's mime type (`webm` / `m4a` / `ogg` / `wav` / `mp3`, default `webm`). Failure is non-fatal: a Notice fires and transcription proceeds. The resolved path is later prepended to the cleaned output as `![[<path>]]\n\n` before insertion.
2. **Transcribe**: `audio``createTranscriptionProvider(profile.transcriptionProvider).transcribe(blob, config)`. Skipped when the source is `paste` or `text` (input passes through). Just before dispatching, `validateRecording(blobSize, durationMs, providerId)` from [src/transcription/limits.ts](src/transcription/limits.ts) throws a friendly per-provider error if the recording exceeds the provider's documented byte or duration cap. Because validation runs after `persist-audio`, the user keeps the saved file and can switch providers + reprocess from the vault.
3. **Cleanup**: `createLLMProvider(profile.llmProvider).complete(systemPrompt, transcript, config)`. The system prompt is the template prompt, optionally augmented with an `## Ad-hoc instructions` block when the wake-name scan ([src/wake-name.ts](src/wake-name.ts)) extracts directives from the transcript, a `## Context` block when `params.contextHint` is non-empty (see Context hint below), a `## Known nouns` block when `plugin.knownNouns` is non-empty (see Assistant prompt and Known nouns sections below), and a `## Note properties` block when the template declares `noteProperties` (see Note properties below). `cleanupTranscript` returns `{ body, properties, title? }`: when the template declares properties OR sets `titleFromContent`, the LLM is asked to emit a leading ```` ```yaml ```` block which `extractFromBlock` parses off the front (the rest is the body); otherwise `properties` is `{}`, `title` is undefined, and `body` is the full output. On error, the provider error propagates unchanged; nothing is written to the clipboard (the earlier clipboard fallback was removed as a sensitive-content exposure, and for `audio` sources the persisted recording is the recovery path).
4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment`, and `{{title}}` expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extracted `properties` are threaded via `InsertParams.properties` and written into the note's frontmatter via `app.fileManager.processFrontMatter` in `newFile` mode only (see Note properties below). An extracted `title` (from `titleFromContent`) is threaded via `InsertParams.title` and shapes the `newFile` filename via the `{{title}}` token / whole-name replacement (see Note title below).
4. **Insert**: `src/insert.ts` routes to `cursor` / `newFile` / `append` per the template. `cursor` falls back to `append` when no editor is active; `append` falls back to `newFile` when no markdown file exists. `{{date}}` / `{{time}}` in filename templates expand via Obsidian's `moment` (reached through `formatMoment` in [src/time.ts](src/time.ts)), and `{{title}}` expands to the LLM-generated title (empty when no title). The modal's per-invocation Destination control overrides `insertMode` / `newFileFolder` / `newFileNameTemplate` via `PipelineParams.destinationOverride`; the override is shallow-merged onto a copy of the template before the insert call, so the template file on disk is never mutated. Extracted `properties` are threaded via `InsertParams.properties` and written into the note's frontmatter via `app.fileManager.processFrontMatter` in `newFile` mode only (see Note properties below). An extracted `title` (from `titleFromContent`) is threaded via `InsertParams.title` and shapes the `newFile` filename via the `{{title}}` token / whole-name replacement (see Note title below).
The pipeline accepts an `AbortSignal` (forwarded to providers) and is consumed by [src/ui/modal.ts](src/ui/modal.ts), [src/ui/quick-record.ts](src/ui/quick-record.ts), and [src/ui/text-source.ts](src/ui/text-source.ts) (the `runTextPipeline` helper for command + editor-menu entry points). Every caller passes the plugin itself as `host: PipelineHost`; `PipelineHost` is a narrow interface ({ `assistantPrompt`, `knownNouns` }) so `cleanupTranscript` can read the loaded vault content without importing `ReWritePlugin` (which would form a circular dep through the UI layer).
@ -348,10 +349,18 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **Keep [src/main.ts](src/main.ts) minimal**: only plugin lifecycle, command registration, settings tab registration. Feature logic belongs in dedicated modules.
- **Defer heavy work**: no long tasks in `onload`. Providers/recorders lazy-init when first used.
- **Network policy**: provider calls go to user-configured endpoints with user-provided keys. No telemetry, no auto-update of plugin code, no `fetch`+`eval`.
- **Releases**: GitHub release tag must exactly match `manifest.json`'s `version` (no leading `v`). Attach `main.js`, `manifest.json`, `styles.css` as individual binary assets (not zipped). This is automated by [.github/workflows/release.yml](.github/workflows/release.yml): pushing a version tag builds the bundle, runs `actions/attest-build-provenance` (GitHub artifact attestations for provenance), and publishes the assets via `softprops/action-gh-release`. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation). **Before publishing any release, follow [docs/RELEASING.md](docs/RELEASING.md)** for the full step-by-step (version bump, tag, push, verify) and the guideline-conflict checklist that keeps the Obsidian community review green.
- **Releases**: GitHub release tag must exactly match `manifest.json`'s `version` (no leading `v`). Attach `main.js`, `manifest.json`, `styles.css` as individual binary assets (not zipped). This is automated by [.github/workflows/release.yml](.github/workflows/release.yml): pushing a version tag builds the bundle, runs `actions/attest-build-provenance` (GitHub artifact attestations for provenance), and publishes the assets via `softprops/action-gh-release`. To cut a release, push a tag named exactly the version; do not hand-upload assets (that loses attestation). A tag carrying a pre-release suffix (any `-`, e.g. `1.3.0-alpha`/`1.3.0-beta`) is instead published as a GitHub **prerelease**: the workflow stamps the tag's version into the *published* `manifest.json` only (master's copy is untouched, so the official updater and community review never see it). This channel is where ALL release testing happens — every stable release is a tested prerelease plus only the version-bump commit (and doc-only changes). See "The alpha/beta channel" section of RELEASING.md. **Before publishing any release, follow [docs/RELEASING.md](docs/RELEASING.md)** for the full step-by-step (prerelease tag, feature pass, bump, bare tag, verify) and the guideline-conflict checklist that keeps the Obsidian community review green.
## Gotchas
### Review-bot type environment (lint parity)
The Obsidian community-review bot runs the same typed ESLint rules our config mirrors, but in a **different type environment**: it uses this repo's `tsconfig.json` while lacking parts of our `node_modules` (`@types/node`, `moment`'s typings) and possibly running an older TypeScript. Anything error-typed there trips `no-unsafe-*` at every use, even though local lint is clean — all ~30 of the 1.2.1 review warnings were this (see [docs/DEVCONFLICTS.md](docs/DEVCONFLICTS.md) finding 10 and the parity section of [docs/RELEASING.md](docs/RELEASING.md)). Three standing invariants:
- **Keep `tsconfig.json`'s `lib` matched to the APIs the code uses; `types: []` enforces it.** With `types: []`, an API newer than the declared lib fails `npm run build` locally instead of being silently typed by the test suite's auto-included `@types/node` (which is exactly how ES2017+ usage slipped past an ES2016 lib before 1.2.1).
- **Never call `moment` (re-exported by `obsidian`) directly; go through `formatMoment` in [src/time.ts](src/time.ts).** `obsidian` itself types fine in the bot's environment, but its `moment` export's type comes from the `moment` package, which does not resolve there, so direct calls are error-typed. The wrapper reaches it through a narrow structural alias (same pattern as `ScriptProcessorNodeLike`).
- **Avoid TS-version-dependent type assertions.** On TS 5.7+ a value declared `Uint8Array` is `ArrayBufferLike`-backed and needs `as BufferSource` for WebCrypto params; on older TS that same assertion trips `no-unnecessary-type-assertion`. Write code that needs no assertion on any version: let byte-helper return types be inferred (a `new Uint8Array(...)` infers `ArrayBuffer`-backed on 5.7+), type params that only feed WebCrypto as `BufferSource`, and copy a foreign library's bytes (`new Uint8Array(raw)`) rather than asserting ([src/secrets.ts](src/secrets.ts) does all three). Never write the generic `Uint8Array<ArrayBuffer>` syntax — it is a hard error before TS 5.7. Prefer a type-predicate narrow (`isRecord`) over an `as Record<string, unknown>` assertion where either would do.
### HTTP and provider requests
- **`requestUrl` multipart bodies are hand-built.** `requestUrl` does not accept `FormData`. [src/http.ts](src/http.ts) exports `buildMultipart(parts)` which produces a `Uint8Array` with a random boundary; transcription adapters (Whisper, Rev.ai) call into it. If you add a multipart-LLM provider, reuse this rather than reaching for `FormData`.

View file

@ -89,6 +89,15 @@ Guideline: "Use `editorCallback` or `editorCheckCallback` for commands requiring
### 9. `app.vault.getFiles()` full-vault scan
[src/ui/audio-source.ts:18](../src/ui/audio-source.ts#L18) iterates all vault files to filter audio by extension. The guideline's "don't iterate all files" advice is specifically about *finding a file by path* (use `getFileByPath`), which this is not — there is no extension index in the API, so a scan is reasonable. Listed only for completeness; not a real conflict.
### 10. 1.2.1 automated-review `no-unsafe-*` warnings (type-environment mismatch)
The 1.2.1 submission drew ~30 warnings (`no-unsafe-assignment/call/member-access/argument/return`, `no-unnecessary-type-assertion`) that local `npm run lint` did not reproduce even after the rule mirror ([eslint.config.mts](../eslint.config.mts)) was in place. Investigation showed none of the flagged code was genuinely unsafe; every warning came from the bot's **type environment** differing from local. The bot's typed lint uses the repo's own `tsconfig.json` but not the repo's full `node_modules`, so a value that types cleanly locally can be error-typed there (and an error-typed value trips `no-unsafe-*` at every use). Three sub-causes:
1. **Declared `lib` was lower than the APIs the code uses.** `tsconfig.json` declared `lib` ES2016 while the source uses `Object.entries`/`values`/`fromEntries`, `String.padStart`, and `Promise.finally` (ES2017ES2019). Locally, tsc auto-included the test suite's `@types/node`, which silently supplied those types; in the bot's environment they were error-typed (~20 of the warnings).
2. **`moment`'s types don't resolve in the bot's environment.** `obsidian` itself types fine there, but its `moment` re-export's type comes from the `moment` package (a transitive dependency), which the bot doesn't have — every direct `moment(...)` call was error-typed (5 warnings across [src/audio-persist.ts](../src/audio-persist.ts), [src/insert.ts](../src/insert.ts), [src/template-guide.ts](../src/template-guide.ts)).
3. **TS-version-dependent assertions.** On TS 5.7+ (local, 5.8) a value declared `Uint8Array` is `ArrayBufferLike`-backed and needs `as BufferSource` to satisfy WebCrypto; on the bot's older TS the same assertion is "unnecessary" ([src/secrets.ts](../src/secrets.ts)).
> **Resolution: Fixed (all three).** `tsconfig.json` now declares `lib: ["DOM", "ES2019"]` with `types: []` (so the `@types/node` masking can never recur — a lib/API mismatch now fails `npm run build` locally) and `skipLibCheck`. All `moment` calls go through `formatMoment` in [src/time.ts](../src/time.ts), a narrow structural alias over Obsidian's bundled moment (same pattern as `ScriptProcessorNodeLike`). The secrets byte helpers were restructured to need no `BufferSource` assertion under any TS version (inferred `ArrayBuffer`-backed return types, a `BufferSource` param, one 32-byte defensive copy of the hash-wasm output), and the two `as Record<string, unknown>` narrows the bot flagged were replaced with an `isRecord` type predicate.
---
## Checked and clean (no conflict found)

View file

@ -2,45 +2,51 @@
How to cut a new release the Obsidian way, without tripping the community-plugin review. Read this before every release.
Releases are automated by [.github/workflows/release.yml](../.github/workflows/release.yml): pushing a version tag builds the bundle, attaches build-provenance attestations, and publishes the GitHub release. Your job is the version bump, the pre-flight checks, and pushing a correctly named tag.
Releases are automated by [.github/workflows/release.yml](../.github/workflows/release.yml): pushing a version tag builds the bundle, attaches build-provenance attestations, and publishes the GitHub release. Your job is the pre-flight checks and pushing correctly named tags.
**Every release rides the alpha/beta channel first.** All testing (the manual feature pass included) happens against published `-alpha`/`-beta` prerelease artifacts; cutting the stable release is then *only* the version bump + bare tag on the same source. Between the last prerelease you tested and the stable tag, nothing that affects the built artifact (`src/`, `styles.css`, `manifest.json` beyond the version, build config, dependencies) may change — doc-only commits may ride along, but any artifact-affecting change means a new prerelease round, not a direct-to-stable commit.
## TL;DR
```bash
# 0. On master, clean working tree, everything you want shipped is committed.
npm run build && npm run lint # must both pass
# 0a. Roll docs/ROADMAP.md: move the Unreleased items into a new
# "### <version><YYYY-MM-DD>" block under Released, leave Unreleased empty.
# 1. Bump version files (no auto commit/tag so we control the message)
# 1. On master, clean tree, everything you want to ship committed. Do NOT bump yet —
# manifest.json stays at the last stable version throughout testing.
npm run build && npm run lint && npm test # must all pass
# 2. Cut a prerelease of the version you are ABOUT to ship
git tag -a 1.3.0-alpha -m "Pre-release 1.3.0-alpha"
git push origin 1.3.0-alpha
# 3. Test the PUBLISHED prerelease artifact in a real vault (the release-checklist
# skill / CHECKLIST.md — see "The alpha/beta channel" below for install options).
# Fixes needed? Commit them, tag 1.3.0-alpha.2 / 1.3.0-beta, re-test. Repeat until green.
# 4. Go: roll docs/ROADMAP.md (Unreleased -> "### <version><YYYY-MM-DD>" under
# Released), then bump and tag the bare version.
npm version patch --no-git-tag-version # or minor / major
# 2. Commit the bump (include the rolled roadmap)
git add manifest.json package.json package-lock.json versions.json docs/ROADMAP.md
git commit -m "1.0.1" # use the new version as the subject
# 3. Tag with the BARE version (no leading v) and push
git tag -a 1.0.1 -m "Release 1.0.1"
git commit -m "1.3.0" # use the new version as the subject
git tag -a 1.3.0 -m "Release 1.3.0"
git push origin master
git push origin 1.0.1
# 4. Watch CI, then verify provenance (see Verify below)
git push origin 1.3.0
# 5. Watch CI, then verify provenance (see Verify below)
```
The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pins `tag-version-prefix=""`, so `npm version` produces a bare tag too if you ever let it tag directly.
The stable tag name must equal `manifest.json`'s `version` exactly (bare, no leading `v`). `.npmrc` already pins `tag-version-prefix=""`, so `npm version` produces a bare tag too if you ever let it tag directly.
## Hard rules (Obsidian requirements)
- **No `v` in the tag.** The release tag must match `manifest.json` `version` character-for-character: `1.0.1`, never `v1.0.1`.
- **Three loose asset files.** `main.js`, `manifest.json`, `styles.css` attached as individual binary assets, never zipped. The workflow does this; do not hand-upload.
- **A new release needs a new version number.** Obsidian's automated review only registers a change when the version increments. Re-pushing the same version does not count as a new submission. Bump the patch/minor/major rather than overwriting a published version. (To address review feedback, update the repo and publish a new GitHub release with an incremented version.)
- **Version format is `x.y.z` only.** Semantic Versioning, no pre-release suffixes (no `1.0.0-beta`, no build metadata). The initial release is `1.0.0`.
- **Stable version format is `x.y.z` only.** Semantic Versioning, no build metadata. The initial release is `1.0.0`. Pre-release suffixes (`1.3.0-alpha`, `1.3.0-beta.1`) are allowed **only** for the alpha/beta channel below, which is deliberately invisible to the official review/updater; a stable tag never carries a suffix.
- **The directory reads `manifest.json` at the HEAD of your default branch** (`master`), not just the release asset. Keep master's `manifest.json` correct and in sync with the released version.
- **`minAppVersion` must be >= the highest `@since` of every Obsidian API you call directly** (anything not behind a runtime feature-detect). Check `node_modules/obsidian/obsidian.d.ts` for the `@since` of new APIs. Example from this project: `FileManager.trashFile` is `@since 1.6.6`, which is why `minAppVersion` is `1.6.6` (raised from 1.4.4, which `FileManager.processFrontMatter` `@since 1.4.4` had driven). The `obsidianmd/no-unsupported-api` lint rule flags a direct call newer than the declared floor. Feature-detected APIs (like `app.secretStorage`) do not raise the floor.
- **`versions.json` maps plugin version -> minAppVersion.** Our [version-bump.mjs](../version-bump.mjs) only adds a new line when the `minAppVersion` value is not already present (i.e. when the floor actually changes). That is valid: Obsidian reads the latest version straight from the release `manifest.json`, and consults `versions.json` only to find the newest plugin version compatible with an older app. If you raise `minAppVersion`, confirm a new `versions.json` entry was written; if you keep it, no new line is expected.
- **`versions.json` maps plugin version -> minAppVersion.** Our [version-bump.mjs](../version-bump.mjs) records an entry for **every** bumped version (it skips only when the version key already exists). It previously added a line only when the `minAppVersion` *value* changed, which silently dropped every release after the first from the compatibility map; that was fixed, so expect one new line per release. Obsidian reads the latest version straight from the release `manifest.json`, and consults `versions.json` only to find the newest plugin version compatible with an older app.
- **Public repo + LICENSE.** The repo must be public to be listed, with a real LICENSE whose copyright holder is correct (this plugin is 0BSD). The README must disclose network use, and `manifest.json` carries `author`, `authorUrl`, and (if you take donations) `fundingUrl`.
## Pre-flight checklist
1. `npm run build` passes (this is `tsc -noEmit` then esbuild production; a type error here is a release blocker).
2. `npm run lint` passes with zero warnings, and `npm test` passes. The local `eslint-plugin-obsidianmd` is looser than the official review bot, so also eyeball the conflict checklist below.
3. Manual feature pass via the **`release-checklist` skill** (`.claude/skills/release-checklist/`), which sequences the whole verification. Its Phase 1 runs the automated pre-checks (`build` / `lint` / `test` plus the advisory `npm run review` and `npm run review:docs` local reviews); then `npm run release:prep` builds and installs `main.js` / `manifest.json` / `styles.css` into your test vault's `.obsidian/plugins/rewrite-voice-notes/` (configure `releaseVault.vaultPath` in `dev-tools.config.json` first — `release:prep` only overwrites those three files, so a vault with real data is safe); then the skill walks `CHECKLIST.md` feature by feature. `CHECKLIST.md` is also runnable standalone by a human without Claude Code. See [DEV_TOOLING.md](DEV_TOOLING.md). Test the actual **versioned** artifact: bump first (`npm version <patch|minor|major> --no-git-tag-version`), then run the pass, then commit + tag.
3. Manual feature pass via the **`release-checklist` skill** (`.claude/skills/release-checklist/`), which sequences the whole verification. Its Phase 1 runs the automated pre-checks (`build` / `lint` / `test` plus the advisory `npm run review` and `npm run review:docs` local reviews); then the **published prerelease artifact** is installed into your test vault's `.obsidian/plugins/rewrite-voice-notes/` (download the three assets from the prerelease page, or use BRAT — the install only replaces `main.js` / `manifest.json` / `styles.css`, so a vault with real data is safe); then the skill walks `CHECKLIST.md` feature by feature. `CHECKLIST.md` is also runnable standalone by a human without Claude Code. See [DEV_TOOLING.md](DEV_TOOLING.md). Sign off on the prerelease build, not a local one: `npm run release:prep` (which builds and copies from the working tree) remains the fast path for iterating *before* a prerelease exists, but the pass that gates the release runs against the attested `-alpha`/`-beta` assets, and the stable bump happens only after the go decision.
4. Update docs for any behavioral change ([CLAUDE.md](../CLAUDE.md), the user-facing [`wiki/`](../wiki/) pages, and the [README](../README.md)), per the doc-maintenance rules in CLAUDE.md.
5. Roll [ROADMAP.md](ROADMAP.md): every item shipping in this release should already have an **Unreleased** entry. Move them into a new `### <version> — <YYYY-MM-DD>` heading at the top of the **Released** archive, and leave `## Unreleased` empty for the next cycle. The version + date must match the tag.
@ -48,9 +54,13 @@ The tag name must equal `manifest.json`'s `version` exactly. `.npmrc` already pi
These are the recurring findings; clear them before tagging. Most are also why the items above exist.
**Local lint now mirrors the bot for the classes that previously slipped through** (the 1.2.0 submission failed on three the local lint did not catch). [eslint.config.mts](../eslint.config.mts) enables: the type-checked `@typescript-eslint` rules (`no-deprecated`, the `no-unsafe-*` family, `no-unnecessary-type-assertion`); `no-unsupported-api` (cherry-picked from a `0.4.1` alias of `eslint-plugin-obsidianmd` since our pinned `0.1.9` base lacks it) so a direct Obsidian API newer than `minAppVersion` is caught locally; and `noInlineConfig` so an `eslint-disable` can never silence a rule. So `npm run lint` failing on these now is the point. When the review bot adds a new rule class we do not catch, add it here the same way (prefer cherry-picking one rule from the alias over adopting `0.4.x`'s recommended config wholesale, whose `ui/sentence-case` is over-aggressive and diverges from the bot).
**The bot's TYPE environment also differs from local, and mirroring the rules alone does not cover that** (the 1.2.1 submission drew ~30 `no-unsafe-*` warnings local lint could not reproduce; see [DEVCONFLICTS.md](DEVCONFLICTS.md) finding 10). The bot lints with the repo's own `tsconfig.json` but not the repo's full `node_modules`: `@types/node` and `moment`'s typings are absent there, and its TypeScript version may lag ours. Anything that is error-typed in that environment trips `no-unsafe-*` at every use. The standing guards: `tsconfig.json` declares `lib` matched to the APIs the code actually uses plus `types: []`, so an API newer than the declared lib fails `npm run build` locally instead of being silently typed by the test suite's `@types/node`; `moment` is only reached through `formatMoment` in [src/time.ts](../src/time.ts) (never called directly from `obsidian`'s re-export); and type assertions whose necessity depends on the TS version (`as BufferSource`) are avoided in favor of code that needs no assertion on any version. If the bot ever flags a `no-unsafe-*` or `no-unnecessary-type-assertion` warning that local lint does not show, suspect a type-resolution difference first: check what supplies the type locally (`npx tsc -noEmit --explainFiles`), rather than assuming the warning is spurious.
- **Plugin `id`**: lowercase letters and hyphens only, must not end in `plugin`, must not contain `obsidian`. Locked once published; do not change it. (Ours is `rewrite-voice-notes`.)
- **No newer-than-minAppVersion APIs**: see the `minAppVersion` rule above.
- **No `eslint-disable` directives.** The bot rejects disabling its rules. If a string trips `ui/sentence-case` (e.g. a random example), pass it through a variable instead of a string literal; the rule only inspects literals.
- **No newer-than-minAppVersion APIs**: see the `minAppVersion` rule above. Enforced locally by `obsidianmd-latest/no-unsupported-api`.
- **No `eslint-disable` directives.** The bot rejects disabling its rules, and `noInlineConfig` makes them inert locally (an attempt to suppress an error just leaves the error). Reach APIs outside the typed/deprecated surface through local type-aliases instead (see [src/realtime/pcm.ts](../src/realtime/pcm.ts)'s `ScriptProcessorNodeLike`). If a string trips `ui/sentence-case` (e.g. a random example), pass it through a variable instead of a string literal; the rule only inspects literals.
- **Popout-window safety**: use `activeDocument` / `activeWindow` instead of `document` / `window`-as-globalThis where a popout could differ; use `window.setTimeout` / `window.clearTimeout` (not bare `setTimeout`); avoid `globalThis` (use `window`). For paired `addEventListener` / `removeEventListener`, capture one document reference so removal targets the same object.
- **No `!important` in [styles.css](../styles.css).** Raise specificity, use CSS variables, or toggle via Obsidian's `el.toggle()` / `hide()` / `show()` (which set inline display) instead.
- **Manifest `description`**: action-focused, <= 250 chars, ends with a period, no emoji.
@ -89,6 +99,29 @@ gh attestation verify /tmp/rel/main.js --repo WiseGuru/ReWrite-Voice-Notes # mus
Also confirm the release page shows the bare tag (`1.0.1`, no `v`) and all three assets.
## The alpha/beta channel (where all testing happens)
Every release candidate is published as a prerelease and tested in that form. A prerelease builds and publishes the same three attested assets as a stable release, but as a GitHub **prerelease**, so it is invisible to the official channel: GitHub's "latest release" (what Obsidian's in-app updater reads) excludes prereleases, and the community submission review reads `manifest.json` at **master's HEAD**, which this flow never touches.
Convention: pre-release the version you are *about to* ship. If the next release is `1.3.0`, tag `1.3.0-alpha` (then `-alpha.2`, `-beta`, `-rc`, etc. as rounds accumulate) while master's `manifest.json` still reads the last stable version (`1.2.1`). Because `1.3.0-alpha` < `1.3.0` in SemVer, a BRAT beta tester is auto-upgraded when the real `1.3.0` lands.
Any tag containing a `-` is treated as a pre-release by [release.yml](../.github/workflows/release.yml): it stamps the tag's version into the **published** `manifest.json` at build time (master's copy is left alone) and sets `prerelease: true` on the GitHub release.
```bash
# On master, clean tree, everything you want to test committed.
# Do NOT bump manifest.json / package.json / versions.json and do NOT commit a bump.
npm run build && npm run lint # must both pass
git tag -a 1.3.0-alpha -m "Pre-release 1.3.0-alpha"
git push origin 1.3.0-alpha
# CI publishes a prerelease with a manifest.json version of 1.3.0-alpha.
```
To install for testing, either download the three assets from the prerelease page and copy them into `<Vault>/.obsidian/plugins/rewrite-voice-notes/`, or add the repo in **BRAT** with "beta versions" enabled (BRAT reads the newest prerelease). Run the feature pass (`release-checklist` skill / `CHECKLIST.md`) against that install. Fixes go to master and get a further suffixed tag (`1.3.0-alpha.2`, `1.3.0-beta`, ...); re-test at least the affected areas plus a clean load.
When the pass is green, cut the stable `1.3.0` per the TL;DR: version bump + bare tag on the **same source** you just tested — the bump commit (plus doc-only commits) is the only permitted delta between the last prerelease and the stable release; anything touching the artifact means a new prerelease round. Pre-release tags and their GitHub releases can be left in place (they stay marked prerelease) or deleted; they never affect the stable channel.
Note: `versions.json` is not updated for pre-releases (no bump runs), which is correct. Never point a stable tag at a suffixed name, and never commit a `-alpha`/`-beta` version into master's `manifest.json` (it would corrupt what the directory reads at HEAD).
## Re-releasing the same version (rare)
Only for fixing a botched release that nobody has consumed, and never once the version is accepted/depended on. Move the tag to the new commit and force-push to re-trigger CI:

View file

@ -32,6 +32,10 @@ The auto-start and idle-stop lifecycle toggles shipped (see Unreleased). The rem
Merged to `master`, not yet tagged. These become the next version's release notes.
### Fix: resolve the 1.2.1 automated-review `no-unsafe-*` warnings at the root (type-environment parity)
All ~30 warnings the Obsidian review bot raised on 1.2.1 traced to its lint environment resolving types differently from local, not to genuinely unsafe code (full analysis: [DEVCONFLICTS.md](DEVCONFLICTS.md) finding 10). Fixed at the root so the class cannot recur: `tsconfig.json` now declares `lib: ["DOM", "ES2019"]` (matching the ES2017ES2019 APIs the code actually uses) with `types: []` so the test suite's `@types/node` can no longer mask a lib/API mismatch locally; all `moment` calls go through the new `formatMoment` structural wrapper in `src/time.ts` (moment's typings don't resolve in the bot's environment); `src/secrets.ts` was restructured to need no `as BufferSource` assertions under any TypeScript version; and two `as Record<string, unknown>` narrows became an `isRecord` type predicate. Runtime behavior is unchanged. Guardrails documented in CLAUDE.md's "Review-bot type environment" gotcha and RELEASING.md's parity section.
---
## Released

View file

@ -1,5 +1,12 @@
import tseslint from 'typescript-eslint';
import obsidianmd from "eslint-plugin-obsidianmd";
// The Obsidian community-review bot runs a newer eslint-plugin-obsidianmd than our
// pinned 0.1.9 base, whose `no-unsupported-api` rule (flags a direct Obsidian API call
// newer than manifest `minAppVersion`) is what caught `FileManager.trashFile` at
// submission. We keep 0.1.9 as the base config (its `ui/sentence-case` is the one the bot
// actually enforces; 0.4.x's is over-aggressive and produces wrong fixes), and cherry-pick
// ONLY that one rule from a 0.4.1 alias so the minAppVersion check runs locally too.
import obsidianmdLatest from "obsidianmd-latest";
import globals from "globals";
import { globalIgnores } from "eslint/config";
@ -55,6 +62,15 @@ const sentenceCaseOptions = {
export default tseslint.config(
{
// The community-review bot rejects `eslint-disable` directives (it flags both the
// undescribed comment and disabling a rule at all). `noInlineConfig` makes every inline
// eslint directive inert, so a rule (ours or the bot's) cannot be silenced from a
// comment: an attempt to suppress a real error just leaves the error in place and fails
// `npm run lint`. Reach APIs outside the typed/deprecated surface through local
// type-aliases instead (see src/realtime/pcm.ts), never a disable comment.
linterOptions: {
noInlineConfig: true,
},
languageOptions: {
globals: {
...globals.browser,
@ -101,6 +117,18 @@ export default tseslint.config(
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
},
},
{
// Cherry-picked from the 0.4.1 alias (see the import comment): the single rule that
// flags a direct Obsidian API newer than manifest `minAppVersion`. Registering the
// plugin only makes its rules available; nothing else from 0.4.1 runs unless enabled
// here, so its divergent sentence-case rule stays off. This is the check that would
// have caught `trashFile` locally before the 1.2.0 submission.
files: ['src/**/*.ts'],
plugins: { 'obsidianmd-latest': obsidianmdLatest },
rules: {
'obsidianmd-latest/no-unsupported-api': 'error',
},
},
{
// test/ runs under Node via Vitest, not inside Obsidian, so the Node-builtin ban that
// keeps src/ portable to the Electron/mobile plugin runtime doesn't apply here.

136
package-lock.json generated
View file

@ -22,6 +22,7 @@
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"obsidianmd-latest": "npm:eslint-plugin-obsidianmd@0.4.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",
@ -478,6 +479,34 @@
"node": ">=18"
}
},
"node_modules/@eslint-community/eslint-plugin-eslint-comments": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz",
"integrity": "sha512-LF03qURSwEWm2dz5wtdDCzNk+7Opl0X7q6I3undsaIuNsEiNvRV3BCtqu14Q/6Pzg1tBj44LcxpW2EpSLZStZw==",
"dev": true,
"dependencies": {
"escape-string-regexp": "^4.0.0",
"ignore": "^7.0.5"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
},
"peerDependencies": {
"eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@ -626,7 +655,6 @@
"integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/plugin-kit": "^0.4.1",
@ -705,7 +733,6 @@
"integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=18"
}
@ -1188,7 +1215,6 @@
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz",
"integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -2882,12 +2908,20 @@
"node": ">=10"
}
},
"node_modules/eslint-plugin-no-unsanitized": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz",
"integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==",
"dev": true,
"peerDependencies": {
"eslint": "^9 || ^10"
}
},
"node_modules/eslint-plugin-obsidianmd": {
"version": "0.1.9",
"resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.1.9.tgz",
"integrity": "sha512-/gyo5vky3Y7re4BtT/8MQbHU5Wes4o6VRqas3YmXE7aTCnMsdV0kfzV1GDXJN9Hrsc9UQPoeKUMiapKL0aGE4g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@microsoft/eslint-plugin-sdl": "^1.1.0",
"@types/eslint": "8.56.2",
@ -2930,7 +2964,6 @@
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.8.7.tgz",
"integrity": "sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
@ -4560,10 +4593,9 @@
}
},
"node_modules/obsidian": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.3.tgz",
"integrity": "sha512-VP+ZSxNMG7y6Z+sU9WqLvJAskCfkFrTz2kFHWmmzis+C+4+ELjk/sazwcTHrHXNZlgCeo8YOlM6SOrAFCynNew==",
"license": "MIT",
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
"dependencies": {
"@types/codemirror": "5.60.8",
"moment": "2.29.4"
@ -4573,6 +4605,92 @@
"@codemirror/view": "6.38.6"
}
},
"node_modules/obsidianmd-latest": {
"name": "eslint-plugin-obsidianmd",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.4.1.tgz",
"integrity": "sha512-Nv3593kVsFOS8kir/HWaJ5CR3xcyiPWEPyXst5Pib8mxRYYuLYPpJS2ALMoZXHulHyiGwoYW8AaxvLRc4rhrRg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
"@eslint/config-helpers": "^0.4.2",
"@eslint/js": "^9.30.1",
"@eslint/json": "0.14.0",
"@microsoft/eslint-plugin-sdl": "^1.1.0",
"@types/eslint": "9.6.1",
"@types/node": "20.12.12",
"@typescript-eslint/types": "^8.33.1",
"@typescript-eslint/utils": "^8.33.1",
"eslint": ">=9.19.0",
"eslint-plugin-depend": "1.3.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-json-schema-validator": "5.1.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"eslint-plugin-security": "2.1.1",
"globals": "14.0.0",
"obsidian": "1.12.3",
"semver": "^7.7.4",
"typescript": "5.4.5",
"typescript-eslint": "^8.35.1"
},
"bin": {
"eslint-plugin-obsidian": "dist/lib/index.js"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/json": "0.14.0",
"eslint": ">=9.19.0",
"obsidian": "1.8.7",
"typescript-eslint": "^8.35.1"
}
},
"node_modules/obsidianmd-latest/node_modules/@types/eslint": {
"version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"dev": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
}
},
"node_modules/obsidianmd-latest/node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/obsidianmd-latest/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/obsidianmd-latest/node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",

View file

@ -23,6 +23,7 @@
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"obsidianmd-latest": "npm:eslint-plugin-obsidianmd@0.4.1",
"tslib": "2.4.0",
"typescript": "^5.8.3",
"typescript-eslint": "8.35.1",

View file

@ -1,4 +1,5 @@
import { App, moment, normalizePath } from 'obsidian';
import { App, normalizePath } from 'obsidian';
import { formatMoment } from 'time';
import { GlobalSettings } from './types';
interface AttachmentFileManager {
@ -50,7 +51,7 @@ export function mimeToExtension(mime: string): string {
}
export function buildAudioFilename(now: Date, ext: string): string {
const stamp = moment(now).format('YYYY-MM-DD-HHmmss');
const stamp = formatMoment('YYYY-MM-DD-HHmmss', now);
return `ReWrite-${stamp}.${ext}`;
}

View file

@ -1,4 +1,5 @@
import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { App, MarkdownView, Modal, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { formatMoment } from 'time';
import { NewFileCollisionMode, NoteTemplate } from './types';
import { guardReservedName, sanitizeFilename } from './templates-folder';
@ -207,11 +208,11 @@ class RenamePromptModal extends Modal {
}
function expandFilenameTemplate(template: string, title?: string): string {
const now = moment();
const now = new Date();
const safeTitle = title ? titleToFilename(title) : '';
return template
.replace(/\{\{date\}\}/g, now.format('YYYY-MM-DD'))
.replace(/\{\{time\}\}/g, now.format('HHmmss'))
.replace(/\{\{date\}\}/g, formatMoment('YYYY-MM-DD', now))
.replace(/\{\{time\}\}/g, formatMoment('HHmmss', now))
.replace(/\{\{title\}\}/g, safeTitle);
}

View file

@ -234,9 +234,9 @@ export function extractFromBlock(
let usedStrict = false;
try {
const parsed: unknown = parseYaml(block);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
if (isRecord(parsed)) {
usedStrict = true;
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
for (const [key, value] of Object.entries(parsed)) {
// Values are scalars. Take strings as-is, coerce numbers/booleans,
// and ignore null/undefined or nested objects/arrays.
let coerced: string | null = null;
@ -280,6 +280,10 @@ export function extractFromBlock(
return { body, properties, title: title || undefined };
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function stripQuotes(value: string): string {
let v = value.trim();
// Peel matched leading/trailing quote pairs.

View file

@ -404,14 +404,19 @@ function bytesToBase64(bytes: Uint8Array): string {
return btoa(s);
}
function base64ToBytes(b64: string): Uint8Array {
// Return types are deliberately inferred (not annotated `Uint8Array`): on TS 5.7+
// the inferred type is the ArrayBuffer-backed Uint8Array that WebCrypto's
// BufferSource params accept directly, while an explicit `Uint8Array` annotation
// widens to an ArrayBufferLike backing and forces `as BufferSource` assertions
// that older TS (the community-review bot's) then flags as unnecessary.
function base64ToBytes(b64: string) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
function randomBytes(n: number): Uint8Array {
function randomBytes(n: number) {
const out = new Uint8Array(n);
crypto.getRandomValues(out);
return out;
@ -428,7 +433,7 @@ function isAllocationFailure(e: unknown): boolean {
// ---------- key derivation ----------
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
async function deriveKeyFromPassphrase(passphrase: string, salt: BufferSource, iterations: number): Promise<CryptoKey> {
const passBytes = new TextEncoder().encode(passphrase);
const baseKey = await crypto.subtle.importKey(
'raw',
@ -438,7 +443,7 @@ async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, ite
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations },
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
@ -464,7 +469,10 @@ async function deriveArgon2idKey(
});
return crypto.subtle.importKey(
'raw',
raw as BufferSource,
// hash-wasm types the binary output as a bare Uint8Array (ArrayBufferLike
// backing on TS 5.7+); copying into a fresh array satisfies BufferSource on
// every TS version without an assertion. 32 bytes, once per derivation.
new Uint8Array(raw),
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt'],
@ -516,7 +524,7 @@ async function buildPassphraseKdfAndKey(passphrase: string): Promise<{ kdf: Pass
async function aesGcmEncrypt(key: CryptoKey, plaintext: string): Promise<string> {
const iv = randomBytes(AES_IV_BYTES);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(plaintext),
);
@ -529,9 +537,9 @@ async function aesGcmDecrypt(key: CryptoKey, payload: string): Promise<string> {
const iv = base64ToBytes(payload.slice(0, sepIdx));
const ct = base64ToBytes(payload.slice(sepIdx + 1));
const pt = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: iv as BufferSource },
{ name: 'AES-GCM', iv },
key,
ct as BufferSource,
ct,
);
return new TextDecoder().decode(pt);
}

View file

@ -1,4 +1,5 @@
import { App, moment, normalizePath, TFile } from 'obsidian';
import { App, normalizePath, TFile } from 'obsidian';
import { formatMoment } from 'time';
import type { TemplateUpdateConflict, TemplateUpdateEntry, UpdateResult } from './templates-folder';
// The Update button's worklist. Lives next to the templates folder (in its
@ -120,7 +121,7 @@ function renderUpdateReport(result: UpdateResult): string {
lines.push('# ReWrite: template update report');
lines.push('');
const failNote = result.parseFailed ? `, ${result.parseFailed} could not be parsed` : '';
lines.push(`Generated ${moment().format('YYYY-MM-DD HH:mm')}. ${result.updated} updated, ${result.conflicts} need manual review, ${result.created} created, ${result.unchanged} unchanged${failNote}.`);
lines.push(`Generated ${formatMoment('YYYY-MM-DD HH:mm')}. ${result.updated} updated, ${result.conflicts} need manual review, ${result.created} created, ${result.unchanged} unchanged${failNote}.`);
lines.push('');
const actionable = result.entries.filter((e) => e.status !== 'unchanged');

View file

@ -421,6 +421,10 @@ async function parseTemplateFile(app: App, file: TFile): Promise<NoteTemplate |
return parseTemplateContent(file, content);
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
// Split out from parseTemplateFile so callers that already hold the file content (the update
// walk previously read each candidate file up to three times: once for the id, once for the
// full parse, once more to diff against the rendered output) can parse without a redundant read.
@ -482,8 +486,8 @@ export function parseTemplateContent(file: TFile, content: string): NoteTemplate
// blank keys are skipped; a missing/non-string instruction becomes "".
const noteProperties: NotePropertySpec[] = [];
const rawProps = obj.noteProperties;
if (rawProps && typeof rawProps === 'object' && !Array.isArray(rawProps)) {
for (const [name, instruction] of Object.entries(rawProps as Record<string, unknown>)) {
if (isRecord(rawProps)) {
for (const [name, instruction] of Object.entries(rawProps)) {
const key = name.trim();
if (!key) continue;
noteProperties.push({

19
src/time.ts Normal file
View file

@ -0,0 +1,19 @@
import { moment } from 'obsidian';
// Obsidian re-exports its bundled moment, but the export's TYPE comes from the
// `moment` package (a transitive dependency of `obsidian`), which the community
// review bot's lint environment does not resolve: `obsidian` itself types fine
// there, `moment` does not, so every direct `moment(...)` call is error-typed and
// trips the bot's no-unsafe-* rules. All timestamp formatting therefore goes
// through this narrow structural alias (the same pattern as ScriptProcessorNodeLike
// and WakeLockLike), which needs nothing from moment's own typings. Runtime
// behavior is unchanged: it is still Obsidian's bundled moment doing the work.
interface MomentLike {
format(fmt: string): string;
}
type MomentFactory = (input?: Date) => MomentLike;
export function formatMoment(fmt: string, input?: Date): string {
return (moment as unknown as MomentFactory)(input).format(fmt);
}

View file

@ -17,11 +17,11 @@
"strictBindCallApply": true,
"allowSyntheticDefaultImports": true,
"useUnknownInCatchVariables": true,
"types": [],
"skipLibCheck": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
"ES2019"
]
},
"include": [