feat(core): record ids, file paths, time formatting
Refs #1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7cd3811ead
commit
6b2466cf05
48
src/core/naming.ts
Normal file
48
src/core/naming.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
export const SOURCE_DIR = 'voice/source';
|
||||||
|
export const INBOX_DIR = 'voice/inbox';
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
// Время в id — UTC: порядок id не зависит от пояса устройства.
|
||||||
|
export function makeId(startedAt: Date, random: () => number): string {
|
||||||
|
const d = startedAt;
|
||||||
|
const date = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
|
||||||
|
const time = `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`;
|
||||||
|
let hex = '';
|
||||||
|
for (let i = 0; i < 4; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16);
|
||||||
|
return `${date}-${time}-${hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXT: Record<string, string> = {
|
||||||
|
'audio/mp4': 'm4a',
|
||||||
|
'audio/webm': 'webm',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extFromMime(mime: string): string {
|
||||||
|
const base = mime.split(';')[0].trim().toLowerCase();
|
||||||
|
return EXT[base] ?? 'bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioPath(id: string, mime: string): string {
|
||||||
|
return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя заметки — для человека, поэтому в локальном времени.
|
||||||
|
export function notePath(startedAt: Date, n: number): string {
|
||||||
|
const d = startedAt;
|
||||||
|
const name =
|
||||||
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||||
|
`${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
|
||||||
|
return `${INBOX_DIR}/${name}${n > 1 ? ` (${n})` : ''}.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${Math.floor(total / 60)}:${pad(total % 60)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
79
tests/naming.test.ts
Normal file
79
tests/naming.test.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
audioPath,
|
||||||
|
extFromMime,
|
||||||
|
formatDuration,
|
||||||
|
formatLocalTime,
|
||||||
|
makeId,
|
||||||
|
notePath,
|
||||||
|
} from '../src/core/naming';
|
||||||
|
|
||||||
|
const T = new Date('2026-09-27T11:15:03.000Z'); // 14:15:03 по Москве
|
||||||
|
|
||||||
|
function seq(...values: number[]): () => number {
|
||||||
|
return () => values.shift() ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('test environment', () => {
|
||||||
|
it('runs in UTC+3', () => {
|
||||||
|
expect(T.getHours()).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('makeId', () => {
|
||||||
|
it('uses UTC time and 4 hex chars', () => {
|
||||||
|
expect(makeId(T, () => 0)).toBe('20260927-111503-0000');
|
||||||
|
expect(makeId(T, seq(0.99, 0.5, 0.0625, 0.1))).toBe('20260927-111503-f811');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps random() === 1 to f', () => {
|
||||||
|
expect(makeId(T, () => 1)).toBe('20260927-111503-ffff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not depend on the local date', () => {
|
||||||
|
const lateEvening = new Date('2026-09-27T22:30:00.000Z'); // 01:30 28-го по Москве
|
||||||
|
expect(makeId(lateEvening, () => 0)).toBe('20260927-223000-0000');
|
||||||
|
expect(notePath(lateEvening, 1)).toBe('voice/inbox/2026-09-28 01-30-00.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extFromMime', () => {
|
||||||
|
it.each([
|
||||||
|
['audio/mp4', 'm4a'],
|
||||||
|
['audio/webm', 'webm'],
|
||||||
|
['audio/webm;codecs=opus', 'webm'],
|
||||||
|
['AUDIO/MP4', 'm4a'],
|
||||||
|
['audio/ogg; codecs=opus', 'ogg'],
|
||||||
|
['audio/x-foo', 'bin'],
|
||||||
|
['', 'bin'],
|
||||||
|
])('%s → %s', (mime, ext) => {
|
||||||
|
expect(extFromMime(mime)).toBe(ext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('paths', () => {
|
||||||
|
it('audioPath', () => {
|
||||||
|
expect(audioPath('20260927-111503-0000', 'audio/mp4')).toBe('voice/source/20260927-111503-0000.m4a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notePath uses local time and suffix from 2', () => {
|
||||||
|
expect(notePath(T, 1)).toBe('voice/inbox/2026-09-27 14-15-03.md');
|
||||||
|
expect(notePath(T, 2)).toBe('voice/inbox/2026-09-27 14-15-03 (2).md');
|
||||||
|
expect(notePath(T, 3)).toBe('voice/inbox/2026-09-27 14-15-03 (3).md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatting', () => {
|
||||||
|
it.each([
|
||||||
|
[0, '0:00'],
|
||||||
|
[47_000, '0:47'],
|
||||||
|
[125_400, '2:05'],
|
||||||
|
[-5, '0:00'],
|
||||||
|
])('formatDuration(%d) = %s', (ms, text) => {
|
||||||
|
expect(formatDuration(ms)).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatLocalTime', () => {
|
||||||
|
expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user