Skip to content

Commit f55ecba

Browse files
committed
fix(Edit > Album): Paste DMS geo coordinates
1 parent 52e08b9 commit f55ecba

File tree

5 files changed

+122
-52
lines changed

5 files changed

+122
-52
lines changed

eslint.config.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import js from '@eslint/js'
22
import next from '@next/eslint-plugin-next'
33
import ts from '@typescript-eslint/eslint-plugin'
44
import tsParser from '@typescript-eslint/parser'
5-
import jestDom from 'eslint-plugin-jest-dom'
65
import jsdoc from 'eslint-plugin-jsdoc'
76
import testingLibrary from 'eslint-plugin-testing-library'
87

@@ -17,7 +16,6 @@ export default [
1716
js, // ESLint core recommended rules
1817
'@typescript-eslint': ts, // TypeScript recommended rules
1918
'testing-library': testingLibrary,
20-
'jest-dom': jestDom,
2119
'@next/next': next,
2220
jsdoc,
2321
},

src/components/AdminAlbum/Fields.tsx

Lines changed: 50 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ const REFERENCE_SOURCES: ItemReferenceSource[] = ['facebook', 'google', 'instagr
2323

2424
// Parse DMS (Degrees Minutes Seconds) format to decimal
2525
// Supports formats like: 50° 22' 51.51" N or 50°22'51.51"N or 50 22 51.51 N
26-
function parseDMS(dms: string): number | null {
27-
const dmsPattern = /(\d+)[°\s]+(\d+)['\s]+(\d+(?:\.\d+)?)["\s]*([NSEW])?/i
26+
export function parseDMS(dms: string): number | null {
27+
const dmsPattern = /(\d+)[°\s]+(\d+)[\'\s]+(\d+(?:\.\d+)?)[\"\s]*([NSEW])?/i
2828
const match = dms.trim().match(dmsPattern)
2929

3030
if (!match) return null
@@ -44,6 +44,50 @@ function parseDMS(dms: string): number | null {
4444
return decimal
4545
}
4646

47+
export function parseLatInput(value: string, prevGeo?: RawXmlItem['geo']): RawXmlItem['geo'] | undefined {
48+
const trimmed = value.trim()
49+
const hasDms = /\d+[°\s]+\d+[\'\s]+\d+/.test(trimmed)
50+
const hasComma = trimmed.includes(',')
51+
52+
const splitDmsPair = (input: string): [string, string] | null => {
53+
if (hasComma) {
54+
const parts = input.split(',').map(s => s.trim()).filter(Boolean)
55+
return parts.length === 2 ? [parts[0], parts[1]] : null
56+
}
57+
const match = input.match(/\d+[°\s]+\d+[\'\s]+\d+(?:\.\d+)?[\"\s]*[NSEW]?/ig)
58+
if (match && match.length >= 2) {
59+
return [match[0].trim(), match[1].trim()]
60+
}
61+
return null
62+
}
63+
64+
// Check if value contains DMS format (degrees, minutes, seconds)
65+
if (hasDms) {
66+
const pair = splitDmsPair(trimmed)
67+
if (pair) {
68+
const lat = parseDMS(pair[0])
69+
const lon = parseDMS(pair[1])
70+
if (lat !== null && lon !== null) {
71+
return { lat: lat.toString(), lon: lon.toString(), accuracy: prevGeo?.accuracy || '' }
72+
}
73+
}
74+
const lat = parseDMS(trimmed)
75+
if (lat !== null) {
76+
return { lat: lat.toString(), lon: prevGeo?.lon || '', accuracy: prevGeo?.accuracy || '' }
77+
}
78+
}
79+
80+
// Check if the value contains a comma (decimal lat,lon format)
81+
if (hasComma) {
82+
const [lat, lon] = trimmed.split(',').map(s => s.trim())
83+
return { lat: lat || '', lon: lon || '', accuracy: prevGeo?.accuracy || '' }
84+
}
85+
86+
// Plain value for latitude
87+
const lat = trimmed
88+
return lat || prevGeo?.lon ? { lat, lon: prevGeo?.lon || '', accuracy: prevGeo?.accuracy || '' } : undefined
89+
}
90+
4791
export default function Fields(
4892
{ xmlAlbum, gallery, item, children, onItemUpdate, onXmlGenerated, editedItems, applyEditsToItems }:
4993
{
@@ -212,51 +256,10 @@ export default function Fields(
212256
value={editedItem?.geo?.lat ?? ''}
213257
onChange={(e) => {
214258
const value = e.target.value
215-
216-
// Check if value contains DMS format (degrees, minutes, seconds)
217-
if (/\d+[°\s]+\d+['\s]+\d+/.test(value)) {
218-
// Split by comma to check for lat,lon DMS pair
219-
const parts = value.split(',').map(s => s.trim())
220-
221-
if (parts.length === 2) {
222-
// Parse both lat and lon as DMS
223-
const lat = parseDMS(parts[0])
224-
const lon = parseDMS(parts[1])
225-
if (lat !== null && lon !== null) {
226-
updateItem((prev: RawXmlItem | null) => prev ? {
227-
...prev,
228-
geo: { lat: lat.toString(), lon: lon.toString(), accuracy: prev.geo?.accuracy || '' },
229-
} : null)
230-
return
231-
}
232-
} else {
233-
// Single DMS value for latitude only
234-
const lat = parseDMS(value)
235-
if (lat !== null) {
236-
updateItem((prev: RawXmlItem | null) => prev ? {
237-
...prev,
238-
geo: { lat: lat.toString(), lon: prev.geo?.lon || '', accuracy: prev.geo?.accuracy || '' },
239-
} : null)
240-
return
241-
}
242-
}
243-
}
244-
245-
// Check if the value contains a comma (decimal lat,lon format)
246-
if (value.includes(',')) {
247-
const [lat, lon] = value.split(',').map(s => s.trim())
248-
updateItem((prev: RawXmlItem | null) => prev ? {
249-
...prev,
250-
geo: { lat: lat || '', lon: lon || '', accuracy: prev.geo?.accuracy || '' },
251-
} : null)
252-
} else {
253-
// Plain value for latitude
254-
const lat = value
255-
updateItem((prev: RawXmlItem | null) => prev ? {
256-
...prev,
257-
geo: lat || prev.geo?.lon ? { lat, lon: prev.geo?.lon || '', accuracy: prev.geo?.accuracy || '' } : undefined,
258-
} : null)
259-
}
259+
updateItem((prev: RawXmlItem | null) => prev ? {
260+
...prev,
261+
geo: parseLatInput(value, prev.geo),
262+
} : null)
260263
}}
261264
placeholder="Latitude (or lat,lon or DMS)"
262265
title="Latitude (geo.lat) - paste lat,lon or DMS format to auto-split"
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { parseDMS, parseLatInput } from '../Fields'
4+
5+
const toDecimal = (degrees: number, minutes: number, seconds: number) => (
6+
degrees + (minutes / 60) + (seconds / 3600)
7+
)
8+
9+
describe('parseDMS', () => {
10+
test('parses standard DMS with direction', () => {
11+
const value = parseDMS('50° 22\' 51.51" N')
12+
expect(value).not.toBeNull()
13+
expect(value).toBeCloseTo(toDecimal(50, 22, 51.51), 6)
14+
})
15+
16+
test('parses compact DMS without spaces', () => {
17+
const value = parseDMS('50°22\'51.51"N')
18+
expect(value).not.toBeNull()
19+
expect(value).toBeCloseTo(toDecimal(50, 22, 51.51), 6)
20+
})
21+
22+
test('parses DMS with spaces and no symbols', () => {
23+
const value = parseDMS('50 22 51.51 N')
24+
expect(value).not.toBeNull()
25+
expect(value).toBeCloseTo(toDecimal(50, 22, 51.51), 6)
26+
})
27+
28+
test('negates for south and west', () => {
29+
const south = parseDMS('12° 30\' 0" S')
30+
const west = parseDMS('120° 0\' 30" W')
31+
expect(south).toBeCloseTo(-toDecimal(12, 30, 0), 6)
32+
expect(west).toBeCloseTo(-toDecimal(120, 0, 30), 6)
33+
})
34+
35+
test('parses DMS with prime symbols for lat/lon pair', () => {
36+
const lat = parseDMS('50°45′42″N')
37+
const lon = parseDMS('111°29′06″W')
38+
expect(lat).toBeCloseTo(toDecimal(50, 45, 42), 6)
39+
expect(lon).toBeCloseTo(-toDecimal(111, 29, 6), 6)
40+
})
41+
42+
test('returns null for invalid input', () => {
43+
expect(parseDMS('not a coordinate')).toBeNull()
44+
})
45+
})
46+
47+
describe('parseLatInput', () => {
48+
test('parses comma-delimited decimal lat,lon', () => {
49+
const geo = parseLatInput('50.75, -111.485', { lat: '', lon: '', accuracy: '3' })
50+
expect(geo).toEqual({ lat: '50.75', lon: '-111.485', accuracy: '3' })
51+
})
52+
53+
test('parses single number latitude and preserves longitude', () => {
54+
const geo = parseLatInput('12.34', { lat: '0', lon: '99', accuracy: '5' })
55+
expect(geo).toEqual({ lat: '12.34', lon: '99', accuracy: '5' })
56+
})
57+
58+
test('parses DMS lat/lon pair without comma', () => {
59+
const geo = parseLatInput('50°45′42″N 111°29′06″W', { lat: '', lon: '', accuracy: '' })
60+
expect(geo).toEqual({
61+
lat: (50 + (45 / 60) + (42 / 3600)).toString(),
62+
lon: (-(111 + (29 / 60) + (6 / 3600))).toString(),
63+
accuracy: '',
64+
})
65+
})
66+
})

src/utils/__tests__/walk.vitest.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -535,7 +535,7 @@ describe('Walk - util', () => {
535535
test('JPG', () => {
536536
const fileGroup = associateMedia(generateImageFilenames(2, 'jpgraw')).grouped.get('DSC03721')
537537
if (!fileGroup) {
538-
fail('Mock data is bad')
538+
throw new Error('Mock data is bad')
539539
}
540540
const received = getJpgLike(fileGroup)
541541
expect(received?.ext).toEqual('JPG')
@@ -545,7 +545,7 @@ describe('Walk - util', () => {
545545
test('JPEG', () => {
546546
const fileGroup = associateMedia(generateImageFilenames(1, 'jpeg')).grouped.get('DSC03721')
547547
if (!fileGroup) {
548-
fail('Mock data is bad')
548+
throw new Error('Mock data is bad')
549549
}
550550
const received = getJpgLike(fileGroup)
551551
expect(received?.ext).toEqual('JPEG')
@@ -555,7 +555,7 @@ describe('Walk - util', () => {
555555
test('check immutability', () => {
556556
const fileGroup = associateMedia(generateImageFilenames(1, 'jpeg')).grouped.get('DSC03721')
557557
if (!fileGroup) {
558-
fail('Mock data is bad')
558+
throw new Error('Mock data is bad')
559559
}
560560
const generated = generateImageFilenames(1, 'jpeg')
561561
getJpgLike(fileGroup)

tsconfig.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
"resolveJsonModule": true,
2020
"isolatedModules": true,
2121
"jsx": "react-jsx",
22+
"types": [
23+
"vitest/globals"
24+
],
2225
"plugins": [
2326
{
2427
"name": "next"

0 commit comments

Comments
 (0)