From 3ee5db05d912b21a567f5b5811f4a9406783dd46 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 6 Aug 2026 17:40:23 +1000 Subject: [PATCH 1/3] fix: preserve same-second duplicate captures --- src/storage.ts | 3 ++- test/clipcase.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/storage.ts b/src/storage.ts index 208579b..3e76447 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -12,7 +12,8 @@ export async function loadCase(storageDir: string, caseName: string): Promise { meta.entries.sort((a, b) => a.id.localeCompare(b.id)); await fs.writeFile(await indexPath(storageDir, meta.name), JSON.stringify(meta, null, 2) + '\n'); } export async function createCase(storageDir: string, name: string, title?: string, now = new Date()): Promise { await ensureStore(storageDir); const slug = slugify(name); const dir = caseDir(storageDir, slug); await fs.mkdir(path.join(dir, 'entries'), { recursive: true }); const createdAt = now.toISOString(); const meta: CaseMetadata = { name: slug, title: title ?? slug, createdAt, updatedAt: createdAt, entries: [] }; await fs.writeFile(path.join(dir, INDEX_FILE), JSON.stringify(meta, null, 2) + '\n', { flag: 'wx' }); return meta; } export async function listCases(storageDir: string): Promise { await ensureStore(storageDir); const names = await fs.readdir(storageDir).catch(() => [] as string[]); const cases: CaseMetadata[] = []; for (const name of names.sort()) { try { cases.push(await loadCase(storageDir, name)); } catch {} } return cases.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name)); } -export async function addEntry(storageDir: string, input: AddEntryInput): Promise { const findings = findSecrets(input.text); if (findings.length && !input.allowSecret) throw new ClipcaseError(`Refusing to save likely secret(s): ${findings.map((f) => f.label).join(', ')}. Re-run with --allow-secret if this is intentional.`, 3); const meta = await loadCase(storageDir, input.caseName); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const hash = sha256(input.text); const stamp = createdAt.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const id = `${stamp}-${shortHash(input.text)}`; const relPath = toPosix(path.join('entries', `${id}.md`)); const entry: EntryMetadata = { id, caseName: meta.name, createdAt, source: input.source ?? 'stdin', tags: [...new Set(input.tags ?? [])].sort(), hash, bytes: Buffer.byteLength(input.text), path: relPath }; await fs.writeFile(path.join(caseDir(storageDir, meta.name), relPath), renderEntry(entry, input.text), { flag: 'wx' }); meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; } +export async function addEntry(storageDir: string, input: AddEntryInput): Promise { const findings = findSecrets(input.text); if (findings.length && !input.allowSecret) throw new ClipcaseError(`Refusing to save likely secret(s): ${findings.map((f) => f.label).join(', ')}. Re-run with --allow-secret if this is intentional.`, 3); const meta = await loadCase(storageDir, input.caseName); const now = input.now ?? new Date(); const createdAt = now.toISOString(); const hash = sha256(input.text); const stamp = createdAt.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z'); const baseId = `${stamp}-${shortHash(input.text)}`; let entry: EntryMetadata | undefined; for (let collision = 0; !entry; collision += 1) { const id = collision === 0 ? baseId : `${baseId}-${String(collision).padStart(6, '0')}`; const relPath = toPosix(path.join('entries', `${id}.md`)); const candidate: EntryMetadata = { id, caseName: meta.name, createdAt, source: input.source ?? 'stdin', tags: [...new Set(input.tags ?? [])].sort(), hash, bytes: Buffer.byteLength(input.text), path: relPath }; try { await fs.writeFile(path.join(caseDir(storageDir, meta.name), relPath), renderEntry(candidate, input.text), { flag: 'wx' }); entry = candidate; } catch (error) { if (!isAlreadyExists(error)) throw error; } } meta.entries.push(entry); meta.updatedAt = createdAt; await saveCase(storageDir, meta); return entry; } +function isAlreadyExists(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'EEXIST'; } export function renderEntry(entry: EntryMetadata, text: string): string { return `---\nid: ${entry.id}\ncreatedAt: ${entry.createdAt}\nsource: ${entry.source}\ntags: [${entry.tags.join(', ')}]\nhash: ${entry.hash}\nbytes: ${entry.bytes}\n---\n\n# Entry ${entry.id}\n\n- Source: ${escapeMarkdown(entry.source)}\n- Tags: ${formatTags(entry.tags)}\n- SHA-256: \`${entry.hash}\`\n\n\`\`\`text\n${text.replace(/\n?$/, '\n')}\`\`\`\n`; } export async function readEntryText(storageDir: string, meta: CaseMetadata, entry: EntryMetadata): Promise { const md = await fs.readFile(path.join(caseDir(storageDir, meta.name), entry.path), 'utf8'); const match = md.match(/```text\n([\s\S]*?)```\n?$/); return match ? match[1].replace(/\n$/, '') : md; } export async function exportCase(storageDir: string, caseName: string): Promise { const meta = await loadCase(storageDir, caseName); const chunks = [`# ${meta.title}\n`, `- Case: ${meta.name}`, `- Created: ${meta.createdAt}`, `- Updated: ${meta.updatedAt}`, `- Entries: ${meta.entries.length}`, '']; for (const entry of meta.entries) { const text = await readEntryText(storageDir, meta, entry); chunks.push(`## ${entry.id}`, '', `- Source: ${entry.source}`, `- Tags: ${formatTags(entry.tags)}`, `- SHA-256: \`${entry.hash}\``, '', '```text', text, '```', ''); } return chunks.join('\n'); } diff --git a/test/clipcase.test.ts b/test/clipcase.test.ts index dae6cf1..08b8afd 100644 --- a/test/clipcase.test.ts +++ b/test/clipcase.test.ts @@ -3,11 +3,12 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; -import { addEntry, createCase, exportCase, listCases, searchCases } from '../src/index.js'; +import { addEntry, createCase, exportCase, listCases, loadCase, searchCases } from '../src/index.js'; import { findSecrets } from '../src/secrets.js'; import { loadConfig, writeConfig } from '../src/config.js'; async function tmp(): Promise { return fs.mkdtemp(path.join(os.tmpdir(), 'clipcase-test-')); } test('creates cases and captures deterministic entry metadata', async () => { const dir = await tmp(); await createCase(dir, 'Bug Login', 'Bug Login', new Date('2026-01-01T00:00:00.000Z')); const entry = await addEntry(dir, { caseName: 'bug-login', text: 'hello repro\n', source: 'terminal', tags: ['repro'], now: new Date('2026-01-01T00:01:00.000Z') }); assert.equal(entry.id, '20260101T000100Z-4e17aeaa9041'); assert.equal(entry.source, 'terminal'); assert.deepEqual(entry.tags, ['repro']); }); +test('keeps identical same-second captures as distinct, ordered entries', async () => { const dir = await tmp(); await createCase(dir, 'collision'); const now = new Date('2026-01-01T00:00:01.100Z'); const first = await addEntry(dir, { caseName: 'collision', text: 'same content', now }); const second = await addEntry(dir, { caseName: 'collision', text: 'same content', now: new Date('2026-01-01T00:00:01.900Z') }); assert.equal(first.id, '20260101T000001Z-a636bd7cd420'); assert.equal(second.id, `${first.id}-000001`); assert.notEqual(first.path, second.path); const meta = await loadCase(dir, 'collision'); assert.deepEqual(meta.entries.map((entry) => entry.id), [first.id, second.id]); assert.deepEqual(meta.entries.map((entry) => entry.hash), [first.hash, first.hash]); assert.deepEqual(meta.entries.map((entry) => entry.bytes), [12, 12]); assert.equal((await searchCases(dir, 'same content')).length, 2); const exported = await exportCase(dir, 'collision'); assert.ok(exported.indexOf(`## ${first.id}\n`) < exported.indexOf(`## ${second.id}\n`)); }); test('blocks likely secrets unless explicitly allowed', async () => { const dir = await tmp(); await createCase(dir, 'secret-case'); await assert.rejects(() => addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456' }), /Refusing to save/); const entry = await addEntry(dir, { caseName: 'secret-case', text: 'token=abcdefghijklmnopqrstuvwxyz123456', allowSecret: true }); assert.ok(entry.id); assert.equal(findSecrets('AKIAABCDEFGHIJKLMNOP').length, 1); assert.equal(findSecrets('npm_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKL').length, 1); }); test('lists, searches, and exports case content', async () => { const dir = await tmp(); await createCase(dir, 'bug-login', 'Login Bug'); await addEntry(dir, { caseName: 'bug-login', text: 'expired cookie causes failure', source: 'terminal', tags: ['auth'] }); assert.equal((await listCases(dir)).length, 1); const results = await searchCases(dir, 'cookie'); assert.equal(results.length, 1); const exported = await exportCase(dir, 'bug-login'); assert.match(exported, /# Login Bug/); assert.match(exported, /expired cookie/); }); test('writes and loads local config', async () => { const dir = await tmp(); await writeConfig('notes', dir); const config = await loadConfig(dir); assert.equal(config.storageDir, path.join(dir, 'notes')); }); From cb56968644d2f7b89826a81e011b854960bbb242 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 6 Aug 2026 17:40:23 +1000 Subject: [PATCH 2/3] test: cover duplicate captures through CLI --- test/cli.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/cli.test.ts b/test/cli.test.ts index 5f786f3..b81b0ae 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -49,6 +49,23 @@ describe('clipcase CLI', () => { assert.match(run(['export', 'bug-login'], cwd), /expired cookie causes redirect failure/); }); + it('captures repeated identical input without overwriting an entry', async () => { + const cwd = await tmp(); + + run(['init'], cwd); + run(['new', 'duplicates'], cwd); + for (let attempt = 0; attempt < 4; attempt += 1) run(['add', 'duplicates'], cwd, 'identical capture\n'); + + const shown = JSON.parse(run(['show', 'duplicates'], cwd)) as { entries: Array<{ id: string; path: string; hash: string; bytes: number }> }; + assert.equal(shown.entries.length, 4); + assert.equal(new Set(shown.entries.map((entry) => entry.id)).size, 4); + assert.equal(new Set(shown.entries.map((entry) => entry.path)).size, 4); + assert.equal(new Set(shown.entries.map((entry) => entry.hash)).size, 1); + assert.deepEqual(new Set(shown.entries.map((entry) => entry.bytes)), new Set([18])); + assert.equal(run(['search', 'identical'], cwd).trim().split('\n').length, 4); + assert.match(run(['export', 'duplicates'], cwd), /- Entries: 4/); + }); + it('rejects case names that resolve outside the configured store', async () => { const cwd = await tmp(); From 113fa783e6489d3b4324fac0e5590f4eee0dddb7 Mon Sep 17 00:00:00 2001 From: Roger Chappel Date: Thu, 6 Aug 2026 17:40:23 +1000 Subject: [PATCH 3/3] docs: describe collision-safe entry IDs --- docs/STORAGE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/STORAGE.md b/docs/STORAGE.md index 9d6f353..0fdf216 100644 --- a/docs/STORAGE.md +++ b/docs/STORAGE.md @@ -8,3 +8,6 @@ ClipCase stores plain files so users can inspect, diff, back up, or delete casef - `entries/*.md` contains front matter, hash metadata, and fenced plaintext. Entry IDs are timestamp plus content hash prefix: `YYYYMMDDTHHMMSSZ-<12 hex>`. +If that ID already exists, ClipCase appends a zero-padded collision counter, starting +at `-000001`. This preserves both identical captures made within the same second +while keeping filenames and index ordering deterministic.