feat: release 1.5.0 vault-wide star maintenance

Release 1.5.0 with vault-wide GitHub star maintenance commands, CI enforcement, dependency fixes, and updated release metadata.
This commit is contained in:
Flying Nobita 2026-03-27 00:57:51 +08:00 committed by GitHub
parent 4bbe427722
commit b0d434cc5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2762 additions and 14 deletions

37
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,37 @@
name: CI
on:
push:
branches:
- main
- 'codex/**'
pull_request:
branches:
- main
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 20
- name: Install dependencies
run: pnpm install --no-frozen-lockfile
- name: Run tests
run: pnpm test
- name: Build plugin
run: pnpm build

View file

@ -1,5 +1,18 @@
# Changelog
- Mar-27, 2026 - 12:53 AM +08 - [Released 1.5.0 with vault-wide star maintenance commands and CI enforcement]
## [1.5.0] - 2026-03-27
### Added
- Commands to refresh, embed, and remove GitHub star counts across all markdown notes in the vault
- GitHub Actions CI that installs dependencies, runs `pnpm test`, and runs `pnpm build` on pushes and pull requests
- Unit tests for vault-wide repo deduplication and embedded-star removal helpers
### Changed
- Refactored vault-wide refresh and removal flows to use shared helper logic covered by unit tests
- Added declared CodeMirror dependencies required for clean CI and reproducible installs
- Mar-19, 2026 - 10:59 PM +08 - [Refined refresh behavior, added tests, and updated docs]
## [1.4.0] - 2026-03-19

View file

