capgo and adk autoupdates. card tags
This commit is contained in:
@@ -1,3 +1,10 @@
|
||||
/** Case-insensitive substring search over the card's name, tags and comment; an empty query matches all. */
|
||||
export function cardMatchesQuery(card: { name: string; tags?: readonly string[]; comment?: string }, query: string): boolean {
|
||||
const q = query.trim().toLocaleLowerCase();
|
||||
if (!q) return true;
|
||||
return [card.name, ...(card.tags ?? []), card.comment ?? ''].some((text) => text.toLocaleLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/** Colors that keep white text readable. */
|
||||
export const CARD_PALETTE = [
|
||||
'#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C',
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface Card {
|
||||
codeType: CodeType;
|
||||
/** Free text, may be multiline; '' when empty. */
|
||||
comment: string;
|
||||
/** Trimmed, non-empty, unique; [] when none. */
|
||||
tags: string[];
|
||||
logo: ImageRef | null;
|
||||
images: ImageRef[];
|
||||
imagesVersion: number;
|
||||
|
||||
@@ -4,6 +4,37 @@ import { ROLES } from './models.js';
|
||||
|
||||
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
|
||||
|
||||
export const MAX_TAGS = 30;
|
||||
export const MAX_TAG_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* Tags from a comma-separated string (or a list of such strings): split by commas, trim each tag,
|
||||
* drop empty ones and exact duplicates. "один,,, два" -> ["один", "два"].
|
||||
*/
|
||||
export function parseTags(input: string | readonly string[]): string[] {
|
||||
const parts = (typeof input === 'string' ? [input] : input).flatMap((s) => s.split(','));
|
||||
const tags: string[] = [];
|
||||
for (const part of parts) {
|
||||
const tag = part.trim();
|
||||
if (tag && !tags.includes(tag)) tags.push(tag);
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Tags written as the user types them: "a, b, c". */
|
||||
export function formatTags(tags: readonly string[] | undefined): string {
|
||||
return (tags ?? []).join(', ');
|
||||
}
|
||||
|
||||
export const tagsSchema = z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.transform(parseTags)
|
||||
.pipe(
|
||||
z
|
||||
.array(z.string().max(MAX_TAG_LENGTH, `A tag is too long (max ${MAX_TAG_LENGTH} characters)`))
|
||||
.max(MAX_TAGS, `Too many tags (max ${MAX_TAGS})`),
|
||||
);
|
||||
|
||||
export const cardFieldsSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(200),
|
||||
color: colorSchema,
|
||||
@@ -16,6 +47,8 @@ export const cardFieldsSchema = z.object({
|
||||
.transform((s) => s.replace(/\r\n?/g, '\n').trim())
|
||||
.pipe(z.string().max(2000, 'Comment is too long (max 2000 characters)'))
|
||||
.optional(),
|
||||
// Comma-separated string or a list; no default: partial updates must not clear them.
|
||||
tags: tagsSchema.optional(),
|
||||
});
|
||||
export type CardFields = z.infer<typeof cardFieldsSchema>;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cardMatchesQuery } from './cardDisplay.js';
|
||||
import { cardFieldsSchema, formatTags, parseTags, tagsSchema } from './schemas.js';
|
||||
|
||||
describe('tags', () => {
|
||||
it('splits by commas, trims and drops empty tags', () => {
|
||||
expect(parseTags('один,,, два')).toEqual(['один', 'два']);
|
||||
expect(parseTags(' food , pharmacy, ')).toEqual(['food', 'pharmacy']);
|
||||
expect(parseTags('')).toEqual([]);
|
||||
expect(parseTags(' , ,, ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps inner spaces and case, drops exact duplicates', () => {
|
||||
expect(parseTags('Home Goods, home goods, Home Goods')).toEqual(['Home Goods', 'home goods']);
|
||||
});
|
||||
|
||||
it('accepts lists of comma-separated strings', () => {
|
||||
expect(parseTags(['a, b', ' c ', ''])).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('validates limits and formats back', () => {
|
||||
expect(tagsSchema.safeParse('x'.repeat(51)).success).toBe(false);
|
||||
expect(tagsSchema.safeParse(Array.from({ length: 31 }, (_, i) => `t${i}`).join(',')).success).toBe(false);
|
||||
expect(formatTags(['a', 'b c'])).toBe('a, b c');
|
||||
});
|
||||
|
||||
it('is optional in card fields (partial updates keep tags)', () => {
|
||||
const base = { name: 'Shop', color: '#123456', code: '1', codeType: 'QR' as const };
|
||||
expect(cardFieldsSchema.parse(base).tags).toBeUndefined();
|
||||
expect(cardFieldsSchema.parse({ ...base, tags: 'один,,, два' }).tags).toEqual(['один', 'два']);
|
||||
expect(cardFieldsSchema.partial().parse({}).tags).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cardMatchesQuery', () => {
|
||||
const card = { name: 'Пятёрочка', tags: ['продукты', 'Food'], comment: 'Карта Анны\nсканировать дважды' };
|
||||
|
||||
it('searches name, tags and comment, case-insensitively', () => {
|
||||
expect(cardMatchesQuery(card, 'пятёр')).toBe(true);
|
||||
expect(cardMatchesQuery(card, 'ПРОДУКТ')).toBe(true);
|
||||
expect(cardMatchesQuery(card, 'food')).toBe(true);
|
||||
expect(cardMatchesQuery(card, 'анны')).toBe(true);
|
||||
expect(cardMatchesQuery(card, 'аптека')).toBe(false);
|
||||
expect(cardMatchesQuery(card, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles cards stored before tags and comments existed', () => {
|
||||
expect(cardMatchesQuery({ name: 'Old' }, 'old')).toBe(true);
|
||||
expect(cardMatchesQuery({ name: 'Old' }, 'x')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user