Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Media" ADD COLUMN "position" INTEGER;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
13 changes: 13 additions & 0 deletions src/lib/server/media/parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]);
});
});
1 change: 1 addition & 0 deletions src/lib/server/media/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
12 changes: 11 additions & 1 deletion src/lib/server/prisma/media/service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
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: {
NeovimPlugin: {
owner,
name
}
}
},
orderBy: mediaCoverOrderBy
});
}
5 changes: 2 additions & 3 deletions src/lib/server/prisma/neovimplugins/service.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -132,9 +133,7 @@ const selectConfigCount = {
addedLastWeek: true,
dotfyleShieldAddedAt: true,
media: {
orderBy: {
thumbnail: 'desc'
}
orderBy: mediaCoverOrderBy
},
_count: {
select: {
Expand Down
5 changes: 2 additions & 3 deletions src/lib/server/rss/plugins.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -14,9 +15,7 @@ export async function createPluginsRssFeed() {
const plugins = await prismaClient.neovimPlugin.findMany({
include: {
media: {
orderBy: {
thumbnail: 'desc'
}
orderBy: mediaCoverOrderBy
}
},
orderBy: {
Expand Down
15 changes: 15 additions & 0 deletions src/lib/server/sync/plugins/mediaOrder.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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;
}
81 changes: 81 additions & 0 deletions src/lib/server/sync/plugins/sync.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
}
});
});
53 changes: 26 additions & 27 deletions src/lib/server/sync/plugins/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down