From 5d5dea10c87139e5adebdfda519bac4c17b70bd2 Mon Sep 17 00:00:00 2001 From: ergosteur Date: Mon, 17 Aug 2026 13:57:42 -0400 Subject: [PATCH] fix: stop showing a highlight item twice under two naming conventions JDownloader wrote story-shaped names for highlights during one period of its life, so the same item exists on disk as both 0ct0ber19 - C5dQPEYpd9W.mp4 2024-04-07_0ct0ber19 - 01 - C5dQPEYpd9W.mp4 which parsed to the ids "C5dQPEYpd9W" and "01 - C5dQPEYpd9W" -- two posts for one item. The leading ordinal is a position within a day's stories and carries nothing the shortcode does not, so story and highlight ids drop it. Post ids are untouched, since those are permalinks. Measured on the two real files, same archive, cache cleared between: without the fix the profile reads "Heestory - 2 items", with it "Heestory - 1 item". Co-Authored-By: Claude Opus 5 --- src/lib/archive-patterns.test.ts | 38 +++++++++++++++++++++++++++++++- src/lib/archive-patterns.ts | 28 +++++++++++++++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/lib/archive-patterns.test.ts b/src/lib/archive-patterns.test.ts index 47ca1c0..6d7cbfd 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 - 4utumn07 - Sunstory'; + const undated = parseArchiveFilename('4utumn07 - C5dQPEYpd9W.mp4', 'highlight', 1)!; + const dated = parseArchiveFilename('2024-04-07_4utumn07 - 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('4utumn07')).toBe('4utumn07'); + 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}`; +};