@ -1,7 +1,7 @@
# GitHub Stars Plugin
[![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.4.0-green?style=for-the-badge)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.5.0-green?style=for-the-badge)](CHANGELOG.md)
[![Obsidian Downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2FHEAD%2Fcommunity-plugin-stats.json&query=%24%5B%22github-stars%22%5D.downloads&logo=obsidian&label=downloads&style=for-the-badge&color=7C3AED)](https://obsidian.md/plugins?id=github-stars)
An Obsidian plugin that automatically displays GitHub star counts next to repository links in your notes — in both Reading View and Live Preview. Star counts can also be embedded directly into your markdown, making them visible outside Obsidian.
@ -57,9 +57,12 @@ The plugin can be configured in the Settings tab:
The plugin adds the following commands:
- **Refresh for current note**: Fetches fresh star counts from GitHub for all repositories in the current note and rerenders the note. If `Update embedded stars on refresh` is enabled, it also updates already-embedded star text.
- **Refresh for all notes**: Fetches fresh star counts from GitHub for repositories found across your entire vault. Duplicate repository links are deduplicated so each repository is fetched once per run. If `Update embedded stars on refresh` is enabled, embedded star text is updated across notes.
- **Clear cache**: Clears the cached star counts
- **Embed star counts in current note**: Writes star counts (e.g. `⭐ 1.2k`) directly into the markdown file after each GitHub link. Re-running updates existing counts.
- **Embed star counts in all notes**: Writes or updates embedded star counts in every markdown note in the vault.
- **Remove embedded star counts from current note**: Strips all embedded star counts from the file
- **Remove embedded star counts from all notes**: Removes embedded star counts from every markdown note in the vault.
## 🧪 Development

View file

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
extractReposFromContent,
extractUniqueReposFromContents,
findEmbeddedGitHubLinkMatches,
findGitHubLinkMatches,
removeEmbeddedGitHubStars,
rewriteGitHubLinksWithStars,
shouldUseCachedEntry,
} from './githubStarsCore';
@ -39,6 +41,27 @@ https://github.com/baz/qux/tree/main
});
});
describe('extractUniqueReposFromContents', () => {
it('deduplicates repositories across multiple note contents', () => {
const contents = [
`
[Repo](https://github.com/foo/bar)
https://github.com/baz/qux
`,
`
https://github.com/foo/bar.git
[Another](https://github.com/quux/corge/tree/main)
`,
];
expect(extractUniqueReposFromContents(contents)).toEqual([
{ owner: 'foo', repo: 'bar' },
{ owner: 'baz', repo: 'qux' },
{ owner: 'quux', repo: 'corge' },
]);
});
});
describe('refresh-related embedded star behavior', () => {
it('finds only links that already have embedded stars', () => {
const content = `
@ -117,3 +140,33 @@ describe('explicit embed command behavior', () => {
expect(result.content).toContain('[Plain](https://github.com/foo/plain) ⭐ 10');
});
});
describe('removeEmbeddedGitHubStars', () => {
it('removes all embedded star counts and reports how many were removed', () => {
const content = `
[Embedded](https://github.com/foo/embedded) ⭐ 1.2k
[Plain](https://github.com/foo/plain)
[Also Embedded](https://github.com/bar/baz) ⭐ 42
`;
expect(removeEmbeddedGitHubStars(content)).toEqual({
content: `
[Embedded](https://github.com/foo/embedded)
[Plain](https://github.com/foo/plain)
[Also Embedded](https://github.com/bar/baz)
`,
removedCount: 2,
});
});
it('leaves content unchanged when no embedded star counts exist', () => {
const content = `
[Plain](https://github.com/foo/plain)
`;
expect(removeEmbeddedGitHubStars(content)).toEqual({
content,
removedCount: 0,
});
});
});

View file

@ -14,6 +14,11 @@ export interface GitHubLinkMatch extends RepoRef {
linkText: string;
}
export interface RemovalResult {
content: string;
removedCount: number;
}
export function shouldUseCachedEntry(
entry: CacheEntry | undefined,
cacheExpiryMinutes: number,
@ -47,6 +52,18 @@ export function extractReposFromContent(content: string): RepoRef[] {
return Array.from(repos.values());
}
export function extractUniqueReposFromContents(contents: string[]): RepoRef[] {
const repos = new Map<string, RepoRef>();
for (const content of contents) {
for (const repo of extractReposFromContent(content)) {
repos.set(`${repo.owner}/${repo.repo}`, 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);
@ -84,6 +101,16 @@ export function rewriteGitHubLinksWithStars(
return { content: updatedContent, updatedCount };
}
export function removeEmbeddedGitHubStars(content: string): RemovalResult {
const starRegex = /(\[[^\]]*\]\(https?:\/\/(?:www\.)?github\.com\/[^)]+\)) ⭐ [\d,.]+[kMB]?/g;
const matches = content.match(starRegex);
return {
content: content.replace(starRegex, '$1'),
removedCount: matches?.length ?? 0,
};
}
function collectLinkMatches(content: string, regex: RegExp): GitHubLinkMatch[] {
const matches: GitHubLinkMatch[] = [];
let match;

163
main.ts
View file

@ -1,7 +1,7 @@
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';
import { extractReposFromContent, extractUniqueReposFromContents, findEmbeddedGitHubLinkMatches, findGitHubLinkMatches, GitHubLinkMatch, removeEmbeddedGitHubStars, RepoRef, rewriteGitHubLinksWithStars, shouldUseCachedEntry } from './githubStarsCore';
interface GitHubStarsSettings {
@ -212,6 +212,15 @@ export default class GitHubStarsPlugin extends Plugin {
}
});
// Add a command to refresh star counts for every markdown note in the vault
this.addCommand({
id: 'refresh-github-stars-all-notes',
name: 'Refresh for all notes',
callback: () => {
void this.refreshAllNotes();
}
});
// Add a command to embed star counts directly into the markdown file
this.addCommand({
id: 'embed-github-stars',
@ -228,6 +237,15 @@ export default class GitHubStarsPlugin extends Plugin {
}
});
// Add a command to embed star counts in every markdown note in the vault
this.addCommand({
id: 'embed-github-stars-all-notes',
name: 'Embed star counts in all notes',
callback: () => {
void this.embedStarCountsForAllNotes();
}
});
// Add a command to remove embedded star counts from the file
this.addCommand({
id: 'remove-embedded-github-stars',
@ -243,6 +261,15 @@ export default class GitHubStarsPlugin extends Plugin {
return false;
}
});
// Add a command to remove embedded star counts from every markdown note in the vault
this.addCommand({
id: 'remove-embedded-github-stars-all-notes',
name: 'Remove embedded star counts from all notes',
callback: () => {
void this.removeEmbeddedStarCountsFromAllNotes();
}
});
}
onunload() {
@ -305,6 +332,15 @@ export default class GitHubStarsPlugin extends Plugin {
view.previewMode.rerender(true);
}
private rerenderAllOpenMarkdownViews(): void {
this.app.workspace.getLeavesOfType('markdown').forEach((leaf) => {
const view = leaf.view;
if (view instanceof MarkdownView) {
this.rerenderMarkdownView(view);
}
});
}
private getRepoCacheKey(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
@ -614,6 +650,67 @@ export default class GitHubStarsPlugin extends Plugin {
new Notice('Refreshing GitHub star counts...');
}
private async refreshAllNotes(): Promise<void> {
this.activeRefreshRunId += 1;
new Notice('Refreshing GitHub star counts for all notes...');
const markdownFiles = this.app.vault.getMarkdownFiles();
const contents = await Promise.all(markdownFiles.map((file) => this.app.vault.read(file)));
const uniqueRepos = extractUniqueReposFromContents(contents);
const refreshResults = new Map<string, StarCountFetchResult>();
let hadRefreshFailure = false;
for (const repo of uniqueRepos) {
const result = await this.fetchStarCount(repo.owner, repo.repo, true);
refreshResults.set(this.getRepoCacheKey(repo.owner, repo.repo), result);
hadRefreshFailure = hadRefreshFailure || result.fetchFailed;
}
let updatedLinks = 0;
if (this.settings.updateEmbeddedStarsOnRefresh) {
updatedLinks = await this.updateEmbeddedStarCountsForAllNotes(refreshResults);
}
this.rerenderAllOpenMarkdownViews();
if (hadRefreshFailure) {
new Notice('Finished refreshing all notes, but some GitHub star counts could not be refreshed.');
}
if (this.settings.updateEmbeddedStarsOnRefresh) {
new Notice(`Refreshed ${uniqueRepos.length} repositories across all notes and updated ${updatedLinks} embedded star counts.`);
} else {
new Notice(`Refreshed ${uniqueRepos.length} repositories across all notes.`);
}
}
private async updateEmbeddedStarCountsForAllNotes(refreshResults: Map<string, StarCountFetchResult>): Promise<number> {
let totalUpdatedLinks = 0;
for (const file of this.app.vault.getMarkdownFiles()) {
const content = await this.app.vault.read(file);
const matches = findEmbeddedGitHubLinkMatches(content);
if (matches.length === 0) {
continue;
}
const result = rewriteGitHubLinksWithStars(content, matches, (match) => {
if (refreshResults.get(this.getRepoCacheKey(match.owner, match.repo))?.refreshedFromGitHub !== true) {
return null;
}
return this.getFormattedCachedStars(match);
});
if (result.updatedCount > 0) {
await this.app.vault.modify(file, result.content);
totalUpdatedLinks += result.updatedCount;
}
}
return totalUpdatedLinks;
}
private async updateEmbeddedStarCounts(refreshResults: Map<string, StarCountFetchResult>): Promise<void> {
await this.rewriteActiveNoteStars({
findMatches: findEmbeddedGitHubLinkMatches,
@ -657,20 +754,70 @@ export default class GitHubStarsPlugin extends Plugin {
return;
}
let content = await this.app.vault.read(file);
const content = await this.app.vault.read(file);
const result = removeEmbeddedGitHubStars(content);
// Match GitHub markdown links followed by an embedded star count
const starRegex = /(\[[^\]]*\]\(https?:\/\/(?:www\.)?github\.com\/[^)]+\)) ⭐ [\d,.]+[kMB]?/g;
const newContent = content.replace(starRegex, '$1');
if (newContent !== content) {
await this.app.vault.modify(file, newContent);
if (result.removedCount > 0) {
await this.app.vault.modify(file, result.content);
new Notice('Removed embedded star counts');
} else {
new Notice('No embedded star counts found');
}
}
async embedStarCountsForAllNotes(): Promise<void> {
const markdownFiles = this.app.vault.getMarkdownFiles();
let totalUpdatedLinks = 0;
new Notice(`Embedding star counts across ${markdownFiles.length} notes...`);
for (const file of markdownFiles) {
const content = await this.app.vault.read(file);
const matches = findGitHubLinkMatches(content);
if (matches.length === 0) {
continue;
}
for (const match of matches) {
await this.getStarCount(match.owner, match.repo);
}
const result = rewriteGitHubLinksWithStars(content, matches, (match) => this.getFormattedCachedStars(match));
if (result.updatedCount > 0) {
await this.app.vault.modify(file, result.content);
totalUpdatedLinks += result.updatedCount;
}
}
this.rerenderAllOpenMarkdownViews();
new Notice(`Embedded star counts for ${totalUpdatedLinks} GitHub links across all notes.`);
}
async removeEmbeddedStarCountsFromAllNotes(): Promise<void> {
const markdownFiles = this.app.vault.getMarkdownFiles();
let filesUpdated = 0;
let totalRemoved = 0;
for (const file of markdownFiles) {
const content = await this.app.vault.read(file);
const result = removeEmbeddedGitHubStars(content);
if (result.removedCount > 0) {
await this.app.vault.modify(file, result.content);
filesUpdated += 1;
totalRemoved += result.removedCount;
}
}
this.rerenderAllOpenMarkdownViews();
if (filesUpdated > 0) {
new Notice(`Removed ${totalRemoved} embedded star counts from ${filesUpdated} notes.`);
} else {
new Notice('No embedded star counts found in any notes.');
}
}
private async rewriteActiveNoteStars(options: {
findMatches: (content: string) => GitHubLinkMatch[];
noLinksNotice: string | null;

View file

@ -1,7 +1,7 @@
{
"id": "github-stars",
"name": "GitHub Stars",
"version": "1.4.0",
"version": "1.5.0",
"minAppVersion": "1.8.9",
"description": "Displays the number of stars for GitHub repositories mentioned in notes.",
"author": "Flying Nobita",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "obsidian-github-stars",
"version": "1.4.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "obsidian-github-stars",
"version": "1.4.0",
"version": "1.5.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.19.37",

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-github-stars",
"version": "1.4.0",
"version": "1.5.0",
"description": "Obsidian plugin that displays the number of stars for GitHub repositories mentioned in notes",
"main": "main.js",
"scripts": {
@ -18,6 +18,8 @@
"author": "Flying Nobita",
"license": "MIT",
"devDependencies": {
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.37.1",
"@types/node": "^20.19.37",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",

2465
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,5 +5,6 @@
"1.2.0": "1.8.9",
"1.2.1": "1.8.9",
"1.3.0": "1.8.9",
"1.4.0": "1.8.9"
"1.4.0": "1.8.9",
"1.5.0": "1.8.9"
}