diff --git a/src/lib/archive-patterns.test.ts b/src/lib/archive-patterns.test.ts index 862b6d9..e1ccb73 100644 --- a/src/lib/archive-patterns.test.ts +++ b/src/lib/archive-patterns.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { parseArchiveFilename, scopedPostId } from './archive-patterns'; +import { canonicalItemId, parseArchiveFilename, scopedPostId } from './archive-patterns'; describe('parseArchiveFilename — Instagram export format', () => { it('parses a single-image post', () => { @@ -189,3 +189,39 @@ describe('dateFromMtime', () => { expect(p.dateFromMtime).toBe(false); }); }); + +/** + * JDownloader wrote story-shaped names for highlights during one period, so + * the same item exists under two conventions. They must be one post. + */ +describe('canonicalItemId', () => { + it('collapses the two highlight naming conventions onto one id', () => { + const dir = 'story highlights - 0ct0ber19 - Heestory'; + const undated = parseArchiveFilename('0ct0ber19 - C5dQPEYpd9W.mp4', 'highlight', 1)!; + const dated = parseArchiveFilename('2024-04-07_0ct0ber19 - 01 - C5dQPEYpd9W.mp4', 'highlight')!; + expect(scopedPostId(dated.postId, 'highlight', dir)) + .toBe(scopedPostId(undated.postId, 'highlight', dir)); + }); + + it('does the same for stories', () => { + const a = parseArchiveFilename('2025-10-26_u - 2 - DQRuDx9iW5Q.jpg', 'stories')!; + expect(scopedPostId(a.postId, 'stories', 'story - u')).toBe('story - u/DQRuDx9iW5Q'); + }); + + it('keeps distinct story items distinct', () => { + const a = parseArchiveFilename('2026-08-13_u - 1 - Db-UTJcCUUr.mp4', 'stories')!; + const b = parseArchiveFilename('2026-08-13_u - 2 - Db-oNJ1CWQ4.mp4', 'stories')!; + expect(scopedPostId(a.postId, 'stories', 'story - u')) + .not.toBe(scopedPostId(b.postId, 'stories', 'story - u')); + }); + + it('leaves a shortcode that merely starts with digits alone', () => { + expect(canonicalItemId('0ct0ber19')).toBe('0ct0ber19'); + expect(canonicalItemId('C5dQPEYpd9W')).toBe('C5dQPEYpd9W'); + expect(canonicalItemId('12345')).toBe('12345'); + }); + + it('does not touch posts, whose ids are permalinks', () => { + expect(scopedPostId('01 - ABC', 'posts')).toBe('01 - ABC'); + }); +}); diff --git a/src/lib/archive-patterns.ts b/src/lib/archive-patterns.ts index b6ddb98..2fad8ee 100644 --- a/src/lib/archive-patterns.ts +++ b/src/lib/archive-patterns.ts @@ -100,12 +100,36 @@ export const parseArchiveFilename = ( return null; }; +/** + * A leading per-day ordinal on a story or highlight id: `01 - C5dQPEYpd9W`. + * + * JDownloader wrote story-shaped names for highlights during one period of its + * life, so the same item exists as both `user - CODE.jpg` and + * `date_user - 01 - CODE.jpg`. Those parse to different ids and the viewer + * shows the item twice. The ordinal carries no information the shortcode does + * not — it is a position within a day's stories, and the shortcode is already + * unique — so it is dropped. + */ +const LEADING_ORDINAL = /^\d+ - (?=[A-Za-z0-9_-]+$)/; + +/** Strip the ordinal so both naming conventions land on the same post. */ +export const canonicalItemId = (postId: string): string => + postId.replace(LEADING_ORDINAL, ''); + /** * Namespace a post ID by its source directory. * * Base-profile IDs are left untouched so existing permalinks keep working; * sidecar IDs are prefixed so a shortcode appearing in both the profile and a * highlight stays two distinct posts. + * + * Story and highlight ids are canonicalised first, so an item fetched under + * two different naming conventions is one post rather than two. */ -export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string => - kind === 'posts' ? postId : `${dir ?? kind}/${postId}`; +export const scopedPostId = (postId: string, kind: SourceKind, dir?: string): string => { + if (kind === 'posts') return postId; + const id = (kind === 'stories' || kind === 'highlight') + ? canonicalItemId(postId) + : postId; + return `${dir ?? kind}/${id}`; +};