From 695534e2477ea27607a86a34e3a65255eef51f3b Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Mon, 27 Apr 2026 12:14:28 -0400 Subject: [PATCH] Use new location metadata fields The backend is changing which location metadata fields to store and we need to use those fields. Issue #1001 Use new location metadata fields --- .../file-viewer/file-viewer.component.html | 8 +- .../file-viewer/file-viewer.component.spec.ts | 8 + .../location-picker.component.html | 4 + .../location-picker.component.spec.ts | 241 ++++++++++++++++-- .../location-picker.component.ts | 31 ++- .../components/sidebar/sidebar.component.html | 8 +- src/app/models/locn-vo.ts | 7 + src/app/shared/pipes/pr-location.pipe.spec.ts | 71 ++++++ src/app/shared/pipes/pr-location.pipe.ts | 22 +- .../shared/services/api/record.repo.spec.ts | 96 +++++++ src/app/shared/services/api/record.repo.ts | 63 +++-- 11 files changed, 494 insertions(+), 65 deletions(-) create mode 100644 src/app/shared/pipes/pr-location.pipe.spec.ts diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.html b/src/app/file-browser/components/file-viewer/file-viewer.component.html index 632471ff6..c441a243c 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.html +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.html @@ -192,8 +192,12 @@ @if (currentRecord.LocnVO) { } - @if (currentRecord.LocnVO) { - {{ (currentRecord.LocnVO | prLocation)?.full }} + @if (currentRecord.LocnVO | prLocation; as location) { + @if (location.name) { + {{ location.name }} +
+ } + {{ location.line1 }}, {{ location.line2 }} } @if (!currentRecord.LocnVO && canEdit) { Click to add location diff --git a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts index 1f75aaec9..d06c62814 100644 --- a/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts +++ b/src/app/file-browser/components/file-viewer/file-viewer.component.spec.ts @@ -40,6 +40,13 @@ class MockPrConstantsPipe implements PipeTransform { } } +@Pipe({ name: 'prLocation', standalone: false }) +class MockPrLocationPipe implements PipeTransform { + transform(value: any): any { + return value ? { name: value.name, line1: '', line2: '' } : null; + } +} + const defaultTagList: TagVOData[] = [ { tagId: 1, @@ -147,6 +154,7 @@ describe('FileViewerComponent', () => { MockFileSizePipe, MockGetAltTextPipe, MockPrConstantsPipe, + MockPrLocationPipe, GetThumbnailPipe, ], imports: [HttpClientTestingModule], diff --git a/src/app/file-browser/components/location-picker/location-picker.component.html b/src/app/file-browser/components/location-picker/location-picker.component.html index 1a9a15b13..ab4e3cd68 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.html +++ b/src/app/file-browser/components/location-picker/location-picker.component.html @@ -15,6 +15,10 @@ @if (currentLocation) {
+ @if (currentLocationDisplay.name) { + {{ currentLocationDisplay.name }} +
+ } {{ currentLocationDisplay.line1 }}
{{ currentLocationDisplay.line2 }} diff --git a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts index 612d35d3a..543127451 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.spec.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.spec.ts @@ -1,25 +1,216 @@ -// import { async, ComponentFixture, TestBed } from '@angular/core/testing'; - -// import { LocationPickerComponent } from './location-picker.component'; - -// describe('LocationPickerComponent', () => { -// let component: LocationPickerComponent; -// let fixture: ComponentFixture; - -// beforeEach(async(() => { -// TestBed.configureTestingModule({ -// declarations: [ LocationPickerComponent ] -// }) -// .compileComponents(); -// })); - -// beforeEach(() => { -// fixture = TestBed.createComponent(LocationPickerComponent); -// component = fixture.componentInstance; -// fixture.detectChanges(); -// }); - -// it('should create', () => { -// expect(component).toBeTruthy(); -// }); -// }); +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { ApiService } from '@shared/services/api/api.service'; +import { MessageService } from '@shared/services/message/message.service'; +import { EditService } from '@core/services/edit/edit.service'; +import { ProfileService } from '@shared/services/profile/profile.service'; +import { PrLocationPipe } from '@shared/pipes/pr-location.pipe'; +import { LocationPickerComponent } from './location-picker.component'; + +const fakeGoogleMaps = { + LatLng: class { + constructor(public input: unknown) {} + }, + places: { + Autocomplete: class { + setFields(): void {} + addListener(): void {} + getPlace(): unknown { + return null; + } + }, + }, +}; + +const buildAddressComponents = ( + overrides: Partial< + Record + > = {}, +): google.maps.GeocoderAddressComponent[] => { + const defaults: Record = { + street_number: { long_name: '55', short_name: '55' }, + route: { long_name: 'Rue Plumet', short_name: 'Rue Plumet' }, + locality: { long_name: 'Paris', short_name: 'Paris' }, + postal_code: { long_name: '75007', short_name: '75007' }, + administrative_area_level_1: { + long_name: 'Ile-de-France', + short_name: 'IDF', + }, + country: { long_name: 'France', short_name: 'FR' }, + }; + const merged = { ...defaults, ...overrides }; + return Object.entries(merged) + .filter(([, value]) => value !== undefined) + .map(([type, value]) => ({ + long_name: value.long_name, + short_name: value.short_name, + types: [type], + })) as google.maps.GeocoderAddressComponent[]; +}; + +const buildPlace = ( + overrides: Partial = {}, + addressOverrides: + | Parameters[0] + | undefined = undefined, +): google.maps.places.PlaceResult => + ({ + name: "Jean Valjean's House", + address_components: buildAddressComponents(addressOverrides), + geometry: { + location: { + lat: () => 48.83, + lng: () => 2.3, + }, + }, + ...overrides, + }) as unknown as google.maps.places.PlaceResult; + +describe('LocationPickerComponent', () => { + let fixture: ComponentFixture; + let component: LocationPickerComponent; + const testWindow = window as unknown as { google?: unknown }; + let previousGoogle: unknown; + let hadGoogle = false; + + beforeAll(() => { + hadGoogle = 'google' in testWindow; + previousGoogle = testWindow.google; + testWindow.google = { maps: fakeGoogleMaps }; + }); + + afterAll(() => { + if (hadGoogle) { + testWindow.google = previousGoogle; + } else { + delete testWindow.google; + } + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [LocationPickerComponent], + providers: [ + { provide: ApiService, useValue: {} }, + { provide: MessageService, useValue: {} }, + { provide: EditService, useValue: {} }, + { provide: ProfileService, useValue: {} }, + { provide: PrLocationPipe, useValue: { transform: () => ({}) } }, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(LocationPickerComponent); + component = fixture.componentInstance; + }); + + describe('createLocnFromPlace', () => { + it('populates the spec-aligned fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.sublocation).toBe('55 Rue Plumet'); + expect(locn.city).toBe('Paris'); + expect(locn.adminOneName).toBe('Ile-de-France'); + expect(locn.country).toBe('France'); + expect(locn.postalCode).toBe('75007'); + expect(locn.latitude).toBe(48.83); + expect(locn.longitude).toBe(2.3); + }); + + it('does not write deprecated legacy fields', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.streetNumber).toBeUndefined(); + expect(locn.streetName).toBeUndefined(); + expect(locn.locality).toBeUndefined(); + expect(locn.countryCode).toBeUndefined(); + expect((locn as Record).displayName).toBeUndefined(); + expect(locn.adminOneCode).toBeUndefined(); + expect(locn.adminTwoName).toBeUndefined(); + expect(locn.adminTwoCode).toBeUndefined(); + }); + + it('sets sublocation to null but keeps the name when only a name is available', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined, route: undefined }), + ); + + expect(locn.sublocation).toBeNull(); + expect(locn.name).toBe("Jean Valjean's House"); + }); + + it('falls back to streetName alone when streetNumber is absent', () => { + const locn = component.createLocnFromPlace( + buildPlace({}, { street_number: undefined }), + ); + + expect(locn.sublocation).toBe('Rue Plumet'); + }); + + it('writes name when the place name does not include the sublocation', () => { + const locn = component.createLocnFromPlace(buildPlace()); + + expect(locn.name).toBe("Jean Valjean's House"); + }); + + it('stores the place name as-is, even when it matches the sublocation', () => { + const locn = component.createLocnFromPlace( + buildPlace({ name: '55 Rue Plumet' }), + ); + + expect(locn.name).toBe('55 Rue Plumet'); + expect(locn.sublocation).toBe('55 Rue Plumet'); + }); + + it('nulls the name when the place has no name', () => { + const locn = component.createLocnFromPlace( + buildPlace({ name: undefined }), + ); + + expect(locn.name).toBeNull(); + }); + }); + + describe('saveItem', () => { + it('persists the location directly, then references the saved locn on the record', async () => { + const savedLocn = { + locnId: 99, + name: 'The Grand Canyon', + sublocation: null, + }; + const createSpy = jasmine + .createSpy('create') + .and.resolveTo({ getLocnVO: () => savedLocn }); + const updateItemsSpy = jasmine + .createSpy('updateItems') + .and.resolveTo(undefined); + (TestBed.inject(ApiService) as unknown as { locn: unknown }).locn = { + create: createSpy, + }; + ( + TestBed.inject(EditService) as unknown as { updateItems: unknown } + ).updateItems = updateItemsSpy; + + const item = { update: jasmine.createSpy('update') }; + component.item = item as unknown as typeof component.item; + component.currentLocation = { + latitude: 36.1, + longitude: -112.1, + name: 'The Grand Canyon', + sublocation: null, + }; + + await component.saveItem(); + + expect(createSpy).toHaveBeenCalledWith(component.currentLocation); + expect(updateItemsSpy).toHaveBeenCalledWith([item], ['LocnVO']); + // The record references the SAVED locn (which now has an id), not the + // raw picker object — and points its locnId FK straight at it, so the + // backend associates it by id instead of reverse-geocoding. + expect(item.update).toHaveBeenCalledWith({ + LocnVO: savedLocn, + locnId: savedLocn.locnId, + }); + }); + }); +}); diff --git a/src/app/file-browser/components/location-picker/location-picker.component.ts b/src/app/file-browser/components/location-picker/location-picker.component.ts index 14cc1b15d..7bc586c50 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.ts @@ -188,7 +188,13 @@ export class LocationPickerComponent implements OnInit, AfterViewInit { } async saveItem() { - this.item.update({ LocnVO: this.currentLocation }); + // Persist the location directly (POST /locn stores exactly what the + // picker collected — no reverse geocoding), then reference the saved + // locn by id so the record update associates the existing location + // instead of re-deriving it. Mirrors saveProfileItem. + const response = await this.api.locn.create(this.currentLocation); + const locnVO = response.getLocnVO(); + this.item.update({ LocnVO: locnVO, locnId: locnVO.locnId }); await this.editService.updateItems([this.item], ['LocnVO']); } @@ -229,32 +235,33 @@ export class LocationPickerComponent implements OnInit, AfterViewInit { createLocnFromPlace(place: google.maps.places.PlaceResult) { const addr = place.address_components; + const streetNumber = getComponentName(addr, 'street_number'); + const streetName = getComponentName(addr, 'route'); + const sublocation = + [streetNumber, streetName].filter(Boolean).join(' ') || null; const locn: LocnVOData = { latitude: place.geometry.location.lat(), longitude: place.geometry.location.lng(), - streetNumber: getComponentName(addr, 'street_number'), - streetName: getComponentName(addr, 'route'), postalCode: getComponentName(addr, 'postal_code'), - locality: getComponentName(addr, 'locality'), adminOneName: getComponentName(addr, 'administrative_area_level_1'), - adminOneCode: getComponentName(addr, 'administrative_area_level_1', true), - adminTwoName: getComponentName(addr, 'administrative_area_level_2'), - adminTwoCode: getComponentName(addr, 'administrative_area_level_2', true), country: getComponentName(addr, 'country'), - countryCode: getComponentName(addr, 'country', true), + sublocation, + city: getComponentName(addr, 'locality'), }; + // Store the place name as-is, nulling it when absent so the backend + // clears any stale value (sublocation is nulled the same way). The pipe + // decides how to render it. + locn.name = place.name || null; + return locn; function getComponentName( addressComponents: google.maps.GeocoderAddressComponent[], type, - getShortName = true, ) { const component = find(addressComponents, (c) => c.types.includes(type)); - return component - ? component[getShortName ? 'short_name' : 'long_name'] - : null; + return component ? component.long_name : null; } } } diff --git a/src/app/file-browser/components/sidebar/sidebar.component.html b/src/app/file-browser/components/sidebar/sidebar.component.html index 14ce93477..584ee8a9e 100644 --- a/src/app/file-browser/components/sidebar/sidebar.component.html +++ b/src/app/file-browser/components/sidebar/sidebar.component.html @@ -153,8 +153,12 @@ (click)="onLocationClick()" [class.can-edit]="canEdit" > - @if (selectedItem.LocnVO) { - {{ (selectedItem.LocnVO | prLocation)?.full }} + @if (selectedItem.LocnVO | prLocation; as location) { + @if (location.name) { + {{ location.name }} +
+ } + {{ location.line1 }}, {{ location.line2 }} } @if (!selectedItem.LocnVO && !canEdit) { No location diff --git a/src/app/models/locn-vo.ts b/src/app/models/locn-vo.ts index 409b9fa0e..2ebbfb20e 100644 --- a/src/app/models/locn-vo.ts +++ b/src/app/models/locn-vo.ts @@ -1,5 +1,7 @@ import { BaseVOData } from '@models/base-vo'; +export type LocationPrecision = 'approximate' | 'uncertain' | 'unknown'; + export interface LocnVOData extends BaseVOData { locnId?: number; timeZoneId?: number; @@ -25,6 +27,11 @@ export interface LocnVOData extends BaseVOData { geometryAsArray?: string; geoCodeType?: string; geoCodeResponseAsXml?: string; + name?: string; + sublocation?: string; + city?: string; + altitudeMeters?: number; + locationPrecision?: LocationPrecision; status?: string; type?: string; } diff --git a/src/app/shared/pipes/pr-location.pipe.spec.ts b/src/app/shared/pipes/pr-location.pipe.spec.ts new file mode 100644 index 000000000..5b7ffe8bc --- /dev/null +++ b/src/app/shared/pipes/pr-location.pipe.spec.ts @@ -0,0 +1,71 @@ +import { LocnVOData } from '@models'; +import { PrLocationPipe } from './pr-location.pipe'; + +describe('PrLocationPipe', () => { + let pipe: PrLocationPipe; + + beforeEach(() => { + pipe = new PrLocationPipe(); + }); + + it('returns null when input is null', () => { + expect(pipe.transform(null)).toBeNull(); + }); + + it('renders the spec-aligned fields', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + city: 'Paris', + name: "Jean Valjean's House", + adminOneName: 'Ile-de-France', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('55 Rue Plumet'); + expect(output.line2).toContain('Paris'); + expect(output.name).toBe("Jean Valjean's House"); + expect(output.full.startsWith("Jean Valjean's House, ")).toBe(true); + }); + + it('renders the name even when it matches line1', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + name: '55 Rue Plumet', + city: 'Paris', + country: 'France', + latitude: 48.83, + longitude: 2.3, + }; + + const output = pipe.transform(locn); + + expect(output.name).toBe('55 Rue Plumet'); + }); + + it('falls through to lat/long when address fields are missing', () => { + const locn: LocnVOData = { + latitude: 48.83, + longitude: 2.3, + country: 'France', + }; + + const output = pipe.transform(locn); + + expect(output.line1).toBe('48.83, 2.3'); + expect(output.line2).toContain('France'); + }); + + it('reports unknown when no address or coordinates fall through to line2', () => { + const locn: LocnVOData = { + sublocation: '55 Rue Plumet', + }; + + const output = pipe.transform(locn); + + expect(output.line2).toBe('Unknown Location'); + }); +}); diff --git a/src/app/shared/pipes/pr-location.pipe.ts b/src/app/shared/pipes/pr-location.pipe.ts index 27d732043..cb4daef78 100644 --- a/src/app/shared/pipes/pr-location.pipe.ts +++ b/src/app/shared/pipes/pr-location.pipe.ts @@ -5,6 +5,7 @@ export interface LocnPipeOutput { line1?: string; line2?: string; full?: string; + name?: string; } @Pipe({ @@ -19,16 +20,19 @@ export class PrLocationPipe implements PipeTransform { const output: LocnPipeOutput = {}; + const hasCoordinates = + locnVO.latitude != null && + locnVO.latitude !== '' && + locnVO.longitude != null && + locnVO.longitude !== ''; + // order by priority/usefulness const queue = [ - locnVO.streetNumber - ? locnVO.streetNumber + ' ' + locnVO.streetName - : locnVO.streetName, - locnVO.locality, + locnVO.sublocation, + locnVO.city, locnVO.adminOneName, - locnVO.latitude + ', ' + locnVO.longitude, + hasCoordinates ? `${locnVO.latitude}, ${locnVO.longitude}` : null, locnVO.country, - locnVO.countryCode, ]; const line2 = []; @@ -52,6 +56,12 @@ export class PrLocationPipe implements PipeTransform { output.line2 = line2.length ? line2.join(', ') : 'Unknown Location'; output.full = output.line1 + ', ' + output.line2; + // Surface the name whenever it exists; it is rendered distinctly (bold, + // on its own line), so it does not need deduping against the sublocation. + if (locnVO.name) { + output.name = locnVO.name; + output.full = locnVO.name + ', ' + output.full; + } return output; } } diff --git a/src/app/shared/services/api/record.repo.spec.ts b/src/app/shared/services/api/record.repo.spec.ts index a5bc7e084..4cb1ebedc 100644 --- a/src/app/shared/services/api/record.repo.spec.ts +++ b/src/app/shared/services/api/record.repo.spec.ts @@ -10,6 +10,8 @@ import { convertStelaRecordToRecordVO, RecordRepo, RecordResponse, + StelaLocation, + convertStelaLocationToLocnVOData, } from '@shared/services/api/record.repo'; import { RecordVO } from '@root/app/models'; import { @@ -226,6 +228,100 @@ describe('RecordRepo', () => { }); }); + describe('convertStelaLocationToLocnVOData', () => { + it('returns null when the stela location has no id', () => { + expect(convertStelaLocationToLocnVOData(null)).toBeNull(); + expect( + convertStelaLocationToLocnVOData({ id: '' } as StelaLocation), + ).toBeNull(); + }); + + it('parses the id and remaps state/precision onto the LocnVO shape', () => { + const stelaLocation: StelaLocation = { + id: '42', + name: "Jean Valjean's House", + sublocation: '55 Rue Plumet', + city: 'Paris', + state: 'Ile-de-France', + postalCode: '75007', + country: 'France', + latitude: 48.83, + longitude: 2.3, + altitudeMeters: 35, + precision: 'approximate', + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.locnId).toBe(42); + expect(result.name).toBe("Jean Valjean's House"); + expect(result.sublocation).toBe('55 Rue Plumet'); + expect(result.city).toBe('Paris'); + expect(result.adminOneName).toBe('Ile-de-France'); + expect(result.postalCode).toBe('75007'); + expect(result.country).toBe('France'); + expect(result.altitudeMeters).toBe(35); + expect(result.locationPrecision).toBe('approximate'); + + expect((result as Record).state).toBeUndefined(); + expect((result as Record).precision).toBeUndefined(); + }); + + it('coerces null state/precision to undefined', () => { + const stelaLocation: StelaLocation = { + id: '7', + state: null, + precision: null, + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.adminOneName).toBeUndefined(); + expect(result.locationPrecision).toBeUndefined(); + }); + + it('shims legacy fields when the modern location fields are absent', () => { + const stelaLocation: StelaLocation = { + id: '7', + name: null, + sublocation: null, + city: null, + country: null, + streetNumber: '239', + streetName: 'Glenwood Road', + locality: 'Melrose Park', + countryCode: 'US', + displayName: 'Home', + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.sublocation).toBe('239 Glenwood Road'); + expect(result.city).toBe('Melrose Park'); + expect(result.name).toBe('Home'); + expect(result.country).toBe('US'); + }); + + it('prefers the modern fields over legacy when both are present', () => { + const stelaLocation: StelaLocation = { + id: '7', + name: 'Modern Name', + sublocation: 'Modern Sublocation', + city: 'Modern City', + streetNumber: '239', + streetName: 'Glenwood Road', + locality: 'Melrose Park', + displayName: 'Legacy Name', + }; + + const result = convertStelaLocationToLocnVOData(stelaLocation); + + expect(result.sublocation).toBe('Modern Sublocation'); + expect(result.city).toBe('Modern City'); + expect(result.name).toBe('Modern Name'); + }); + }); + describe('updateStelaRecord', () => { let httpV2PatchSpy: jasmine.Spy; let httpSendRequestPromiseSpy: jasmine.Spy; diff --git a/src/app/shared/services/api/record.repo.ts b/src/app/shared/services/api/record.repo.ts index 09f4ee7ea..d132d7121 100644 --- a/src/app/shared/services/api/record.repo.ts +++ b/src/app/shared/services/api/record.repo.ts @@ -5,6 +5,7 @@ import { FolderLinkType, ShareVO, LocnVOData, + LocationPrecision, } from '@root/app/models'; import { BaseResponse, @@ -71,16 +72,24 @@ interface StelaFile { } export interface StelaLocation { id: string; - streetNumber: string; - streetName: string; - locality: string; - county: string; - state: string; - latitude: number; - longitude: number; - country: string; - countryCode: string; - displayName: string | null; + name?: string | null; + sublocation?: string | null; + city?: string | null; + state?: string | null; + postalCode?: string | null; + country?: string | null; + latitude?: number | null; + longitude?: number | null; + altitudeMeters?: number | null; + precision?: LocationPrecision | null; + // Legacy columns still returned by stela for backwards compatibility. Used + // to shim locations that predate (or were geocoded without) the new fields. + streetNumber?: string | null; + streetName?: string | null; + locality?: string | null; + county?: string | null; + countryCode?: string | null; + displayName?: string | null; } interface StelaArchive { id: string; @@ -158,14 +167,32 @@ export const convertStelaSharetoShareVO = (stelaShare: StelaShare): ShareVO => }); export const convertStelaLocationToLocnVOData = ( - stelaLocation: StelaLocation, -): LocnVOData => - stelaLocation?.id - ? { - ...stelaLocation, - locnId: Number.parseInt(stelaLocation.id, 10), - } - : null; + stelaLocation: StelaLocation | null | undefined, +): LocnVOData | null => { + if (!stelaLocation?.id) { + return null; + } + const { state, precision, ...rest } = stelaLocation; + // Legacy shim: locations geocoded before the IPTC fields existed — or by + // backend paths (e.g. EXIF geocoding on upload) that still only write the + // legacy columns — arrive without name/sublocation/city. Fall back to the + // legacy fields so the rest of the app can rely on the new shape. Mirrors the + // backfill in migration 20260427120000_add_iptc_fields_to_locn. + const legacySublocation = + [stelaLocation.streetNumber, stelaLocation.streetName] + .filter(Boolean) + .join(' ') || undefined; + return { + ...rest, + locnId: Number.parseInt(stelaLocation.id, 10), + adminOneName: state ?? undefined, + locationPrecision: precision ?? undefined, + name: stelaLocation.name ?? stelaLocation.displayName ?? undefined, + sublocation: stelaLocation.sublocation ?? legacySublocation, + city: stelaLocation.city ?? stelaLocation.locality ?? undefined, + country: stelaLocation.country ?? stelaLocation.countryCode ?? undefined, + }; +}; export const convertStelaRecordToRecordVO = ( stelaRecord: StelaRecord,