mirror of
https://github.com/flyingnobita/obsidian-github-stars.git
synced 2026-07-22 05:42:52 +00:00
feat(plugin): refine refresh and embedded star updates
This commit is contained in:
parent
1a8924e40f
commit
082be15e07
7 changed files with 2270 additions and 170 deletions
119
githubStarsCore.test.ts
Normal file
119
githubStarsCore.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractReposFromContent,
|
||||
findEmbeddedGitHubLinkMatches,
|
||||
findGitHubLinkMatches,
|
||||
rewriteGitHubLinksWithStars,
|
||||
shouldUseCachedEntry,
|
||||
} from './githubStarsCore';
|
||||
|
||||
describe('shouldUseCachedEntry', () => {
|
||||
it('uses a valid cache entry when refresh is not forced', () => {
|
||||
const entry = { stars: 42, timestamp: 1_000 };
|
||||
expect(shouldUseCachedEntry(entry, 10, 2_000, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not use an expired cache entry', () => {
|
||||
const entry = { stars: 42, timestamp: 1_000 };
|
||||
expect(shouldUseCachedEntry(entry, 1, 100_000, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not use a valid cache entry when refresh is forced', () => {
|
||||
const entry = { stars: 42, timestamp: 1_000 };
|
||||
expect(shouldUseCachedEntry(entry, 10, 2_000, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractReposFromContent', () => {
|
||||
it('deduplicates repositories and strips .git suffixes', () => {
|
||||
const content = `
|
||||
[Repo](https://github.com/foo/bar)
|
||||
https://github.com/foo/bar.git
|
||||
https://github.com/baz/qux/tree/main
|
||||
`;
|
||||
|
||||
expect(extractReposFromContent(content)).toEqual([
|
||||
{ owner: 'foo', repo: 'bar' },
|
||||
{ owner: 'baz', repo: 'qux' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh-related embedded star behavior', () => {
|
||||
it('finds only links that already have embedded stars', () => {
|
||||
const content = `
|
||||
[Embedded](https://github.com/foo/embedded) ⭐ 1.2k
|
||||
[Plain](https://github.com/foo/plain)
|
||||
`;
|
||||
|
||||
expect(findEmbeddedGitHubLinkMatches(content)).toEqual([
|
||||
expect.objectContaining({ owner: 'foo', repo: 'embedded' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates only embedded stars and leaves plain links untouched', () => {
|
||||
const content = `
|
||||
[Embedded](https://github.com/foo/embedded) ⭐ 1.2k
|
||||
[Plain](https://github.com/foo/plain)
|
||||
`;
|
||||
const matches = findEmbeddedGitHubLinkMatches(content);
|
||||
const result = rewriteGitHubLinksWithStars(content, matches, (match) => {
|
||||
return match.repo === 'embedded' ? '⭐ 2.0k' : null;
|
||||
});
|
||||
|
||||
expect(result.updatedCount).toBe(1);
|
||||
expect(result.content).toContain('[Embedded](https://github.com/foo/embedded) ⭐ 2.0k');
|
||||
expect(result.content).toContain('[Plain](https://github.com/foo/plain)\n');
|
||||
expect(result.content).not.toContain('[Plain](https://github.com/foo/plain) ⭐');
|
||||
});
|
||||
|
||||
it('updates embedded occurrences of the same repo while leaving plain occurrences plain', () => {
|
||||
const content = `
|
||||
[Embedded](https://github.com/foo/shared) ⭐ 1.2k
|
||||
[Plain](https://github.com/foo/shared)
|
||||
`;
|
||||
const matches = findEmbeddedGitHubLinkMatches(content);
|
||||
const result = rewriteGitHubLinksWithStars(content, matches, () => '⭐ 2.0k');
|
||||
|
||||
expect(result.updatedCount).toBe(1);
|
||||
expect(result.content).toContain('[Embedded](https://github.com/foo/shared) ⭐ 2.0k');
|
||||
expect(result.content).toContain('[Plain](https://github.com/foo/shared)\n');
|
||||
expect(result.content).not.toContain('[Plain](https://github.com/foo/shared) ⭐');
|
||||
});
|
||||
|
||||
it('leaves embedded stars unchanged when refresh data is unavailable', () => {
|
||||
const content = `
|
||||
[Embedded](https://github.com/foo/embedded) ⭐ 1.2k
|
||||
`;
|
||||
const matches = findEmbeddedGitHubLinkMatches(content);
|
||||
const result = rewriteGitHubLinksWithStars(content, matches, () => null);
|
||||
|
||||
expect(result.updatedCount).toBe(0);
|
||||
expect(result.content).toBe(content);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explicit embed command behavior', () => {
|
||||
it('writes stars for plain and already-embedded links', () => {
|
||||
const content = `
|
||||
[Embedded](https://github.com/foo/embedded) ⭐ 1.2k
|
||||
[Plain](https://github.com/foo/plain)
|
||||
`;
|
||||
const matches = findGitHubLinkMatches(content);
|
||||
const result = rewriteGitHubLinksWithStars(content, matches, (match) => {
|
||||
if (match.repo === 'embedded') {
|
||||
return '⭐ 2.0k';
|
||||
}
|
||||
|
||||
if (match.repo === 'plain') {
|
||||
return '⭐ 10';
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
expect(result.updatedCount).toBe(2);
|
||||
expect(result.content).toContain('[Embedded](https://github.com/foo/embedded) ⭐ 2.0k');
|
||||
expect(result.content).toContain('[Plain](https://github.com/foo/plain) ⭐ 10');
|
||||
});
|
||||
});
|
||||
102
githubStarsCore.ts
Normal file
102
githubStarsCore.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
export interface CacheEntry {
|
||||
stars: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface RepoRef {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface GitHubLinkMatch extends RepoRef {
|
||||
index: number;
|
||||
length: number;
|
||||
linkText: string;
|
||||
}
|
||||
|
||||
export function shouldUseCachedEntry(
|
||||
entry: CacheEntry | undefined,
|
||||
cacheExpiryMinutes: number,
|
||||
now: number,
|
||||
forceRefresh = false
|
||||
): boolean {
|
||||
if (!entry || forceRefresh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiryTime = cacheExpiryMinutes * 60 * 1000;
|
||||
return now - entry.timestamp < expiryTime;
|
||||
}
|
||||
|
||||
export function extractReposFromContent(content: string): RepoRef[] {
|
||||
const githubUrlRegex = /https?:\/\/(?:www\.)?github\.com\/([^/\s)#?]+)\/([^/\s)#?]+)(?:\/[^\s)]*)?/g;
|
||||
const repos = new Map<string, RepoRef>();
|
||||
let match;
|
||||
|
||||
while ((match = githubUrlRegex.exec(content)) !== null) {
|
||||
const owner = match[1];
|
||||
let repo = match[2];
|
||||
|
||||
if (repo.endsWith('.git')) {
|
||||
repo = repo.slice(0, -4);
|
||||
}
|
||||
|
||||
repos.set(`${owner}/${repo}`, { owner, repo });
|
||||
}
|
||||
|
||||
return Array.from(repos.values());
|
||||
}
|
||||
|
||||
export function findGitHubLinkMatches(content: string): GitHubLinkMatch[] {
|
||||
const linkRegex = /(\[[^\]]*\]\(https?:\/\/(?:www\.)?github\.com\/([^/\s)]+)\/([^/\s)#?]+)[^)]*\))( ⭐ [\d,.]+[kMB]?)?/g;
|
||||
return collectLinkMatches(content, linkRegex);
|
||||
}
|
||||
|
||||
export function findEmbeddedGitHubLinkMatches(content: string): GitHubLinkMatch[] {
|
||||
const embeddedLinkRegex = /(\[[^\]]*\]\(https?:\/\/(?:www\.)?github\.com\/([^/\s)]+)\/([^/\s)#?]+)[^)]*\))( ⭐ [\d,.]+[kMB]?)/g;
|
||||
return collectLinkMatches(content, embeddedLinkRegex);
|
||||
}
|
||||
|
||||
export function rewriteGitHubLinksWithStars(
|
||||
content: string,
|
||||
matches: GitHubLinkMatch[],
|
||||
getFormattedStars: (match: GitHubLinkMatch) => string | null
|
||||
): { content: string; updatedCount: number } {
|
||||
let updatedContent = content;
|
||||
let updatedCount = 0;
|
||||
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const match = matches[i];
|
||||
const formattedStars = getFormattedStars(match);
|
||||
|
||||
if (formattedStars === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const replacement = `${match.linkText} ${formattedStars}`;
|
||||
updatedContent =
|
||||
updatedContent.substring(0, match.index) +
|
||||
replacement +
|
||||
updatedContent.substring(match.index + match.length);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
return { content: updatedContent, updatedCount };
|
||||
}
|
||||
|
||||
function collectLinkMatches(content: string, regex: RegExp): GitHubLinkMatch[] {
|
||||
const matches: GitHubLinkMatch[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
linkText: match[1],
|
||||
owner: match[2],
|
||||
repo: match[3].replace(/\.git$/, ''),
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
476
main.ts
476
main.ts
|
|
@ -1,18 +1,23 @@
|
|||
import { App, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, MarkdownPostProcessorContext, requestUrl } from 'obsidian';
|
||||
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
|
||||
import { RangeSetBuilder } from '@codemirror/state';
|
||||
import { extractReposFromContent, findEmbeddedGitHubLinkMatches, findGitHubLinkMatches, GitHubLinkMatch, RepoRef, rewriteGitHubLinksWithStars, shouldUseCachedEntry } from './githubStarsCore';
|
||||
|
||||
|
||||
interface GitHubStarsSettings {
|
||||
cacheExpiry: number; // Time in minutes before cache expires
|
||||
apiToken: string; // Optional GitHub API token for higher rate limits
|
||||
numberFormat: 'full' | 'abbreviated'; // Number formatting style
|
||||
updateEmbeddedStarsOnRefresh: boolean; // Whether refresh updates embedded star counts in the note
|
||||
showTokenWarnings: boolean; // Whether manual refresh shows warnings for missing or invalid tokens
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: GitHubStarsSettings = {
|
||||
cacheExpiry: 1440, // Default cache expiry: 1440 minutes (1 day)
|
||||
apiToken: "",
|
||||
numberFormat: 'abbreviated'
|
||||
numberFormat: 'abbreviated',
|
||||
updateEmbeddedStarsOnRefresh: true,
|
||||
showTokenWarnings: true
|
||||
}
|
||||
|
||||
// Interface for cache entries
|
||||
|
|
@ -21,6 +26,12 @@ interface CacheEntry {
|
|||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StarCountFetchResult {
|
||||
stars: number | null;
|
||||
refreshedFromGitHub: boolean;
|
||||
fetchFailed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the CodeMirror 6 ViewPlugin for Live Preview star count display.
|
||||
* The plugin and widget classes are defined inside this function so they
|
||||
|
|
@ -59,6 +70,7 @@ function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
|
|||
span.removeClass('github-stars-loading');
|
||||
if (stars !== null) {
|
||||
span.setText(plugin.formatStarCount(stars));
|
||||
plugin.scheduleEmbeddedStarUpdate([{ owner: this.owner, repo: this.repo }]);
|
||||
} else {
|
||||
span.setText('⭐ ?');
|
||||
span.addClass('github-stars-error');
|
||||
|
|
@ -144,6 +156,11 @@ function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
|
|||
export default class GitHubStarsPlugin extends Plugin {
|
||||
settings: GitHubStarsSettings;
|
||||
cache: Record<string, CacheEntry> = {};
|
||||
activeRefreshRunId = 0;
|
||||
lastMissingTokenWarningRefreshId = 0;
|
||||
lastInvalidTokenWarningRefreshId = 0;
|
||||
pendingEmbeddedUpdateRepos = new Set<string>();
|
||||
pendingEmbeddedUpdateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async onload() {
|
||||
// Load settings
|
||||
|
|
@ -154,32 +171,11 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
// Called when the user clicks the icon.
|
||||
new Notice('Processing GitHub stars...');
|
||||
|
||||
// Get the active markdown view
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView) {
|
||||
// Force refresh by triggering the markdown processor
|
||||
activeView.previewMode.rerender(true);
|
||||
new Notice('GitHub star counts refreshed!');
|
||||
} else {
|
||||
new Notice('No active markdown view found');
|
||||
}
|
||||
this.refreshCurrentNote();
|
||||
});
|
||||
// Perform additional things with the ribbon
|
||||
ribbonIconEl.addClass('github-stars-ribbon-class');
|
||||
|
||||
// Load cache from data
|
||||
try {
|
||||
|
||||
const loadedCache = await this.loadData();
|
||||
|
||||
// Check if we have a cache property in the loaded data
|
||||
if (loadedCache && loadedCache.cache) {
|
||||
this.cache = loadedCache.cache;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading cache:', error);
|
||||
}
|
||||
|
||||
// Register the markdown post processor to find and enhance GitHub links (Reading View)
|
||||
this.registerMarkdownPostProcessor(this.processMarkdown.bind(this));
|
||||
|
||||
|
|
@ -208,9 +204,7 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (activeView) {
|
||||
if (!checking) {
|
||||
// Force refresh by triggering the markdown processor
|
||||
activeView.previewMode.rerender(true);
|
||||
new Notice('Refreshing GitHub star counts...');
|
||||
this.refreshCurrentNote();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -259,6 +253,10 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
async loadSettings() {
|
||||
const data = await this.loadData();
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
|
||||
if (typeof this.settings.updateEmbeddedStarsOnRefresh !== 'boolean'
|
||||
&& data && typeof data.refreshEmbeddedStars === 'string') {
|
||||
this.settings.updateEmbeddedStarsOnRefresh = data.refreshEmbeddedStars === 'update';
|
||||
}
|
||||
// Store cache separately if it exists in the loaded data
|
||||
if (data && data.cache) {
|
||||
this.cache = data.cache;
|
||||
|
|
@ -274,6 +272,89 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
await this.saveData(dataToSave);
|
||||
}
|
||||
|
||||
private getActiveMarkdownFileView(): MarkdownView | null {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeView;
|
||||
}
|
||||
|
||||
private requireActiveFile(view: MarkdownView): NonNullable<MarkdownView['file']> | null {
|
||||
const file = view.file;
|
||||
if (!file) {
|
||||
new Notice('No active markdown view found');
|
||||
return null;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private getCachedStars(owner: string, repo: string): number | null {
|
||||
const stars = this.cache[`${owner}/${repo}`]?.stars;
|
||||
return typeof stars === 'number' ? stars : null;
|
||||
}
|
||||
|
||||
private getFormattedCachedStars(match: GitHubLinkMatch): string | null {
|
||||
const stars = this.getCachedStars(match.owner, match.repo);
|
||||
return stars === null ? null : this.formatStarCount(stars);
|
||||
}
|
||||
|
||||
private rerenderMarkdownView(view: MarkdownView): void {
|
||||
view.previewMode.rerender(true);
|
||||
}
|
||||
|
||||
private getRepoCacheKey(owner: string, repo: string): string {
|
||||
return `${owner}/${repo}`;
|
||||
}
|
||||
|
||||
private shouldShowTokenWarningDuringRefresh(): boolean {
|
||||
return this.settings.showTokenWarnings && this.activeRefreshRunId > 0;
|
||||
}
|
||||
|
||||
scheduleEmbeddedStarUpdate(repos: RepoRef[]): void {
|
||||
if (!this.settings.updateEmbeddedStarsOnRefresh || repos.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { owner, repo } of repos) {
|
||||
this.pendingEmbeddedUpdateRepos.add(this.getRepoCacheKey(owner, repo));
|
||||
}
|
||||
|
||||
if (this.pendingEmbeddedUpdateTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingEmbeddedUpdateTimer = setTimeout(() => {
|
||||
void this.flushScheduledEmbeddedStarUpdates();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private async flushScheduledEmbeddedStarUpdates(): Promise<void> {
|
||||
const pendingKeys = new Set(this.pendingEmbeddedUpdateRepos);
|
||||
this.pendingEmbeddedUpdateRepos.clear();
|
||||
|
||||
if (this.pendingEmbeddedUpdateTimer !== null) {
|
||||
clearTimeout(this.pendingEmbeddedUpdateTimer);
|
||||
this.pendingEmbeddedUpdateTimer = null;
|
||||
}
|
||||
|
||||
if (pendingKeys.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.rewriteActiveNoteStars({
|
||||
findMatches: findEmbeddedGitHubLinkMatches,
|
||||
noLinksNotice: null,
|
||||
progressNotice: null,
|
||||
successNotice: null,
|
||||
fetchLatest: false,
|
||||
failureNotice: null,
|
||||
shouldRewriteMatch: (match) => pendingKeys.has(this.getRepoCacheKey(match.owner, match.repo)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process markdown content to find and enhance GitHub links
|
||||
*/
|
||||
|
|
@ -316,6 +397,7 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
if (stars !== null) {
|
||||
const formattedStars = this.formatStarCount(stars);
|
||||
starSpan.setText(formattedStars);
|
||||
this.scheduleEmbeddedStarUpdate([repoInfo]);
|
||||
} else {
|
||||
starSpan.setText('⭐ ?');
|
||||
starSpan.addClass('github-stars-error');
|
||||
|
|
@ -375,42 +457,46 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
* Get star count for a GitHub repository
|
||||
* Uses cache if available and not expired
|
||||
*/
|
||||
async getStarCount(owner: string, repo: string): Promise<number | null> {
|
||||
const cacheKey = `${owner}/${repo}`;
|
||||
async getStarCount(owner: string, repo: string, forceRefresh = false): Promise<number | null> {
|
||||
const result = await this.fetchStarCount(owner, repo, forceRefresh);
|
||||
return result.stars;
|
||||
}
|
||||
|
||||
// Check if we have a valid cache entry
|
||||
if (this.cache[cacheKey]) {
|
||||
const entry = this.cache[cacheKey];
|
||||
const now = Date.now();
|
||||
const expiryTime = this.settings.cacheExpiry * 60 * 1000; // Convert minutes to milliseconds
|
||||
|
||||
// If cache entry is still valid, use it
|
||||
if (now - entry.timestamp < expiryTime) {
|
||||
return entry.stars;
|
||||
}
|
||||
private async fetchStarCount(owner: string, repo: string, forceRefresh = false): Promise<StarCountFetchResult> {
|
||||
const cacheKey = this.getRepoCacheKey(owner, repo);
|
||||
const entry = this.cache[cacheKey];
|
||||
if (shouldUseCachedEntry(entry, this.settings.cacheExpiry, Date.now(), forceRefresh)) {
|
||||
return {
|
||||
stars: entry.stars,
|
||||
refreshedFromGitHub: false,
|
||||
fetchFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch star count from GitHub API
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitHub-Stars-Plugin'
|
||||
};
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
const hasToken = this.settings.apiToken.trim() !== '';
|
||||
|
||||
// Add API token if available
|
||||
if (this.settings.apiToken && this.settings.apiToken.trim() !== '') {
|
||||
headers['Authorization'] = `token ${this.settings.apiToken.trim()}`;
|
||||
if (!hasToken
|
||||
&& this.shouldShowTokenWarningDuringRefresh()
|
||||
&& this.lastMissingTokenWarningRefreshId !== this.activeRefreshRunId) {
|
||||
console.warn('GitHub Stars: No GitHub API token configured. Requests will use the lower unauthenticated rate limit. Add a valid token in plugin settings for higher limits.');
|
||||
this.lastMissingTokenWarningRefreshId = this.activeRefreshRunId;
|
||||
}
|
||||
|
||||
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||
let response = await this.fetchRepoDetails(apiUrl, hasToken);
|
||||
|
||||
const response = await requestUrl({
|
||||
url: apiUrl,
|
||||
headers: headers,
|
||||
method: 'GET'
|
||||
});
|
||||
if (response.status === 401 && hasToken) {
|
||||
if (this.shouldShowTokenWarningDuringRefresh()
|
||||
&& this.lastInvalidTokenWarningRefreshId !== this.activeRefreshRunId) {
|
||||
console.warn(`GitHub Stars: GitHub API token was rejected for ${owner}/${repo}. Retrying without authentication. Add a valid token in plugin settings for higher limits.`);
|
||||
new Notice('GitHub API token was rejected. Retrying without authentication.');
|
||||
this.lastInvalidTokenWarningRefreshId = this.activeRefreshRunId;
|
||||
}
|
||||
|
||||
response = await this.fetchRepoDetails(apiUrl, false);
|
||||
}
|
||||
|
||||
// Handle rate limiting
|
||||
const rateLimitRemaining = response.headers['X-RateLimit-Remaining'];
|
||||
const rateLimitReset = response.headers['X-RateLimit-Reset'];
|
||||
|
||||
|
|
@ -420,138 +506,157 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
const minutesUntilReset = Math.ceil((resetTime.getTime() - now.getTime()) / (60 * 1000));
|
||||
|
||||
console.warn(`GitHub API rate limit exceeded. Resets in ${minutesUntilReset} minutes.`);
|
||||
|
||||
// If we have a cached value, use it even if expired
|
||||
if (this.cache[cacheKey]) {
|
||||
return this.cache[cacheKey].stars;
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.getFailedFetchResult(cacheKey);
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
console.error(`Repository ${owner}/${repo} not found`);
|
||||
return null;
|
||||
return this.getFailedFetchResult(cacheKey);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.error(`Failed to fetch star count for ${owner}/${repo}: ${response.status}`);
|
||||
|
||||
// If we have a cached value, use it even if expired
|
||||
if (this.cache[cacheKey]) {
|
||||
return this.cache[cacheKey].stars;
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.getFailedFetchResult(cacheKey);
|
||||
}
|
||||
|
||||
const data = response.json;
|
||||
|
||||
if (!data || typeof data.stargazers_count !== 'number') {
|
||||
console.error(`Invalid response data for ${owner}/${repo}`);
|
||||
return null;
|
||||
return this.getFailedFetchResult(cacheKey);
|
||||
}
|
||||
|
||||
const stars = data.stargazers_count;
|
||||
|
||||
// Update cache
|
||||
this.cache[cacheKey] = {
|
||||
stars,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Save cache to disk
|
||||
this.saveSettings();
|
||||
|
||||
return stars;
|
||||
return {
|
||||
stars,
|
||||
refreshedFromGitHub: true,
|
||||
fetchFailed: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching star count for ${owner}/${repo}:`, error);
|
||||
|
||||
// If we have a cached value, use it even if expired
|
||||
if (this.cache[cacheKey]) {
|
||||
return this.cache[cacheKey].stars;
|
||||
}
|
||||
|
||||
return null;
|
||||
return this.getFailedFetchResult(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
private getFailedFetchResult(cacheKey: string): StarCountFetchResult {
|
||||
const cachedStars = this.cache[cacheKey]?.stars ?? null;
|
||||
return {
|
||||
stars: cachedStars,
|
||||
refreshedFromGitHub: false,
|
||||
fetchFailed: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchRepoDetails(apiUrl: string, useToken: boolean) {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Obsidian-GitHub-Stars-Plugin',
|
||||
'X-GitHub-Api-Version': '2022-11-28'
|
||||
};
|
||||
|
||||
if (useToken) {
|
||||
headers['Authorization'] = `Bearer ${this.settings.apiToken.trim()}`;
|
||||
}
|
||||
|
||||
return requestUrl({
|
||||
url: apiUrl,
|
||||
headers,
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshCurrentNote(): Promise<void> {
|
||||
const activeView = this.getActiveMarkdownFileView();
|
||||
if (!activeView) {
|
||||
new Notice('No active markdown view found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeRefreshRunId += 1;
|
||||
const file = this.requireActiveFile(activeView);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(file);
|
||||
const repos = extractReposFromContent(content);
|
||||
const refreshResults = new Map<string, StarCountFetchResult>();
|
||||
let hadRefreshFailure = false;
|
||||
|
||||
if (repos.length > 0) {
|
||||
await Promise.all(repos.map(async ({ owner, repo }) => {
|
||||
const result = await this.fetchStarCount(owner, repo, true);
|
||||
refreshResults.set(this.getRepoCacheKey(owner, repo), result);
|
||||
hadRefreshFailure = hadRefreshFailure || result.fetchFailed;
|
||||
}));
|
||||
}
|
||||
|
||||
if (this.settings.updateEmbeddedStarsOnRefresh) {
|
||||
new Notice('Refreshing GitHub star counts and updating embedded stars...');
|
||||
await this.updateEmbeddedStarCounts(refreshResults);
|
||||
if (hadRefreshFailure) {
|
||||
new Notice('Some GitHub star counts could not be refreshed.');
|
||||
}
|
||||
this.rerenderMarkdownView(activeView);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hadRefreshFailure) {
|
||||
new Notice('Some GitHub star counts could not be refreshed.');
|
||||
}
|
||||
this.rerenderMarkdownView(activeView);
|
||||
new Notice('Refreshing GitHub star counts...');
|
||||
}
|
||||
|
||||
private async updateEmbeddedStarCounts(refreshResults: Map<string, StarCountFetchResult>): Promise<void> {
|
||||
await this.rewriteActiveNoteStars({
|
||||
findMatches: findEmbeddedGitHubLinkMatches,
|
||||
noLinksNotice: null,
|
||||
progressNotice: null,
|
||||
successNotice: null,
|
||||
fetchLatest: false,
|
||||
failureNotice: null,
|
||||
shouldRewriteMatch: (match) => refreshResults.get(this.getRepoCacheKey(match.owner, match.repo))?.refreshedFromGitHub === true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed star counts directly into the markdown file content.
|
||||
* Inserts or updates inline star text (e.g. "⭐ 1.2k") after each GitHub link.
|
||||
*/
|
||||
async embedStarCounts(): Promise<void> {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) {
|
||||
new Notice('No active markdown view found');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = activeView.file;
|
||||
let content = await this.app.vault.read(file);
|
||||
|
||||
// Match markdown links to GitHub repos, with optional existing star count.
|
||||
// Group 1: full markdown link Group 2: owner Group 3: repo Group 4: existing star text
|
||||
const linkRegex = /(\[[^\]]*\]\(https?:\/\/(?:www\.)?github\.com\/([^/\s)]+)\/([^/\s)#?]+)[^)]*\))( ⭐ [\d,.]+[kMB]?)?/g;
|
||||
|
||||
const matches: Array<{
|
||||
index: number;
|
||||
length: number;
|
||||
linkText: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}> = [];
|
||||
|
||||
let match;
|
||||
while ((match = linkRegex.exec(content)) !== null) {
|
||||
matches.push({
|
||||
index: match.index,
|
||||
length: match[0].length,
|
||||
linkText: match[1],
|
||||
owner: match[2],
|
||||
repo: match[3].replace(/\.git$/, ''),
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
new Notice('No GitHub links found in current note');
|
||||
return;
|
||||
}
|
||||
|
||||
new Notice(`Fetching star counts for ${matches.length} repositories...`);
|
||||
|
||||
// Process in reverse order so earlier positions are not shifted by replacements
|
||||
let updatedCount = 0;
|
||||
for (let i = matches.length - 1; i >= 0; i--) {
|
||||
const m = matches[i];
|
||||
const stars = await this.getStarCount(m.owner, m.repo);
|
||||
if (stars !== null) {
|
||||
const formatted = this.formatStarCount(stars);
|
||||
const replacement = `${m.linkText} ${formatted}`;
|
||||
content = content.substring(0, m.index) + replacement + content.substring(m.index + m.length);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updatedCount > 0) {
|
||||
await this.app.vault.modify(file, content);
|
||||
new Notice(`Embedded star counts for ${updatedCount} GitHub links`);
|
||||
} else {
|
||||
new Notice('Could not fetch star counts for any repositories');
|
||||
}
|
||||
await this.rewriteActiveNoteStars({
|
||||
findMatches: findGitHubLinkMatches,
|
||||
noLinksNotice: 'No GitHub links found in current note',
|
||||
progressNotice: (count) => `Fetching star counts for ${count} repositories...`,
|
||||
successNotice: (count) => `Embedded star counts for ${count} GitHub links`,
|
||||
fetchLatest: false,
|
||||
failureNotice: 'Could not fetch star counts for any repositories',
|
||||
shouldRewriteMatch: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove embedded star counts from the markdown file content
|
||||
*/
|
||||
async removeEmbeddedStarCounts(): Promise<void> {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
if (!activeView || !activeView.file) {
|
||||
const activeView = this.getActiveMarkdownFileView();
|
||||
if (!activeView) {
|
||||
new Notice('No active markdown view found');
|
||||
return;
|
||||
}
|
||||
|
||||
const file = activeView.file;
|
||||
const file = this.requireActiveFile(activeView);
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = await this.app.vault.read(file);
|
||||
|
||||
// Match GitHub markdown links followed by an embedded star count
|
||||
|
|
@ -566,6 +671,73 @@ export default class GitHubStarsPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private async rewriteActiveNoteStars(options: {
|
||||
findMatches: (content: string) => GitHubLinkMatch[];
|
||||
noLinksNotice: string | null;
|
||||
progressNotice: ((count: number) => string) | null;
|
||||
successNotice: ((count: number) => string) | null;
|
||||
fetchLatest: boolean;
|
||||
failureNotice: string | null;
|
||||
shouldRewriteMatch: (match: GitHubLinkMatch) => boolean;
|
||||
}): Promise<number> {
|
||||
const activeView = this.getActiveMarkdownFileView();
|
||||
if (!activeView) {
|
||||
new Notice('No active markdown view found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const file = this.requireActiveFile(activeView);
|
||||
if (!file) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const content = await this.app.vault.read(file);
|
||||
const matches = options.findMatches(content);
|
||||
|
||||
if (matches.length === 0) {
|
||||
if (options.noLinksNotice) {
|
||||
new Notice(options.noLinksNotice);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.progressNotice) {
|
||||
new Notice(options.progressNotice(matches.length));
|
||||
}
|
||||
|
||||
if (options.fetchLatest) {
|
||||
for (const match of matches) {
|
||||
await this.getStarCount(match.owner, match.repo, true);
|
||||
}
|
||||
} else {
|
||||
for (const match of matches) {
|
||||
await this.getStarCount(match.owner, match.repo);
|
||||
}
|
||||
}
|
||||
|
||||
const result = rewriteGitHubLinksWithStars(content, matches, (match) => {
|
||||
if (!options.shouldRewriteMatch(match)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getFormattedCachedStars(match);
|
||||
});
|
||||
|
||||
if (result.updatedCount > 0) {
|
||||
await this.app.vault.modify(file, result.content);
|
||||
if (options.successNotice) {
|
||||
new Notice(options.successNotice(result.updatedCount));
|
||||
}
|
||||
return result.updatedCount;
|
||||
}
|
||||
|
||||
if (options.failureNotice) {
|
||||
new Notice(options.failureNotice);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format star count according to settings
|
||||
*/
|
||||
|
|
@ -652,6 +824,26 @@ class GitHubStarsSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Update embedded stars on refresh')
|
||||
.setDesc('When enabled, refreshing updates existing embedded star text. When disabled, embedded star text is left unchanged and may show an older value than the refreshed star count.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.updateEmbeddedStarsOnRefresh)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.updateEmbeddedStarsOnRefresh = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Show token warnings on refresh')
|
||||
.setDesc('When enabled, manual refresh warns if the GitHub token is missing or invalid.')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.showTokenWarnings)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.showTokenWarnings = value;
|
||||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// Add a button to clear the cache
|
||||
new Setting(containerEl)
|
||||
.setName('Clear cache')
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "github-stars",
|
||||
"name": "GitHub Stars",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"minAppVersion": "1.8.9",
|
||||
"description": "Displays the number of stars for GitHub repositories mentioned in notes.",
|
||||
"author": "Flying Nobita",
|
||||
|
|
|
|||
1730
package-lock.json
generated
1730
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +1,12 @@
|
|||
{
|
||||
"name": "obsidian-github-stars",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"description": "Obsidian plugin that displays the number of stars for GitHub repositories mentioned in notes",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [
|
||||
|
|
@ -17,13 +18,14 @@
|
|||
"author": "Flying Nobita",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/node": "^20.19.37",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
"typescript": "4.7.4",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@
|
|||
"1.1.0": "1.8.9",
|
||||
"1.2.0": "1.8.9",
|
||||
"1.2.1": "1.8.9",
|
||||
"1.3.0": "1.8.9"
|
||||
"1.3.0": "1.8.9",
|
||||
"1.4.0": "1.8.9"
|
||||
}
|
||||
Loading…
Reference in a new issue