mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a6b49b6b | |||
| 0855bafca7 | |||
| a39f3fe40c | |||
| f13a0aa6ee | |||
| 10d1a61614 |
@@ -685,7 +685,7 @@ extension TrekShellPlugin {
|
||||
// A held one that was delivered stops the purchase here. One that was paid
|
||||
// but never set up is handed back as bought, so its instance is set up now.
|
||||
if let held = await TrekStore.currentEntitlement(prefix: "cloud.") {
|
||||
if await TrekStore.isUnfinished(held.id) {
|
||||
if await TrekStore.awaitingSetup(held) {
|
||||
call.resolve(["kind": "bought", "jws": held.jws, "transactionId": String(held.id), "productId": held.productId])
|
||||
} else {
|
||||
call.resolve(["kind": "subscribed"])
|
||||
@@ -759,8 +759,8 @@ extension TrekShellPlugin {
|
||||
call.resolve(["held": false])
|
||||
return
|
||||
}
|
||||
let unfinished = await TrekStore.isUnfinished(held.id)
|
||||
call.resolve(["held": !unfinished])
|
||||
let awaiting = await TrekStore.awaitingSetup(held)
|
||||
call.resolve(["held": !awaiting])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -793,17 +793,54 @@ enum TrekStore {
|
||||
}
|
||||
}
|
||||
|
||||
static func currentEntitlement(prefix: String) async -> (jws: String, id: UInt64, productId: String)? {
|
||||
var newest: (date: Date, jws: String, id: UInt64, productId: String)?
|
||||
typealias Held = (jws: String, id: UInt64, productId: String, firstPurchase: Bool)
|
||||
|
||||
/// The subscription this Apple ID holds under `prefix`, newest first. The
|
||||
/// entitlements on the device come first; on a fresh install they can still
|
||||
/// be empty while the subscription runs, so the status of the subscription
|
||||
/// group, which StoreKit asks the App Store for, decides then.
|
||||
static func currentEntitlement(prefix: String) async -> Held? {
|
||||
var newest: (date: Date, held: Held)?
|
||||
for await result in Transaction.currentEntitlements {
|
||||
guard case .verified(let transaction) = result,
|
||||
transaction.productID.hasPrefix(prefix),
|
||||
transaction.revocationDate == nil else { continue }
|
||||
if newest == nil || transaction.purchaseDate > newest!.date {
|
||||
newest = (transaction.purchaseDate, result.jwsRepresentation, transaction.id, transaction.productID)
|
||||
newest = (transaction.purchaseDate, held(result.jwsRepresentation, transaction))
|
||||
}
|
||||
}
|
||||
return newest.map { (jws: $0.jws, id: $0.id, productId: $0.productId) }
|
||||
if let newest { return newest.held }
|
||||
return await subscriptionGroupHeld(prefix: prefix)
|
||||
}
|
||||
|
||||
/// The plans the control plane sells (`cloud.<plan>.<monthly|yearly>`, as
|
||||
/// productIdFor in iap.ts); one of them is enough to find their group.
|
||||
private static let plans = ["start", "basis", "plus", "pro"]
|
||||
|
||||
private static func subscriptionGroupHeld(prefix: String) async -> Held? {
|
||||
let ids = plans.flatMap { ["\(prefix)\($0).monthly", "\(prefix)\($0).yearly"] }
|
||||
guard let products = try? await Product.products(for: ids),
|
||||
let group = products.compactMap({ $0.subscription?.subscriptionGroupID }).first,
|
||||
let statuses = try? await Product.SubscriptionInfo.status(for: group) else { return nil }
|
||||
let running: [Product.SubscriptionInfo.RenewalState] = [.subscribed, .inGracePeriod, .inBillingRetryPeriod]
|
||||
for status in statuses where running.contains(status.state) {
|
||||
if case .verified(let transaction) = status.transaction, transaction.revocationDate == nil {
|
||||
return held(status.transaction.jwsRepresentation, transaction)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func held(_ jws: String, _ transaction: Transaction) -> Held {
|
||||
(jws: jws, id: transaction.id, productId: transaction.productID, firstPurchase: transaction.originalID == transaction.id)
|
||||
}
|
||||
|
||||
/// A first purchase that was paid but whose instance was never set up: handed
|
||||
/// back as bought so it is set up now. A renewal the device has not finished
|
||||
/// yet is no such purchase; its subscription is running and counts as held.
|
||||
static func awaitingSetup(_ held: Held) async -> Bool {
|
||||
guard held.firstPurchase else { return false }
|
||||
return await isUnfinished(held.id)
|
||||
}
|
||||
|
||||
/// Paid but not delivered yet: the shell finishes a transaction once its instance exists.
|
||||
|
||||
@@ -12,6 +12,7 @@ import L from 'leaflet';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import { withMarkerCategory } from '../../pages/collections/collectionsModel';
|
||||
import CollectionMap from './CollectionMap';
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
@@ -258,3 +259,26 @@ describe('CollectionMap framing', () => {
|
||||
expect(wrappedIn(map, '[--bottom-nav-h:0px]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollectionMap pins (#3073)', () => {
|
||||
const MUSEUM = { id: 4, name: 'Museum', color: '#7c3aed', icon: 'Landmark' };
|
||||
|
||||
it('FE-COMP-COLMAP-008: a saved place carries its category where the markers read it', () => {
|
||||
const pin = withMarkerCategory({ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM });
|
||||
expect(pin).toMatchObject({ category_name: 'Museum', category_color: '#7c3aed', category_icon: 'Landmark' });
|
||||
expect(withMarkerCategory(place(2, 'Somewhere', 48.2, 16.37))).toMatchObject({
|
||||
category_name: null,
|
||||
category_color: null,
|
||||
category_icon: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-COLMAP-009: the pin of a place with a category wears its colour, not the grey default', async () => {
|
||||
const { view } = await renderMap([
|
||||
{ ...place(1, 'Belvedere', 48.1915, 16.3809), category_id: 4, category: MUSEUM },
|
||||
]);
|
||||
await waitFor(() => expect(view.container.querySelector('.leaflet-marker-icon')).not.toBeNull());
|
||||
const html = view.container.querySelector('.leaflet-marker-icon')?.innerHTML ?? '';
|
||||
expect(html.toLowerCase()).toContain('#7c3aed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { MapViewAuto } from '../Map/MapViewAuto'
|
||||
import type { CollectionPlace } from '@trek/shared'
|
||||
import { mappablePlaces } from '../../pages/collections/collectionsModel'
|
||||
import { mappablePlaces, withMarkerCategory } from '../../pages/collections/collectionsModel'
|
||||
import { useTileUrl } from '../../hooks/useTileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON } from '../../constants/mapDefaults'
|
||||
import type { ViewportPadding } from '../../utils/mapViewport'
|
||||
@@ -70,7 +70,7 @@ function framePoints(places: CollectionPlace[]): [number, number][] {
|
||||
* while the map is built rather than flying in.
|
||||
*/
|
||||
export default function CollectionMap({ places, selectedPlaceId, onOpenPlace, onDeselect, dark, underBar = false }: CollectionMapProps): React.ReactElement {
|
||||
const pts = useMemo(() => mappablePlaces(places), [places])
|
||||
const pts = useMemo(() => mappablePlaces(places).map(withMarkerCategory), [places])
|
||||
const tileUrl = useTileUrl(dark ? OFM_DARK : OFM_POSITRON)
|
||||
const focusPoints = useMemo(() => framePoints(pts), [pts])
|
||||
// The places as a set: another list, a filter or a new place frames again, while a
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadUnionPlaces } from '../../store/collectionUnion'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { EditorField, INPUT, LABEL } from '../shared/dialogParts'
|
||||
import { ColumnHead, HintCard, SIDE_COLUMN, WHITE_BUTTON } from '../Planner/placeDialogParts'
|
||||
@@ -143,12 +144,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = search.trim().toLowerCase()
|
||||
const list = places.filter(p => {
|
||||
if (listFilter !== 'all' && p.collection_id !== listFilter) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
if (!q) return true
|
||||
return p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q)
|
||||
return textMatchesSearch([p.name, p.address], search)
|
||||
})
|
||||
if (center) list.sort((a, b) => distanceTo(a, center) - distanceTo(b, center))
|
||||
else list.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import type { CollectionImportablePlace, Trip } from '@trek/shared'
|
||||
@@ -128,10 +129,9 @@ export default function ImportFromTripModal({ isOpen, collectionId, collectionNa
|
||||
const savedCount = items.length - newCount
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return items
|
||||
.filter(p => (onlyNew ? !p.already_in_list : true))
|
||||
.filter(p => !q || p.name.toLowerCase().includes(q) || (p.address ?? '').toLowerCase().includes(q))
|
||||
.filter(p => textMatchesSearch([p.name, p.address], query))
|
||||
.sort((a, b) => importOrder(a) - importOrder(b) || a.name.localeCompare(b.name))
|
||||
}, [items, onlyNew, query])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { MapPin, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||
@@ -196,7 +197,7 @@ function SourceMark({ label }: { label: string | null }) {
|
||||
function pickRows<T>(value: LocationPoint | null, typed: string, places: LocationPoint[] | undefined, results: T[]) {
|
||||
const showPicks = !value && typed.length < 3 && !!places?.length
|
||||
const rows: (T | LocationPoint)[] = showPicks
|
||||
? (places ?? []).filter(p => !typed || p.name.toLowerCase().includes(typed.toLowerCase()))
|
||||
? (places ?? []).filter(p => textMatchesSearch([p.name], typed))
|
||||
: results
|
||||
return { showPicks, rows }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// FE-PLANNER-PLACEFORM-125: a place picked from a collection inside a trip (#3074).
|
||||
import { placeFormProps, seedPlaceFormStores } from '../../../tests/helpers/placeFormSetup';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import PlaceFormModal from './PlaceFormModal';
|
||||
|
||||
vi.mock('../shared/CustomTimePicker', async () => ({
|
||||
default: (await import('../../../tests/helpers/placeForm')).TimePickerStub,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedPlaceFormStores();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('PlaceFormModal with a place from a collection', () => {
|
||||
it('FE-PLANNER-PLACEFORM-125: a place picked from a collection keeps the category it was saved under (#3074)', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'collections', name: 'Collections', type: 'collections', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({
|
||||
collections: [{ id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }],
|
||||
incomingInvites: [],
|
||||
});
|
||||
vi.spyOn(collectionsApi, 'get').mockResolvedValue({
|
||||
collection: { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' },
|
||||
places: [
|
||||
{
|
||||
id: 10,
|
||||
collection_id: 1,
|
||||
name: 'Zebra Cafe',
|
||||
status: 'idea',
|
||||
lat: 35.66,
|
||||
lng: 139.7,
|
||||
category_id: 7,
|
||||
category: { id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
// A cafe saved as "Must see": the guess from its kind would be the cafe category.
|
||||
const categories = [
|
||||
{ id: 3, name: 'Cafe', color: '#7c3aed', icon: 'Coffee' },
|
||||
{ id: 7, name: 'Must see', color: '#f59e0b', icon: 'Star' },
|
||||
];
|
||||
render(<PlaceFormModal {...placeFormProps} categories={categories} onSave={onSave} />);
|
||||
fireEvent.click(await screen.findByText('Zebra Cafe'));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Zebra Cafe', category_id: '7' }));
|
||||
});
|
||||
});
|
||||
@@ -410,3 +410,15 @@ export function formPin(form: Pick<PlaceFormData, 'lat' | 'lng'>): { lat: number
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
/**
|
||||
* The category a picked place already carries, as the form's value, when this server
|
||||
* has it. A place saved in a collection keeps its category_id, and categories are the
|
||||
* server's own, so it fits the trip as it stands. Guessing from the place's kind
|
||||
* instead dropped it (#3074).
|
||||
*/
|
||||
export function savedCategoryId(result: Record<string, unknown>, categories: readonly { id: number }[]): string | null {
|
||||
const id = result.category_id
|
||||
if (typeof id !== 'number') return null
|
||||
return categories.some(c => c.id === id) ? String(id) : null
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { weekFromPeriods } from './placeHours'
|
||||
import type { PlaceOpeningHours } from '@trek/shared'
|
||||
import {
|
||||
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, mergeResult, parseCoordinatePair, placeEditForm, placeFormPayload,
|
||||
prefillForm, type PlaceFormData, type PlacePrefill,
|
||||
prefillForm, savedCategoryId, type PlaceFormData, type PlacePrefill,
|
||||
} from './PlaceFormModal.helpers'
|
||||
import { useNewCategory, usePlaceForm } from './usePlaceForm'
|
||||
import { usePlaceSearch } from './usePlaceSearch'
|
||||
@@ -135,9 +135,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// the next pick may replace it and a hand choice is never overwritten (#2282).
|
||||
const categoryGuessedRef = useRef(false)
|
||||
const guessedCategory = (result: Record<string, unknown>): string => {
|
||||
const id = guessCategoryId(result, categories || [])
|
||||
const saved = savedCategoryId(result, categories || []) // a collection's place keeps its own (#3074)
|
||||
const id = saved ? null : guessCategoryId(result, categories || [])
|
||||
categoryGuessedRef.current = id != null
|
||||
return id != null ? String(id) : ''
|
||||
return saved ?? (id != null ? String(id) : '')
|
||||
}
|
||||
// The hours the details column looked up, as a week the contact block can take over (#2472).
|
||||
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
|
||||
|
||||
@@ -53,6 +53,17 @@ const HOLE = undefined as unknown as CollectionPlace;
|
||||
describe('collectionsModel', () => {
|
||||
// ── filterPlaces ────────────────────────────────────────────────────────────
|
||||
describe('filterPlaces', () => {
|
||||
it('FE-COLLECTIONS-MODEL-045: the search finds Óbidos and São Brás typed without accents (#3075)', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'Óbidos', status: 'idea' }),
|
||||
cp({ id: 2, name: 'São Brás de Alportel', status: 'idea' }),
|
||||
cp({ id: 3, name: 'Lagos', status: 'idea', address: 'Rua de Évora' }),
|
||||
];
|
||||
expect(filterPlaces(places, 'all', 'obidos').map(p => p.id)).toEqual([1]);
|
||||
expect(filterPlaces(places, 'all', 'Sao Bras').map(p => p.id)).toEqual([2]);
|
||||
expect(filterPlaces(places, 'all', 'evora').map(p => p.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('FE-COLLECTIONS-MODEL-001: status filter keeps only the matching status', () => {
|
||||
const places = [
|
||||
cp({ id: 1, name: 'A', status: 'idea' }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { CollectionPlace, CollectionLabel } from '@trek/shared'
|
||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||
import { textMatchesSearch } from '../../utils/placeSearch'
|
||||
|
||||
/**
|
||||
* Pure data shaping + presentation metadata for the Collections page. No React
|
||||
@@ -36,7 +37,6 @@ export function filterPlaces(
|
||||
labelFilter: number[] = [],
|
||||
ratingFilter: number | 'all' = 'all',
|
||||
): CollectionPlace[] {
|
||||
const q = search.trim().toLowerCase()
|
||||
return places.filter(p => {
|
||||
if (!p) return false
|
||||
if (statusFilter !== 'all' && p.status !== statusFilter) return false
|
||||
@@ -44,12 +44,7 @@ export function filterPlaces(
|
||||
// Minimum-average-stars filter (#1435): unrated places drop out once a floor is set.
|
||||
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
||||
if (labelFilter.length && !labelFilter.some(id => (p.label_ids ?? []).includes(id))) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.address ?? '').toLowerCase().includes(q) ||
|
||||
(p.notes ?? '').toLowerCase().includes(q)
|
||||
)
|
||||
return textMatchesSearch([p.name, p.address, p.notes], search)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +88,25 @@ export function mappablePlaces(places: CollectionPlace[]): CollectionPlace[] {
|
||||
return places.filter(p => p && typeof p.lat === 'number' && typeof p.lng === 'number')
|
||||
}
|
||||
|
||||
/**
|
||||
* A saved place with its category where the trip map reads it. The server nests a
|
||||
* collection place's category (`category.color`), the markers and the hover card
|
||||
* read the flat fields a trip place carries, so every pin here was the grey default
|
||||
* (#3073).
|
||||
*/
|
||||
export function withMarkerCategory(place: CollectionPlace): CollectionPlace & {
|
||||
category_name: string | null
|
||||
category_color: string | null
|
||||
category_icon: string | null
|
||||
} {
|
||||
return {
|
||||
...place,
|
||||
category_name: place.category?.name ?? null,
|
||||
category_color: place.category?.color ?? null,
|
||||
category_icon: place.category?.icon ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a user-typed link: prepend https:// when there's no scheme so the
|
||||
* href is absolute (a bare "booking.com" would otherwise resolve relative to the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-UTIL-PLACESEARCH-001 to FE-UTIL-PLACESEARCH-004
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { placeMatchesSearch } from './placeSearch'
|
||||
import { foldForSearch, placeMatchesSearch, textMatchesSearch } from './placeSearch'
|
||||
|
||||
const place = {
|
||||
name: 'Café Central',
|
||||
@@ -32,3 +32,25 @@ describe('placeMatchesSearch', () => {
|
||||
expect(placeMatchesSearch(place, 'pizza')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('search without accents (#3075)', () => {
|
||||
it('FE-UTIL-PLACESEARCH-005: a query without accents finds the accented name, and the other way round', () => {
|
||||
for (const [query, name] of [['Obidos', 'Óbidos'], ['Evora', 'Évora'], ['Sao Bras', 'São Brás'], ['Loule', 'Loulé']]) {
|
||||
expect(placeMatchesSearch({ name, address: null, description: null, notes: null }, query)).toBe(true)
|
||||
expect(placeMatchesSearch({ name: query, address: null, description: null, notes: null }, name)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLACESEARCH-006: letters with the mark built in fold to their plain spelling', () => {
|
||||
expect(foldForSearch('Straße')).toBe('strasse')
|
||||
expect(foldForSearch('Łódź')).toBe('lodz')
|
||||
expect(foldForSearch('Ærø')).toBe('aero')
|
||||
expect(textMatchesSearch(['Kraków'], 'krakow')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-UTIL-PLACESEARCH-007: marks that change a word outside Latin script stay, so が is not か', () => {
|
||||
expect(foldForSearch('が')).toBe('が')
|
||||
expect(textMatchesSearch(['かき'], 'がき')).toBe(false)
|
||||
expect(foldForSearch('한국')).toBe('한국')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,9 +10,33 @@ type Searchable = Pick<Place, 'name' | 'address' | 'description' | 'notes'>
|
||||
* can never disagree about what a search finds.
|
||||
*/
|
||||
export function placeMatchesSearch(place: Searchable, query: string): boolean {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return [place.name, place.address, place.description, place.notes].some(
|
||||
field => !!field && field.toLowerCase().includes(q),
|
||||
)
|
||||
return textMatchesSearch([place.name, place.address, place.description, place.notes], query)
|
||||
}
|
||||
|
||||
/** The combining accents of Latin script (U+0300 to U+036F), the marks NFD splits off a letter. */
|
||||
const LATIN_MARKS = new RegExp(`[${String.fromCharCode(0x300)}-${String.fromCharCode(0x36f)}]`, 'g')
|
||||
|
||||
/** Letters that carry their mark in the letter itself, so taking the marks off leaves them as they are. */
|
||||
const FOLDED_LETTERS: Record<string, string> = { ß: 'ss', ø: 'o', æ: 'ae', œ: 'oe', ł: 'l', đ: 'd', ð: 'd', þ: 'th', ı: 'i' }
|
||||
|
||||
/**
|
||||
* Text as a search compares it: lower case and without accents, so "Obidos" finds
|
||||
* "Óbidos", "Sao Bras" finds "São Brás" and the other way round (#3075). Only
|
||||
* Latin marks go: a kana's voicing mark or a Hangul syllable stays whole, since
|
||||
* there it changes the word.
|
||||
*/
|
||||
export function foldForSearch(text: string): string {
|
||||
return text
|
||||
.toLocaleLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(LATIN_MARKS, '')
|
||||
.replace(/[ßøæœłđðþı]/g, ch => FOLDED_LETTERS[ch] ?? ch)
|
||||
.normalize('NFC')
|
||||
}
|
||||
|
||||
/** Whether any of the fields holds the query, accents and case aside. An empty query matches everything. */
|
||||
export function textMatchesSearch(fields: readonly (string | null | undefined)[], query: string): boolean {
|
||||
const q = foldForSearch(query.trim())
|
||||
if (!q) return true
|
||||
return fields.some(field => !!field && foldForSearch(field).includes(q))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user