fix: update obsidian community routes
This commit is contained in:
parent
265921b7b8
commit
30776baef7
|
|
@ -0,0 +1,122 @@
|
|||
import { http, HttpResponse } from 'msw';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { route as pluginsRoute } from './routes/obsidian/plugins';
|
||||
import { route as themesRoute } from './routes/obsidian/themes';
|
||||
|
||||
const searchConfigHtml = '<script>{"config":{"apiKey":"test-search-key","url":"https://community.obsidian.md/api/search"}}</script>';
|
||||
|
||||
describe('/obsidian', () => {
|
||||
it('builds the plugins feed from the community search API sorted by creation time', async () => {
|
||||
const { default: server } = await import('@/setup.test');
|
||||
|
||||
server.use(
|
||||
http.get('https://community.obsidian.md/search', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(url.searchParams.get('type')).toBe('plugin');
|
||||
expect(url.searchParams.get('sort')).toBe('created');
|
||||
|
||||
return HttpResponse.html(searchConfigHtml);
|
||||
}),
|
||||
http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
|
||||
expect(url.searchParams.get('q')).toBe('*');
|
||||
expect(url.searchParams.get('query_by')).toBe('name,authors,short_desc');
|
||||
expect(url.searchParams.get('filter_by')).toBe('type:=plugin');
|
||||
expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
|
||||
expect(url.searchParams.get('per_page')).toBe('54');
|
||||
|
||||
return HttpResponse.json({
|
||||
hits: [
|
||||
{
|
||||
document: {
|
||||
authors: ['slaymish'],
|
||||
downloads: 2,
|
||||
github_created_at: '2026-05-14T05:08:50Z',
|
||||
id: '5757',
|
||||
name: 'Synod',
|
||||
short_desc: 'Run a council of LLM value agents over your journal.',
|
||||
slug: 'synod',
|
||||
tags: ['ai', 'import'],
|
||||
type: 'plugin',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const feed = await pluginsRoute.handler();
|
||||
|
||||
expect(feed.title).toBe('Obsidian Plugins');
|
||||
expect(feed.link).toBe('https://community.obsidian.md/search?type=plugin&sort=created');
|
||||
expect(feed.item).toHaveLength(1);
|
||||
expect(feed.item[0]).toMatchObject({
|
||||
title: 'Synod',
|
||||
description: 'Run a council of LLM value agents over your journal.',
|
||||
link: 'https://community.obsidian.md/plugins/synod',
|
||||
guid: 'plugin:5757',
|
||||
author: 'slaymish',
|
||||
category: ['ai', 'import'],
|
||||
});
|
||||
expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T05:08:50.000Z');
|
||||
});
|
||||
|
||||
it('builds the themes feed from the community search API sorted by creation time', async () => {
|
||||
const { default: server } = await import('@/setup.test');
|
||||
|
||||
server.use(
|
||||
http.get('https://community.obsidian.md/search', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(url.searchParams.get('type')).toBe('theme');
|
||||
expect(url.searchParams.get('sort')).toBe('created');
|
||||
|
||||
return HttpResponse.html(searchConfigHtml);
|
||||
}),
|
||||
http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
|
||||
expect(url.searchParams.get('filter_by')).toBe('type:=theme');
|
||||
expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
|
||||
|
||||
return HttpResponse.json({
|
||||
hits: [
|
||||
{
|
||||
document: {
|
||||
authors: ['jshuntley'],
|
||||
downloads: 0,
|
||||
github_created_at: '2026-05-14T00:28:26Z',
|
||||
id: '5749',
|
||||
name: 'Fjord',
|
||||
short_desc: 'Fjord colorscheme for Obsidian.',
|
||||
slug: 'fjord',
|
||||
tags: [],
|
||||
type: 'theme',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const feed = await themesRoute.handler();
|
||||
|
||||
expect(feed.title).toBe('Obsidian Themes');
|
||||
expect(feed.link).toBe('https://community.obsidian.md/search?type=theme&sort=created');
|
||||
expect(feed.item).toHaveLength(1);
|
||||
expect(feed.item[0]).toMatchObject({
|
||||
title: 'Fjord',
|
||||
description: 'Fjord colorscheme for Obsidian.',
|
||||
link: 'https://community.obsidian.md/themes/fjord',
|
||||
guid: 'theme:5749',
|
||||
author: 'jshuntley',
|
||||
category: [],
|
||||
});
|
||||
expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T00:28:26.000Z');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,40 +1,16 @@
|
|||
import type { Route } from '@/types';
|
||||
import ofetch from '@/utils/ofetch';
|
||||
|
||||
import { buildCommunityFeed } from './utils';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/plugins',
|
||||
name: 'Obsidian Plugins',
|
||||
name: 'Plugins',
|
||||
maintainers: ['DIYgod'],
|
||||
categories: ['program-update'],
|
||||
example: '/obsidian/plugins',
|
||||
handler,
|
||||
};
|
||||
|
||||
async function handler() {
|
||||
const data = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/refs/heads/master/community-plugins.json')) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
repo: string;
|
||||
}>;
|
||||
const stats = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugin-stats.json')) as {
|
||||
[key: string]: {
|
||||
downloads: number;
|
||||
updated: number;
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
title: 'Obsidian Plugins',
|
||||
link: 'https://obsidian.md/plugins',
|
||||
item: data.map((item) => ({
|
||||
title: item.name,
|
||||
description: `${item.description}<br><br>Downloads: ${stats[item.id].downloads}`,
|
||||
link: `https://github.com/${item.repo}`,
|
||||
guid: item.id,
|
||||
pubDate: new Date(stats[item.id].updated),
|
||||
author: item.author,
|
||||
})),
|
||||
};
|
||||
function handler() {
|
||||
return buildCommunityFeed('plugin');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import type { Route } from '@/types';
|
||||
|
||||
import { buildCommunityFeed } from './utils';
|
||||
|
||||
export const route: Route = {
|
||||
path: '/themes',
|
||||
name: 'Themes',
|
||||
maintainers: ['DIYgod'],
|
||||
categories: ['program-update'],
|
||||
example: '/obsidian/themes',
|
||||
handler,
|
||||
};
|
||||
|
||||
function handler() {
|
||||
return buildCommunityFeed('theme');
|
||||
}
|
||||
|
|
@ -1,8 +1,96 @@
|
|||
const regex = /([^/]+)\.md$/;
|
||||
import type { Data, DataItem } from '@/types';
|
||||
import ofetch from '@/utils/ofetch';
|
||||
import { parseDate } from '@/utils/parse-date';
|
||||
|
||||
const getTitle = (path: string): string => {
|
||||
const match = path.match(regex);
|
||||
return match ? match[1] : '';
|
||||
type CommunityEntryType = 'plugin' | 'theme';
|
||||
|
||||
type CommunitySearchConfig = {
|
||||
apiKey: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export { getTitle };
|
||||
type CommunitySearchHit = {
|
||||
document: {
|
||||
authors?: string[];
|
||||
github_created_at?: string;
|
||||
github_updated_at?: string;
|
||||
id: string;
|
||||
latest_release_at?: string;
|
||||
name: string;
|
||||
short_desc?: string;
|
||||
slug: string;
|
||||
tags?: string[];
|
||||
type: CommunityEntryType;
|
||||
};
|
||||
};
|
||||
|
||||
type CommunitySearchResponse = {
|
||||
hits?: CommunitySearchHit[];
|
||||
};
|
||||
|
||||
const hitsPerPage = 54;
|
||||
const searchPageBaseUrl = 'https://community.obsidian.md/search';
|
||||
const titleRegex = /([^/]+)\.md$/;
|
||||
|
||||
export async function buildCommunityFeed(type: CommunityEntryType): Promise<Data> {
|
||||
const pageUrl = getSearchPageUrl(type);
|
||||
const searchConfig = await getSearchConfig(pageUrl);
|
||||
const data = await ofetch<CommunitySearchResponse>(`${searchConfig.url}/collections/entries/documents/search`, {
|
||||
headers: {
|
||||
'X-TYPESENSE-API-KEY': searchConfig.apiKey,
|
||||
},
|
||||
query: {
|
||||
filter_by: `type:=${type}`,
|
||||
per_page: hitsPerPage,
|
||||
q: '*',
|
||||
query_by: 'name,authors,short_desc',
|
||||
sort_by: 'github_created_at:desc',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
title: `Obsidian ${type === 'plugin' ? 'Plugins' : 'Themes'}`,
|
||||
link: pageUrl,
|
||||
item: data.hits?.map(({ document }) => buildItem(document)) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function getTitle(path: string): string {
|
||||
const match = path.match(titleRegex);
|
||||
return match ? match[1] : '';
|
||||
}
|
||||
|
||||
function getSearchPageUrl(type: CommunityEntryType) {
|
||||
const url = new URL(searchPageBaseUrl);
|
||||
url.searchParams.set('type', type);
|
||||
url.searchParams.set('sort', 'created');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function getSearchConfig(pageUrl: string): Promise<CommunitySearchConfig> {
|
||||
const html = await ofetch<string>(pageUrl);
|
||||
const match = html.match(/apiKey\\":\\"([^"\\]+)\\"[\s\S]*?url\\":\\"([^"\\]+)\\"/) ?? html.match(/"apiKey":"([^"]+)"[\s\S]*?"url":"([^"]+)"/);
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Unable to locate Obsidian community search API config');
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: match[1],
|
||||
url: match[2].replaceAll(String.raw`\/`, '/'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildItem(document: CommunitySearchHit['document']): DataItem {
|
||||
return {
|
||||
title: document.name,
|
||||
description: document.short_desc,
|
||||
link: `https://community.obsidian.md/${document.type}s/${document.slug}`,
|
||||
guid: `${document.type}:${document.id}`,
|
||||
pubDate: document.github_created_at ? parseDate(document.github_created_at) : undefined,
|
||||
updated: document.latest_release_at ? parseDate(document.latest_release_at) : document.github_updated_at ? parseDate(document.github_updated_at) : undefined,
|
||||
author: document.authors?.join(', '),
|
||||
category: document.tags,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
|
|||
import { configDefaults, defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tsconfigPaths()],
|
||||
plugins: [tsconfigPaths({ root: '.' })],
|
||||
test: {
|
||||
watch: false,
|
||||
coverage: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue