capgo and adk autoupdates. card tags

This commit is contained in:
azykov@mail.ru
2026-09-29 19:36:08 +03:00
parent 1981d976eb
commit b6fb2fe40f
43 changed files with 1329 additions and 54 deletions
+7
View File
@@ -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',
+2
View File
@@ -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;
+33
View File
@@ -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>;
+51
View File
@@ -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);
});
});