/** * Data model definitions and factory functions. * * All IDs are generated with a simple ULID-like timestamp + random suffix * to keep things sortable and collision-resistant without external deps. */ /** * Generate a unique ID (timestamp-based, sortable). * @returns {string} */ export function generateId() { const timestamp = Date.now().toString(36) const random = Math.random().toString(36).slice(2, 10) return `${timestamp}_${random}` } /** * @typedef {Object} CredentialEntry * @property {string} id - Unique identifier * @property {string} title - Display name (e.g. "GitHub", "Gmail") * @property {string} username - Login username or email * @property {string} encryptedPassword - AES-GCM encrypted password blob (JSON string) * @property {string} [url] - Website URL * @property {string} [notes] - Free-form notes * @property {string} [groupId] - Reference to a Group id (empty string = no group) * @property {string[]} [tags] - Free-form tags * @property {string} createdAt - ISO timestamp * @property {string} updatedAt - ISO timestamp */ /** * Create a new CredentialEntry. * * @param {Object} data * @param {string} data.title * @param {string} data.username * @param {string} data.encryptedPassword - Must already be encrypted * @param {string} [data.url] * @param {string} [data.notes] * @param {string} [data.groupId] * @param {string[]} [data.tags] * @returns {CredentialEntry} */ export function createEntry(data) { const now = new Date().toISOString() return { id: generateId(), title: data.title.trim(), username: data.username.trim(), encryptedPassword: data.encryptedPassword, url: data.url?.trim() || '', notes: data.notes?.trim() || '', groupId: data.groupId || '', tags: data.tags || [], createdAt: now, updatedAt: now, } } /** * Update an existing entry, preserving id and createdAt. * * @param {CredentialEntry} existing * @param {Object} data - Fields to update * @returns {CredentialEntry} */ export function updateEntry(existing, data) { return { ...existing, title: data.title !== undefined ? data.title.trim() : existing.title, username: data.username !== undefined ? data.username.trim() : existing.username, encryptedPassword: data.encryptedPassword !== undefined ? data.encryptedPassword : existing.encryptedPassword, url: data.url !== undefined ? data.url.trim() : existing.url, notes: data.notes !== undefined ? data.notes.trim() : existing.notes, groupId: data.groupId !== undefined ? data.groupId : existing.groupId, tags: data.tags !== undefined ? data.tags : existing.tags, updatedAt: new Date().toISOString(), } } /** * @typedef {Object} Group * @property {string} id - Unique identifier * @property {string} name - Display name * @property {string} [color] - Hex color for UI accent * @property {string} createdAt - ISO timestamp */ const GROUP_COLORS = [ '#6c63ff', '#e5484d', '#34d399', '#fbbf24', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#06b6d4', ] /** * Create a new Group. * * @param {string} name * @param {string} [color] * @returns {Group} */ export function createGroup(name, color) { return { id: generateId(), name: name.trim(), color: color || GROUP_COLORS[Math.floor(Math.random() * GROUP_COLORS.length)], createdAt: new Date().toISOString(), } } /** * Validate a credential entry form submission. * * @param {Object} data * @returns {{ valid: boolean, errors: string[] }} */ export function validateEntry(data) { const errors = [] if (!data.title || !data.title.trim()) errors.push('Title is required') if (!data.username || !data.username.trim()) errors.push('Username is required') if (!data.encryptedPassword) errors.push('Password is required') return { valid: errors.length === 0, errors } } /** * Validate a group name. * * @param {string} name * @returns {{ valid: boolean, errors: string[] }} */ export function validateGroup(name) { const errors = [] if (!name || !name.trim()) errors.push('Group name is required') else if (name.trim().length > 50) errors.push('Group name must be 50 characters or less') return { valid: errors.length === 0, errors } }