Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,64 @@

## Unreleased

### Capture reshape — hierarchical capture/* tags + schema-ensure + option (d) + path-collision fix

- **feat(capture): hierarchical capture tags + schema-ensure + option (d) +
path-collision fix (0.3.15-rc.8).** Closes notes#126 (reshaped scope).
Builds on rc.7's `quickPath()` pre-fill with Aaron's confirmed `capture/*`
classification model + several reviewer follow-ups bundled.
- **`NOTES_REQUIRED_SCHEMA` in `src/lib/vault/schema.ts`** declares the
`capture` parent + `capture/text` + `capture/voice` children with
`parent_names: ["capture"]`. First instance of patterns#57 (surface-
declares-required-schema). Future extensions (`capture/photo`,
`capture/web-clip`) slot in without rename.
- **Tag Role defaults rename**: `DEFAULT_TAG_ROLES.captureText` →
`"capture/text"`, `DEFAULT_TAG_ROLES.captureVoice` → `"capture/voice"`.
**Existing vaults preserve their stored values** — if a user has
`captureText = "quick"` from rc.6, that stays. Only fresh-vault
inheritance changes.
- **`update-tag` client method + idempotent `ensureNotesSchema()` hook**
in `src/lib/vault/schema-ensure.ts`. PUTs each declared tag against
`/api/tags/:name` (field-merged vault-side; no-op when already-correct).
Per-vault per-session ref guard so repeated captures don't hammer the
vault. Failure rolls back the guard so the next capture retries.
Captures-side wiring is fire-and-forget — schema setup doesn't block
the user's save.
- **Option (d) bundled** (was the closed PR #131): clearing the path
input reverts to the mount-time generated value, never vault-picks.
Resolution becomes `pathOverride.trim() || generatedPathRef.current`.
The rc.6 `memoPath()` audio-only fallback is dropped — unreachable
under option (d). One canonical Notes-side rule, no phase-dependent
forks. Aaron's framing: don't re-introduce hidden vault-picks magic
via the cleared-input path.
- **Path-collision fix** (raised in #130 review): `quickPath()` is
second-granularity, so two captures within the same wall-clock second
would land at the same path. `reset()` after successful save now
regenerates `quickPath()` AND updates the input — but only when the
operator hasn't manually edited. A user typing an explicit path
(e.g. `Daily/2026-05-12`) is capturing into a deliberate location;
don't fight them. `pathEditedRef` tracks edit intent; restoring the
generated value clears the flag.
- **Placeholder text** updated: `"(blank → vault picks)"` →
`"(blank → uses generated path)"` so the UI itself describes the
option-(d) rule.
- **Tests.** 6 new in `schema-ensure.test.ts` (declaration-order PUTs,
parent-before-children, per-session per-vault idempotence, multi-vault
independence, retry-on-failure, swallow-failure-doesn't-throw). 2 new
in `Capture.test.tsx` (regen-on-reset when unedited; preserve user
edit across reset). 4 existing tests flipped where defaults changed
(captureText → `capture/text`, captureVoice → `capture/voice`,
text+voice combined now both hierarchical) or option (d) changed
semantics ("empty path reverts to generated", "audio-only cleared
path reverts to generated"). `Capture.test.tsx` adds a `vi.mock` for
`@/lib/vault/schema-ensure` so capture tests don't hit the real PUT
(covered by schema-ensure.test.ts).

### Not in scope (deferred)

- Full Settings audit UI + connect-time banner → notes#129.
- Per-vault customization of path templates → notes#128.

### Text-size shortcuts + header control; Capture path pre-fill

- **feat(ui): accessible text-size (shortcuts + header) + locally-generated
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openparachute/notes",
"version": "0.3.15-rc.7",
"version": "0.3.15-rc.8",
"private": false,
"type": "module",
"description": "Parachute Notes — the default frontend for Parachute. Browse, edit, and capture in any Parachute Vault.",
Expand Down
151 changes: 131 additions & 20 deletions src/app/routes/Capture.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ vi.mock("@/lib/sync", async () => {
};
});

// Schema-ensure (notes#126 reshape) calls a real `PUT /api/tags/:name` via
// the active vault client. Capture tests don't stub fetch at the network
// boundary — they stub at the `enqueue` boundary — so the schema-ensure
// fetch would hang the test environment for 10s+ per case. Stub the
// ensure module to a no-op here; schema-ensure has its own focused tests
// in `schema-ensure.test.ts` that exercise the real path.
vi.mock("@/lib/vault/schema-ensure", () => ({
ensureNotesSchema: vi.fn(async () => {}),
}));

vi.mock("@/lib/capture/recorder", async () => {
const actual =
await vi.importActual<typeof import("@/lib/capture/recorder")>("@/lib/capture/recorder");
Expand Down Expand Up @@ -214,7 +224,8 @@ describe("Capture (unified)", () => {
// on mount, so the payload's path is `Notes/<YYYY>/<MM-DD>/<HH-MM-SS>`.
// Asserting on the prefix keeps the test stable across clock minutes.
expect(rows[0].mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(rows[0].mutation.payload.tags).toEqual(["quick", "idea"]);
// notes#126 reshape: default captureText role is "capture/text" (was "quick").
expect(rows[0].mutation.payload.tags).toEqual(["capture/text", "idea"]);
db.close();
});

Expand Down Expand Up @@ -260,13 +271,15 @@ describe("Capture (unified)", () => {
) {
throw new Error("wrong mutation shape");
}
// With notes#126's pre-fill, pathOverride is seeded with `quickPath()`
// on mount and wins over the audio-only memoPath fallback. Audio-only
// memos now land under Notes/<date>/<time> by default. To keep them
// in Memos/, the user can clear the path input (then memoPath kicks
// in — see the "audio-only with cleared path" test below).
// With notes#126's pre-fill + option (d), audio-only captures also
// land under Notes/<date>/<time> by default. The `memoPath()`
// fallback was dropped in the reshape — one canonical Notes-side
// rule, no phase-dependent forks. Clearing the path reverts to the
// same generated value (see the "audio-only with cleared path" test
// below).
expect(create.mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(create.mutation.payload.tags).toEqual(["voice"]);
// notes#126 reshape: default captureVoice role is "capture/voice" (was "voice").
expect(create.mutation.payload.tags).toEqual(["capture/voice"]);
expect(create.mutation.payload.content).toContain("_Transcript pending._");
expect(create.mutation.payload.content).toContain("![[");
expect(link.mutation.pathRef).toBe(`blob:${upload.mutation.blobId}`);
Expand Down Expand Up @@ -313,7 +326,8 @@ describe("Capture (unified)", () => {
expect(create.mutation.payload.path).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);
expect(create.mutation.payload.content).toContain("context for the recording #meeting");
expect(create.mutation.payload.content).toContain("![[");
expect(create.mutation.payload.tags).toEqual(["quick", "voice", "meeting"]);
// notes#126 reshape: both default capture roles are hierarchical now.
expect(create.mutation.payload.tags).toEqual(["capture/text", "capture/voice", "meeting"]);
db.close();
});

Expand Down Expand Up @@ -418,7 +432,7 @@ describe("Capture (unified)", () => {
const rows = await listPending(db, "dev");
if (rows[0]?.mutation.kind === "create-note") {
expect(rows[0].mutation.payload.content).toBe("walked away mid-thought");
expect(rows[0].mutation.payload.tags).toEqual(["quick"]);
expect(rows[0].mutation.payload.tags).toEqual(["capture/text"]);
}
db.close();
});
Expand Down Expand Up @@ -655,12 +669,17 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
const payload = rows[0].mutation.payload;
expect(payload.path).toBe("Daily/2026-05-12");
expect(payload.metadata).toEqual({ summary: "first pass" });
expect(payload.tags).toEqual(["quick", "wip"]);
expect(payload.tags).toEqual(["capture/text", "wip"]);
expect(payload.content).toBe("lab notes #wip");
db.close();
});

it("Empty path override → payload omits `path` (vault auto-assigns)", async () => {
it("Empty path override reverts to the mount-time generated path (option d)", async () => {
// notes#126 reshape, option (d): clearing the path input never falls
// back to vault-auto-assign. The generated `quickPath()` value captured
// on mount is the truth-default; emptying the input reverts to that
// same value at save time. Aaron's framing: "vault auto-assigns" hides
// what's happening, so empty-input must not surface that magic again.
renderAt("/capture");
await waitForReady();
const detailsEl = screen.getByText(/^more fields$/i).closest("details")!;
Expand All @@ -671,6 +690,9 @@ describe("Capture — More fields panel (path + summary overrides)", () => {

const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement;
const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
const generated = pathInput.value;
expect(generated).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);

await act(async () => {
fireEvent.change(textarea, { target: { value: "no path here" } });
// Whitespace-only is treated as empty per the trim().
Expand All @@ -688,12 +710,13 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
const db = await openLensDB();
const rows = await listPending(db, "dev");
if (rows[0]?.mutation.kind !== "create-note") throw new Error("expected create-note");
expect(rows[0].mutation.payload.path).toBeUndefined();
// Empty input → mount-time generated value, not undefined.
expect(rows[0].mutation.payload.path).toBe(generated);
expect(rows[0].mutation.payload.metadata).toBeUndefined();
db.close();
});

it("Path override wins over the audio-only memo path", async () => {
it("Path override wins over the generated default (audio-only)", async () => {
renderAt("/capture");
await waitForReady();
const detailsEl = screen.getByText(/^more fields$/i).closest("details")!;
Expand Down Expand Up @@ -776,12 +799,12 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
db.close();
});

it("Audio-only with manually cleared path falls back to memoPath (rc.6 escape valve)", async () => {
// notes#126's pre-fill is the new default, but clearing the path
// input is the operator's signal of "I want the historical rule".
// For audio-only captures, that rule is `memoPath()` → `Memos/`.
// This test pins the escape valve so a future refactor doesn't
// delete the fallback.
it("Audio-only with manually cleared path reverts to the generated path (option d)", async () => {
// notes#126 reshape, option (d): clearing the path is NOT an escape
// hatch back to historical rules. It reverts to the mount-time
// `quickPath()` value. One canonical Notes-side rule, no phase-
// dependent forks. Audio captures via this path land under
// `Notes/<date>/<time>` just like text captures.
renderAt("/capture");
await waitForReady();
const detailsEl = screen.getByText(/^more fields$/i).closest("details")!;
Expand All @@ -791,6 +814,9 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
});

const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
const generated = pathInput.value;
expect(generated).toMatch(/^Notes\/\d{4}\/\d{2}-\d{2}\/\d{2}-\d{2}-\d{2}$/);

await act(async () => {
fireEvent.change(pathInput, { target: { value: "" } });
});
Expand Down Expand Up @@ -819,9 +845,94 @@ describe("Capture — More fields panel (path + summary overrides)", () => {
const rows = await listPending(db, "dev");
const create = rows.find((r) => r.mutation.kind === "create-note")!;
if (create.mutation.kind !== "create-note") throw new Error("expected create-note");
expect(create.mutation.payload.path).toMatch(/^Memos\//);
expect(create.mutation.payload.path).toBe(generated);
db.close();
});

it("Regenerates the path on reset when operator hasn't edited (notes#126 collision fix)", async () => {
// Reviewer raised this in #130: `quickPath()` is second-granularity,
// so two captures within the same second produce the same path —
// collision. The reshape regenerates on `reset()` AFTER successful
// save, but only when the operator hasn't manually edited. We need
// wall-clock time to actually move BEFORE the reset's quickPath()
// call to see a different value — use fake timers and advance the
// clock *before* the save click so reset() reads the advanced time.
const tFirst = new Date(2026, 4, 12, 14, 30, 5).getTime();
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(tFirst);
try {
renderAt("/capture");
await waitForReady();
const detailsEl = screen.getByText(/^more fields$/i).closest("details")!;
await act(async () => {
detailsEl.open = true;
detailsEl.dispatchEvent(new Event("toggle"));
});

const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
const first = pathInput.value;
expect(first).toBe("Notes/2026/05-12/14-30-05");

const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement;
await act(async () => {
fireEvent.change(textarea, { target: { value: "thought one" } });
});

// Advance wall-clock BEFORE the click so reset()'s quickPath() reads
// the new value. Two captures within the same wall-clock second is
// the collision case; the regen-on-reset is the fix.
await act(async () => {
vi.setSystemTime(tFirst + 7_000);
});

await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^capture$/i }));
});
await waitFor(() => {
expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true);
});

// After reset(), the input value should have regenerated.
const second = pathInput.value;
expect(second).toBe("Notes/2026/05-12/14-30-12");
expect(second).not.toBe(first);
} finally {
vi.useRealTimers();
}
});

it("Preserves a user-edited path across reset (no regen)", async () => {
// Counter-test for the collision fix: if the operator typed an
// explicit path, they're capturing multiple notes into the same
// place (e.g. `Daily/2026-05-12`). Don't fight them.
renderAt("/capture");
await waitForReady();
const detailsEl = screen.getByText(/^more fields$/i).closest("details")!;
await act(async () => {
detailsEl.open = true;
detailsEl.dispatchEvent(new Event("toggle"));
});

const pathInput = screen.getByLabelText(/path override/i) as HTMLInputElement;
const userPath = "Daily/2026-05-12";
await act(async () => {
fireEvent.change(pathInput, { target: { value: userPath } });
});

const textarea = screen.getByLabelText(/capture content/i) as HTMLTextAreaElement;
await act(async () => {
fireEvent.change(textarea, { target: { value: "first daily" } });
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /^capture$/i }));
});
await waitFor(() => {
expect(useToastStore.getState().toasts.some((t) => t.message === "Captured.")).toBe(true);
});

// After reset, the user-typed path should still be in the input.
expect(pathInput.value).toBe(userPath);
});
});

describe("Capture — inactivity autosave (5s)", () => {
Expand Down
Loading