diff --git a/prisma/migrations/20260717210019_add_position_to_media/migration.sql b/prisma/migrations/20260717210019_add_position_to_media/migration.sql new file mode 100644 index 0000000..7f06954 --- /dev/null +++ b/prisma/migrations/20260717210019_add_position_to_media/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Media" ADD COLUMN "position" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3e112ec..37e4400 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -179,6 +179,7 @@ model Media { url String @unique type String @default("") thumbnail Boolean @default(false) + position Int? NeovimPlugin NeovimPlugin? @relation(fields: [neovimPluginId], references: [id]) neovimPluginId Int? } diff --git a/src/lib/server/media/parser.spec.ts b/src/lib/server/media/parser.spec.ts index 4a5b783..edae376 100644 --- a/src/lib/server/media/parser.spec.ts +++ b/src/lib/server/media/parser.spec.ts @@ -120,4 +120,17 @@ describe('GithubMediaParser.findMediaUrls()', () => { const media = run(md, 'MunifTanjim', 'nui.nvim', 'main'); expect(media[0]).toEqual(output); }); + + // Group-priority order: absolute url emitted before an earlier relative one. + it('emits by source group, not document position', () => { + const readme = [ + '![relative first](./assets/a.png)', + '![absolute second](https://raw.githubusercontent.com/me/repo/main/b.png)' + ].join('\n'); + const media = run(readme, 'me', 'repo', 'main'); + expect(media).toEqual([ + 'https://raw.githubusercontent.com/me/repo/main/b.png', + 'https://raw.githubusercontent.com/me/repo/main/assets/a.png' + ]); + }); }); diff --git a/src/lib/server/media/parser.ts b/src/lib/server/media/parser.ts index b22b7e4..0dfd3ed 100644 --- a/src/lib/server/media/parser.ts +++ b/src/lib/server/media/parser.ts @@ -26,6 +26,7 @@ export class GithubMediaParser { } findMediaUrls(readme: string, user: string, repo: string, branch: string): string[] { + // URLs are emitted grouped by source type below, not strict README order. const validGithubLinkRegex = /https:\/\/(raw|user-images).githubusercontent.com\/[a-zA-Z0-9/]+\/[a-zA-Z0-9/\-._]+.(png|jpg|jpeg|mp4|gif)/g; diff --git a/src/lib/server/prisma/media/service.ts b/src/lib/server/prisma/media/service.ts index cde0b3a..b0bfd16 100644 --- a/src/lib/server/prisma/media/service.ts +++ b/src/lib/server/prisma/media/service.ts @@ -1,5 +1,14 @@ +import type { Prisma } from '@prisma/client'; import { prismaClient } from '../client'; +// Cover-image ordering: manual thumbnail flag first, then README order +// (position; legacy rows have null position and sort last), then id for stability. +export const mediaCoverOrderBy: Prisma.MediaOrderByWithRelationInput[] = [ + { thumbnail: 'desc' }, + { position: { sort: 'asc', nulls: 'last' } }, + { id: 'asc' } +]; + export async function getMediaForPlugin(owner: string, name: string) { return prismaClient.media.findMany({ where: { @@ -7,6 +16,7 @@ export async function getMediaForPlugin(owner: string, name: string) { owner, name } - } + }, + orderBy: mediaCoverOrderBy }); } diff --git a/src/lib/server/prisma/neovimplugins/service.ts b/src/lib/server/prisma/neovimplugins/service.ts index a419430..f21f79d 100644 --- a/src/lib/server/prisma/neovimplugins/service.ts +++ b/src/lib/server/prisma/neovimplugins/service.ts @@ -1,6 +1,7 @@ import { daysAgo } from '$lib/utils'; import type { NeovimPlugin } from '@prisma/client'; import { prismaClient } from '../client'; +import { mediaCoverOrderBy } from '../media/service'; import { paginator, type PaginatedResult } from '../pagination'; import type { NeovimPluginIdentifier, @@ -132,9 +133,7 @@ const selectConfigCount = { addedLastWeek: true, dotfyleShieldAddedAt: true, media: { - orderBy: { - thumbnail: 'desc' - } + orderBy: mediaCoverOrderBy }, _count: { select: { diff --git a/src/lib/server/rss/plugins.ts b/src/lib/server/rss/plugins.ts index b64fdea..ec8a6e7 100644 --- a/src/lib/server/rss/plugins.ts +++ b/src/lib/server/rss/plugins.ts @@ -1,5 +1,6 @@ import RSS from 'rss'; import { prismaClient } from '$lib/server/prisma/client'; +import { mediaCoverOrderBy } from '$lib/server/prisma/media/service'; import { marked } from 'marked'; import { getMediaType, sanitizeHtml } from '$lib/utils'; import fs from 'fs'; @@ -14,9 +15,7 @@ export async function createPluginsRssFeed() { const plugins = await prismaClient.neovimPlugin.findMany({ include: { media: { - orderBy: { - thumbnail: 'desc' - } + orderBy: mediaCoverOrderBy } }, orderBy: { diff --git a/src/lib/server/sync/plugins/mediaOrder.ts b/src/lib/server/sync/plugins/mediaOrder.ts new file mode 100644 index 0000000..ddc0fae --- /dev/null +++ b/src/lib/server/sync/plugins/mediaOrder.ts @@ -0,0 +1,15 @@ +/** + * Assign each media URL its position, deduping by URL and keeping the first + * occurrence. Position tiebreaks cover selection; it follows the parser's emit + * order — grouped by source type, not strict README order. See PR #202. + */ +export function orderedUniqueMedia(urls: string[]): { url: string; position: number }[] { + const seen = new Set(); + const ordered: { url: string; position: number }[] = []; + for (const url of urls) { + if (seen.has(url)) continue; + seen.add(url); + ordered.push({ url, position: ordered.length }); + } + return ordered; +} diff --git a/src/lib/server/sync/plugins/sync.spec.ts b/src/lib/server/sync/plugins/sync.spec.ts new file mode 100644 index 0000000..c974a6a --- /dev/null +++ b/src/lib/server/sync/plugins/sync.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Only I/O is stubbed: the DB write and the network fetch. The parser and the +// position logic run for real, so this exercises the actual sync behaviour. +const { upsert } = vi.hoisted(() => ({ upsert: vi.fn() })); +vi.mock('$lib/server/prisma/client', () => ({ + prismaClient: { media: { upsert } } +})); + +import { PluginSyncer } from './sync'; + +const README = ` +# cool.nvim + +A tidy colorscheme. + +![banner](./media/banner.png) + +## Screenshots + +![editor](./media/editor.png) + +![telescope](./media/telescope.png) + +And the banner once more: ![banner](./media/banner.png) +`; + +function syncReadme(readme: string) { + const plugin = { id: 7, owner: 'me', name: 'cool.nvim', configCount: 0, media: [] }; + // token/repo are unused by the media path beyond the default branch + return new PluginSyncer('token', plugin as never).syncMedia(readme, { + default_branch: 'main' + } as never); +} + +const persisted = () => upsert.mock.calls.map((c) => c[0].create); +const row = (frag: string) => persisted().find((m) => m.url.endsWith(frag)); + +beforeEach(() => { + upsert.mockReset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ headers: { get: () => 'image/png' }, status: 200, statusText: 'OK' })) + ); +}); + +describe('syncMedia — README image ordering', () => { + it('stores the first README image as the cover (position 0), the rest in order', async () => { + await syncReadme(README); + + expect(row('/media/banner.png')?.position).toBe(0); + expect(row('/media/editor.png')?.position).toBe(1); + expect(row('/media/telescope.png')?.position).toBe(2); + }); + + it('collapses a repeated image to one row and keeps its first position', async () => { + await syncReadme(README); + + const banners = persisted().filter((m) => m.url.endsWith('/media/banner.png')); + expect(banners).toHaveLength(1); + expect(banners[0].position).toBe(0); + expect(persisted()).toHaveLength(3); + }); + + it('resolves relative paths against the repo raw URL', async () => { + await syncReadme(README); + + expect(row('/media/banner.png')?.url).toBe( + 'https://raw.githubusercontent.com/me/cool.nvim/main/media/banner.png' + ); + }); + + it('never writes the thumbnail flag, so a manual cover choice survives re-sync', async () => { + await syncReadme(README); + + for (const call of upsert.mock.calls) { + expect(call[0].create).not.toHaveProperty('thumbnail'); + expect(call[0].update).not.toHaveProperty('thumbnail'); + } + }); +}); diff --git a/src/lib/server/sync/plugins/sync.ts b/src/lib/server/sync/plugins/sync.ts index 1092437..eaede6d 100644 --- a/src/lib/server/sync/plugins/sync.ts +++ b/src/lib/server/sync/plugins/sync.ts @@ -6,6 +6,7 @@ import { prismaClient } from '$lib/server/prisma/client'; import type { NeovimPluginWithCount } from '$lib/server/prisma/neovimplugins/schema'; import { getPlugin, updatePlugin } from '$lib/server/prisma/neovimplugins/service'; import { getGithubToken } from '$lib/server/prisma/users/service'; +import { orderedUniqueMedia } from './mediaOrder'; import { daysAgo, hasBeenOneDay, isAdmin } from '$lib/utils'; import type { NeovimPlugin, User } from '@prisma/client'; import { TRPCError } from '@trpc/server'; @@ -62,46 +63,44 @@ export class PluginSyncer { } async syncMedia(readme: string, repo: GithubRepository) { - const media = this.mediaParser.findMediaUrls( + const urls = this.mediaParser.findMediaUrls( readme, this.plugin.owner, this.plugin.name, repo.default_branch ); const data = await Promise.all( - media.map(async (url) => { - return fetch(url).then((r) => { - const type = r.headers.get('Content-Type') ?? ''; - if (!type) { - console.log(`missing Content-Type for ${url}`, { - owner: this.plugin.owner, - name: this.plugin.name, - status: r.status, - statusText: r.statusText - }); - } - return { - url, - type, - neovimPluginId: this.plugin.id - }; - }); + orderedUniqueMedia(urls).map(async ({ url, position }) => { + const r = await fetch(url); + const type = r.headers.get('Content-Type') ?? ''; + if (!type) { + console.log(`missing Content-Type for ${url}`, { + owner: this.plugin.owner, + name: this.plugin.name, + status: r.status, + statusText: r.statusText + }); + } + return { + url, + type, + position, + neovimPluginId: this.plugin.id + }; }) ); // TODO: 1. allow multiple plugins per media url // TODO: 2. Remove stale media - await Promise.all([ - data.map(async (m) => { - return await prismaClient.media.upsert({ - where: { - url: m.url - }, + await Promise.all( + data.map((m) => + prismaClient.media.upsert({ + where: { url: m.url }, create: m, update: m - }); - }) - ]); + }) + ) + ); } syncHasDotfyleShield(readme: string) {