Compare commits

...

16 commits
1.2.0 ... main

Author SHA1 Message Date
Flying Nobita
b7a043454f
fix: release GitHub Stars 1.5.2 (#9)
Fix startup active-view warnings and keep automatic embedded-star updates scoped to their source notes.
2026-07-21 13:55:01 +08:00
Flying Nobita
bed0a1abd3
docs: add pull request template
Add a concise default PR template covering summary, changes, verification, and issue-closing references.
2026-03-27 13:04:56 +08:00
Flying Nobita
b4b9d71569
chore: release 1.5.1 (#6)
* chore: align package tooling with sample plugin

* chore: release 1.5.1
2026-03-27 01:25:37 +08:00
Flying Nobita
b0d434cc5c
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.
2026-03-27 00:57:51 +08:00
flyingnobita
4bbe427722
docs(agent): require full releases and assets 2026-03-20 00:31:54 +08:00
flyingnobita
731e300b00
chore(release): 1.4.0 docs and metadata 2026-03-20 00:21:43 +08:00
flyingnobita
082be15e07
feat(plugin): refine refresh and embedded star updates 2026-03-19 23:50:36 +08:00
flyingnobita
1a8924e40f
docs: update README support section 2026-03-19 21:13:16 +08:00
flyingnobita
0faf143556
chore: release 1.3.0 2026-03-19 15:25:49 +08:00
flyingnobita
f77f6c2b4a
feat: remove configurable display format, hardcode to {stars} 2026-03-19 15:25:43 +08:00
flyingnobita
5a3db79c30
1.2.1 2026-03-18 17:26:37 +08:00
flyingnobita
b97207dd9b
docs: update README with corrected text 2026-03-18 17:24:16 +08:00
flyingnobita
53707d6e68
docs: update README cache expiry default to 1 day
Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-18 17:21:07 +08:00
flyingnobita
dc81b2b893
chore: change default cache expiry to 1 day (1440 mins)
Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-18 17:15:24 +08:00
flyingnobita
a13c35f725
fix: skip decoration when star count is already embedded in markdown
Both the Live Preview ViewPlugin and Reading View post-processor now
detect existing embedded star counts (e.g.  1.2k) after GitHub
links and skip adding a duplicate decoration.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-18 17:08:43 +08:00
flyingnobita
2a7c331e0f
docs: update README for Live Preview and embed features
- Remove outdated Live Preview roadmap item (now implemented)
- Document embed/remove star counts commands
- Update feature list

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-18 17:02:08 +08:00
19 changed files with 5401 additions and 2654 deletions

View file

@ -1,3 +0,0 @@
node_modules/
main.js

View file

@ -1,23 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"env": { "node": true },
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"sourceType": "module"
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
"@typescript-eslint/ban-ts-comment": "off",
"no-prototype-builtins": "off",
"@typescript-eslint/no-empty-function": "off"
}
}

13
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,13 @@
## Summary
Briefly describe the overall objective.
## Changes
- Major change 1
- Major change 2
## Verification
- [ ] `pnpm test`
- [ ] `pnpm build`
## Related Issue
Closes #

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

12
AGENTS.md Normal file
View file

@ -0,0 +1,12 @@
# Agent Instructions
## README
- For `README.md`, treat all sections except `Development` as end-user documentation.
- For `README.md`, write non-`Development` sections for readers who should not be assumed to be technical.
- For `README.md`, `Development` may assume a technical reader.
## Releases
- Every release must include the plugin assets `main.js`, `manifest.json`, and `styles.css`.
- Verify the release page also includes GitHub's source archives `Source code (zip)` and `Source code (tar.gz)`.

91
CHANGELOG.md Normal file
View file

@ -0,0 +1,91 @@
# Changelog
- Jul-21, 2026 - 01:45 PM +08 - [Released 1.5.2 with startup warning and source-note targeting fixes]
## [1.5.2] - 2026-07-21
### Fixed
- Prevented the `No active markdown view found` notice during workspace startup
- Scoped Reading View and Live Preview embedded-star updates to the note that triggered the refresh instead of whichever note is active when the debounced update runs
- Mar-27, 2026 - 01:23 AM +08 - [Released 1.5.1 with package tooling alignment and pnpm standardization]
## [1.5.1] - 2026-03-27
### Changed
- Aligned the package manifest and lint toolchain with the current official Obsidian sample plugin
- Standardized the repository on `pnpm`, removed `package-lock.json`, and documented `pnpm` commands in the README
- Pinned CodeMirror packages to Obsidian's declared peer versions for cleaner installs
- Added repo-local `pnpm` build approval for `esbuild`
- 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
### Added
- Unit tests covering cache refresh and embedded star update behavior
- Refresh token warning setting for missing or invalid GitHub tokens
- Repo-local `AGENTS.md` and `dev-docs/SPECS.md`
### Changed
- `Refresh for current note` now bypasses valid cache entries for repositories in the active note
- Refreshes can update existing embedded stars without embedding plain links
- When enabled, embedded star updates can also happen on Reading View and Live Preview refresh paths
- Updated README and internal specs to match current behavior
### Fixed
- Invalid GitHub tokens now fall back to unauthenticated requests for public repositories
- Failed refreshes leave existing embedded star text unchanged and show one notice per refresh attempt
## [1.3.0] - 2026-03-19
### Removed
- Display Format setting — star counts are now always shown as `⭐ {stars}` (e.g. `⭐ 1.2k`)
### Changed
- Updated README with improved structure, badges, and documentation
### Fixed
- Corrected author name in `package.json`
## [1.2.1] - 2025-01-14
### Fixed
- Skip decoration when star count is already embedded in markdown
## [1.2.0]
### Changed
- Changed default cache expiry to 1 day (1440 minutes)
## [1.1.0]
### Added
- Abbreviated number formatting (e.g. 1.2k instead of 1,234)
- Commands to embed and remove star counts from markdown files
## [1.0.1]
### Fixed
- Minor bug fixes
## [1.0.0]
### Added
- Initial release
- Automatic GitHub star count display in Reading View and Live Preview
- Cache support to minimize API requests
- Optional GitHub API token for higher rate limits

View file

@ -1,61 +1,83 @@
# GitHub Stars Plugin
Display the number of stars next to GitHub repository links.
[![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.5.2-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)
_Note: This plugin currently only display the stars in **Reading View**. In the future I hope to support it in Live Preview as well._
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.
## Features
## Features
- Automatically detects GitHub repository URLs in your notes
- Displays the star count next to each GitHub repository link
- Caches star counts to minimize API requests
- Configurable display format for star counts
- Optional GitHub API token support for higher rate limits
- Supports abbreviated number formatting (e.g., 1.2k instead of 1,234)
- Command to refresh star counts for the current note
- Automatically detects GitHub repository URLs in your notes
- Displays the star count next to each GitHub repository link in both **Reading View** and **Live Preview**
- Embed star counts directly into your markdown files so they are **visible outside Obsidian**
- Caches star counts to minimize API requests
- Optional GitHub API token support for higher rate limits
- Supports abbreviated number formatting (e.g., 1.2k instead of 1,234)
- Commands to refresh, embed, and remove star counts
## Examples
## 📸 Examples
When you include a GitHub repository URL in your notes, the plugin will automatically enhance it to show the star count:
![Obsidian GitHub Stars Plugin Screenshot](obsidian-github-stars-screenshot.png)
![Obsidian GitHub Stars Plugin Screenshot](obsidian-github-stars-screenshot.png)
_Star counts displayed inline next to GitHub repository links in Reading View._
## Configuration
## 📦 Installation
1. Open **Obsidian Settings** → **Community plugins**
2. Disable **Safe mode** if prompted
3. Click **Browse** and search for "GitHub Stars"
4. Click **Install**, then **Enable**
## ⚙️ Configuration
The plugin can be configured in the Settings tab:
- **Cache Expiry**: Time in minutes before the GitHub star count cache expires (default: 60 minutes)
- **Display Format**: Format for displaying star counts. Use `{stars}` as a placeholder for the number (default: `⭐ {stars}`)
- **Number Format**: Choose between full numbers (e.g., 1,234) or abbreviated format (e.g., 1.2k)
- **GitHub API Token**: Optional personal access token for GitHub API to increase rate limits
- **Cache Expiry**: Time in minutes before the GitHub star count cache expires (default: 1440 minutes / 1 day)
- **Number Format**: Choose between full numbers (e.g., 1,234) or abbreviated format (e.g., 1.2k)
- **GitHub API Token**: Optional personal access token to increase the API rate limit from 60 to 5,000 requests per hour. To generate one:
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. Create a fine-grained personal access token or a classic token with access suitable for public repository metadata
3. Give it a name and ensure it can access public repository metadata
4. Click "Generate token"
5. Copy the token and paste it in the plugin settings
- **Update embedded stars on refresh**: Enabled by default. When enabled, refreshing updates the star count written in the Markdown file next to a GitHub URL.
- **Show token warnings on refresh**: Enabled by default. When enabled, the plugin warns during manual refresh if the GitHub token is missing or invalid.
## Commands
## 🔄 Refresh And Cache Behavior
- Rendering in Reading View and Live Preview uses cached star counts when the cache entry is still fresh.
- Cache expiry is lazy. When a cache entry expires, the plugin fetches new data the next time that repository is looked up. Expiry does not trigger a background refresh on its own.
- `Refresh for current note` is stronger than normal rendering. It fetches fresh star counts from GitHub for every repository in the active note even if those cache entries are still valid.
- If `Update embedded stars on refresh` is disabled, embedded star text is left unchanged and may show an older value than the refreshed star count.
## 💻 Commands
The plugin adds the following commands:
- **Refresh GitHub Stars for Current Note**: Refreshes all GitHub star counts in the current note
- **Clear GitHub Stars Cache**: Clears the cached star counts
- **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.
## GitHub API Rate Limits
## 🧪 Development
The GitHub API has rate limits for unauthenticated requests (60 requests per hour). If you use GitHub extensively in your notes, you might want to add a GitHub personal access token in the plugin settings to increase this limit (5,000 requests per hour for authenticated requests).
- `pnpm test`: Runs the unit tests with Vitest
- `pnpm build`: Type-checks and builds the plugin bundle
To create a GitHub personal access token:
## ❤️ Support This Project
1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens)
2. Click "Generate new token"
3. Give it a name and select the "public_repo" scope
4. Click "Generate token"
5. Copy the token and paste it in the plugin settings
You can support this project in a few simple ways:
## Roadmap
- ⭐ [Star the repo](https://github.com/flyingnobita/obsidian-github-stars)
- 🐛 [Report bugs](https://github.com/flyingnobita/obsidian-github-stars/issues)
- 💡 [Suggest features](https://github.com/flyingnobita/obsidian-github-stars/issues)
- 📝 [Contribute code](https://github.com/flyingnobita/obsidian-github-stars/pulls)
- [ ] Support for Live Preview mode
## 📄 License
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the MIT License - see the LICENSE file for details.
[MIT](LICENSE) © Flying Nobita

64
dev-docs/SPECS.md Normal file
View file

@ -0,0 +1,64 @@
# Specs
## Commands
- `Clear cache` removes all stored cached star counts.
- `Refresh for current note` fetches fresh GitHub star counts for repositories in the active note, updates the cache, and may also update already-embedded star text depending on the refresh setting.
- `Embed star counts in current note` writes star counts into the active note as embedded `⭐ ...` text.
- `Remove embedded star counts from current note` removes embedded `⭐ ...` text from the active note.
## User Settings
- `Cache expiry` controls how long cached star counts remain fresh.
- `Number format` controls whether star counts are displayed as full numbers or abbreviated values.
- `GitHub API token (optional)` allows authenticated GitHub API requests for higher rate limits.
- `Update embedded stars on refresh` controls whether refreshes update embedded `⭐ ...` text in the active note.
- `Show token warnings on refresh` controls whether manual refresh warns when the GitHub token is missing or invalid.
## Refresh Behavior
- `Refresh for current note` only applies to the active note.
- `Refresh for current note` fetches fresh star counts from GitHub for repositories in the active note even if their cache entries are still valid.
- When `Update embedded stars on refresh` is enabled, refresh updates only links that already have embedded `⭐ ...` text in the active note and then rerenders the note.
- When `Update embedded stars on refresh` is enabled, Reading View and Live Preview refresh paths update existing embedded `⭐ ...` text in the source note being rendered, even if another view becomes active before the update finishes.
- Background rendering does not require an active Markdown view and does not show an active-view warning during workspace startup.
- If the same repository appears multiple times in the active note, refresh fetches it once and applies the result to all matching occurrences.
- When `Update embedded stars on refresh` is disabled, refresh rerenders the note without changing embedded `⭐ ...` text.
- If a refresh fetch fails for a repository, existing embedded star text for that repository is left unchanged.
- If one or more repository refreshes fail during a manual refresh, the plugin shows a single failure notice for that refresh attempt.
## Rendering Modes
- Reading View injects star counts into rendered HTML after GitHub repository links.
- Live Preview injects star counts as CodeMirror widgets after visible GitHub repository links.
- Embedded star text in the markdown file is distinct from transient rendered star counts.
## Embedded Star Rules
- The plugin detects existing embedded `⭐ ...` text and skips adding duplicate stars during normal rendering.
- Refresh never embeds stars for plain links. It only updates embedded `⭐ ...` text that already exists when the refresh setting is enabled.
- `Embed star counts in current note` writes or updates embedded `⭐ ...` text directly in the markdown file.
- `Remove embedded star counts from current note` removes embedded `⭐ ...` text while leaving the GitHub links intact.
## Authentication Behavior
- If no GitHub API token is configured, requests use the lower unauthenticated GitHub API rate limit.
- During a manual refresh of a note, token warnings are shown at most once even if the refresh processes multiple GitHub links.
- If GitHub rejects the configured token, the plugin logs a warning, shows a notice, and retries public repositories without authentication.
## Cache Behavior
- GitHub star counts are cached per repository using `owner/repo` as the cache key.
- Each cache entry stores the fetched star count and a timestamp.
- Cache expiry is lazy, not proactive. When an entry expires, the plugin does not schedule or run a background refresh automatically.
- A fresh API request only happens the next time the plugin tries to resolve stars for that repository and sees that the cached entry is missing or expired.
- Star lookups can be triggered by rendering a note in Reading View, rendering visible GitHub links in Live Preview, running `Refresh for current note`, or running `Embed star counts in current note`.
- If a cached entry is still within the configured expiry window, the cached value is used and no network request is made.
- If a request fails and a stale cache entry exists for the same repository, the stale cached value may still be returned.
- Clearing the cache removes stored cache entries but does not remove star text that is already rendered in the current UI.
## Supported URLs
- The plugin only processes GitHub repository URLs.
- Repository parsing extracts `owner/repo` from standard GitHub URLs and strips a trailing `.git` suffix from the repository name.
- Non-repository GitHub URLs and malformed links are ignored.

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "node:module";
const banner =
`/*
@ -31,7 +31,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
...builtinModules],
format: "cjs",
target: "es2018",
logLevel: "info",

45
eslint.config.mts Normal file
View file

@ -0,0 +1,45 @@
import tseslint from 'typescript-eslint';
import obsidianmd from 'eslint-plugin-obsidianmd';
import globals from 'globals';
import { globalIgnores } from 'eslint/config';
export default tseslint.config(
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
projectService: {
allowDefaultProject: [
'eslint.config.mts',
'manifest.json',
],
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json'],
},
},
},
...obsidianmd.configs.recommended,
{
files: ['main.ts'],
rules: {
'@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'obsidianmd/commands/no-plugin-id-in-command-id': 'off',
'obsidianmd/ui/sentence-case': 'off',
},
},
globalIgnores([
'node_modules',
'dist',
'esbuild.config.mjs',
'eslint.config.mjs',
'version-bump.mjs',
'versions.json',
'main.js',
]),
);

198
githubStarsCore.test.ts Normal file
View file

@ -0,0 +1,198 @@
import { describe, expect, it } from 'vitest';
import {
extractReposFromContent,
extractUniqueReposFromContents,
findEmbeddedGitHubLinkMatches,
findGitHubLinkMatches,
queueRepoUpdatesForSource,
removeEmbeddedGitHubStars,
rewriteGitHubLinksWithStars,
shouldUseCachedEntry,
} from './githubStarsCore';
describe('queueRepoUpdatesForSource', () => {
it('keeps pending repository updates scoped to their source files', () => {
const pendingUpdates = new Map<string, Set<string>>();
queueRepoUpdatesForSource(pendingUpdates, 'Notes/First.md', [
{ owner: 'foo', repo: 'shared' },
{ owner: 'foo', repo: 'first-only' },
]);
queueRepoUpdatesForSource(pendingUpdates, 'Notes/Second.md', [
{ owner: 'foo', repo: 'shared' },
]);
queueRepoUpdatesForSource(pendingUpdates, 'Notes/First.md', [
{ owner: 'foo', repo: 'shared' },
]);
expect(Array.from(pendingUpdates.get('Notes/First.md') ?? [])).toEqual([
'foo/shared',
'foo/first-only',
]);
expect(Array.from(pendingUpdates.get('Notes/Second.md') ?? [])).toEqual([
'foo/shared',
]);
});
});
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('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 = `
[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');
});
});
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,
});
});
});

145
githubStarsCore.ts Normal file
View file

@ -0,0 +1,145 @@
export interface CacheEntry {
stars: number;
timestamp: number;
}
export interface RepoRef {
owner: string;
repo: string;
}
export function queueRepoUpdatesForSource(
pendingUpdates: Map<string, Set<string>>,
sourcePath: string,
repos: RepoRef[]
): void {
let pendingRepoKeys = pendingUpdates.get(sourcePath);
if (!pendingRepoKeys) {
pendingRepoKeys = new Set<string>();
pendingUpdates.set(sourcePath, pendingRepoKeys);
}
for (const { owner, repo } of repos) {
pendingRepoKeys.add(`${owner}/${repo}`);
}
}
export interface GitHubLinkMatch extends RepoRef {
index: number;
length: number;
linkText: string;
}
export interface RemovalResult {
content: string;
removedCount: number;
}
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 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);
}
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 };
}
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;
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;
}

697
main.ts
View file

@ -1,20 +1,23 @@
import { App, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, MarkdownPostProcessorContext, requestUrl } from 'obsidian';
import { App, editorInfoField, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, MarkdownPostProcessorContext, requestUrl, TFile } from 'obsidian';
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, WidgetType } from '@codemirror/view';
import { RangeSetBuilder } from '@codemirror/state';
import { extractReposFromContent, extractUniqueReposFromContents, findEmbeddedGitHubLinkMatches, findGitHubLinkMatches, GitHubLinkMatch, queueRepoUpdatesForSource, removeEmbeddedGitHubStars, RepoRef, rewriteGitHubLinksWithStars, shouldUseCachedEntry } from './githubStarsCore';
interface GitHubStarsSettings {
cacheExpiry: number; // Time in minutes before cache expires
displayFormat: string; // Format for displaying stars (e.g., "⭐ {stars}")
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: 60, // Default cache expiry: 60 minutes
displayFormat: "⭐ {stars}",
cacheExpiry: 1440, // Default cache expiry: 1440 minutes (1 day)
apiToken: "",
numberFormat: 'abbreviated'
numberFormat: 'abbreviated',
updateEmbeddedStarsOnRefresh: true,
showTokenWarnings: true
}
// Interface for cache entries
@ -23,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
@ -30,12 +39,14 @@ interface CacheEntry {
*/
function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
class StarsWidget extends WidgetType {
constructor(private owner: string, private repo: string) {
constructor(private owner: string, private repo: string, private sourcePath: string | null) {
super();
}
eq(other: StarsWidget): boolean {
return this.owner === other.owner && this.repo === other.repo;
return this.owner === other.owner
&& this.repo === other.repo
&& this.sourcePath === other.sourcePath;
}
toDOM(): HTMLElement {
@ -61,6 +72,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 }], this.sourcePath);
} else {
span.setText('⭐ ?');
span.addClass('github-stars-error');
@ -92,6 +104,7 @@ function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const sourcePath = view.state.field(editorInfoField, false)?.file?.path ?? null;
const githubUrlRegex = /https?:\/\/(www\.)?github\.com\/([^/\s)#?]+)\/([^/\s)#?]+)(\/[^\s)]*)?\/?(#[^\s)]*)?/g;
for (const { from, to } of view.visibleRanges) {
@ -117,11 +130,17 @@ function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
}
}
// Skip if a star count is already embedded in the text
const textAfter = view.state.doc.sliceString(insertPos, Math.min(insertPos + 20, view.state.doc.length));
if (/^ ⭐ [\d,.]+[kMB]?/.test(textAfter)) {
continue;
}
builder.add(
insertPos,
insertPos,
Decoration.widget({
widget: new StarsWidget(owner, repo),
widget: new StarsWidget(owner, repo, sourcePath),
side: 1,
})
);
@ -140,6 +159,11 @@ function buildGitHubStarsViewPlugin(plugin: GitHubStarsPlugin) {
export default class GitHubStarsPlugin extends Plugin {
settings: GitHubStarsSettings;
cache: Record<string, CacheEntry> = {};
activeRefreshRunId = 0;
lastMissingTokenWarningRefreshId = 0;
lastInvalidTokenWarningRefreshId = 0;
pendingEmbeddedUpdates = new Map<string, Set<string>>();
pendingEmbeddedUpdateTimer: ReturnType<typeof setTimeout> | null = null;
async onload() {
// Load settings
@ -150,32 +174,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));
@ -204,9 +207,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;
}
@ -214,6 +215,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',
@ -230,6 +240,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',
@ -245,9 +264,24 @@ 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() {
if (this.pendingEmbeddedUpdateTimer !== null) {
clearTimeout(this.pendingEmbeddedUpdateTimer);
this.pendingEmbeddedUpdateTimer = null;
}
this.pendingEmbeddedUpdates.clear();
// Save settings when plugin is unloaded
this.saveSettings();
}
@ -255,6 +289,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;
@ -270,6 +308,102 @@ 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 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}`;
}
private shouldShowTokenWarningDuringRefresh(): boolean {
return this.settings.showTokenWarnings && this.activeRefreshRunId > 0;
}
scheduleEmbeddedStarUpdate(repos: RepoRef[], sourcePath: string | null): void {
if (!this.settings.updateEmbeddedStarsOnRefresh || !sourcePath || repos.length === 0) {
return;
}
queueRepoUpdatesForSource(this.pendingEmbeddedUpdates, sourcePath, repos);
if (this.pendingEmbeddedUpdateTimer !== null) {
return;
}
this.pendingEmbeddedUpdateTimer = setTimeout(() => {
void this.flushScheduledEmbeddedStarUpdates();
}, 50);
}
private async flushScheduledEmbeddedStarUpdates(): Promise<void> {
const pendingUpdates = new Map<string, Set<string>>();
for (const [sourcePath, repoKeys] of this.pendingEmbeddedUpdates) {
pendingUpdates.set(sourcePath, new Set(repoKeys));
}
this.pendingEmbeddedUpdates.clear();
if (this.pendingEmbeddedUpdateTimer !== null) {
clearTimeout(this.pendingEmbeddedUpdateTimer);
this.pendingEmbeddedUpdateTimer = null;
}
for (const [sourcePath, pendingKeys] of pendingUpdates) {
const file = this.app.vault.getFileByPath(sourcePath);
if (!file) {
continue;
}
await this.rewriteNoteStars(file, {
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
*/
@ -288,6 +422,13 @@ export default class GitHubStarsPlugin extends Plugin {
// Check if the link is a GitHub repository URL
const repoInfo = this.extractRepoInfo(url);
if (repoInfo) {
// Skip if a star count is already embedded in the text after the link
const nextSibling = link.nextSibling;
if (nextSibling && nextSibling.nodeType === Node.TEXT_NODE
&& nextSibling.textContent && /^\s*⭐ [\d,.]+[kMB]?/.test(nextSibling.textContent)) {
continue;
}
// Create a span to hold the star count with loading indicator
const starSpan = document.createElement('span');
starSpan.addClass('github-stars-count');
@ -305,6 +446,7 @@ export default class GitHubStarsPlugin extends Plugin {
if (stars !== null) {
const formattedStars = this.formatStarCount(stars);
starSpan.setText(formattedStars);
this.scheduleEmbeddedStarUpdate([repoInfo], ctx.sourcePath);
} else {
starSpan.setText('⭐ ?');
starSpan.addClass('github-stars-error');
@ -364,42 +506,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'];
@ -409,60 +555,185 @@ 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);
return this.getFailedFetchResult(cacheKey);
}
}
// If we have a cached value, use it even if expired
if (this.cache[cacheKey]) {
return this.cache[cacheKey].stars;
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 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;
}
return null;
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,
noLinksNotice: null,
progressNotice: null,
successNotice: null,
fetchLatest: false,
failureNotice: null,
shouldRewriteMatch: (match) => refreshResults.get(this.getRepoCacheKey(match.owner, match.repo))?.refreshedFromGitHub === true,
});
}
/**
@ -470,97 +741,180 @@ export default class GitHubStarsPlugin extends Plugin {
* 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;
let content = await this.app.vault.read(file);
const file = this.requireActiveFile(activeView);
if (!file) {
return;
}
// 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');
const content = await this.app.vault.read(file);
const result = removeEmbeddedGitHubStars(content);
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;
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;
}
return this.rewriteNoteStars(file, options);
}
private async rewriteNoteStars(file: TFile, 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 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
*/
formatStarCount(stars: number): string {
const formatted = this.settings.displayFormat.replace('{stars}', this.formatNumber(stars));
return formatted;
return `${this.formatNumber(stars)}`;
}
/**
@ -619,21 +973,6 @@ class GitHubStarsSettingTab extends PluginSettingTab {
}
}));
new Setting(containerEl)
.setName('Display format')
.setDesc('Format for displaying star counts. Use {stars} as a placeholder for the number.')
.addText(text => text
.setPlaceholder('⭐ {stars}')
.setValue(this.plugin.settings.displayFormat)
.onChange(async (value) => {
if (value.includes('{stars}')) {
this.plugin.settings.displayFormat = value;
await this.plugin.saveSettings();
} else {
new Notice('Display format must include {stars} placeholder');
}
}));
new Setting(containerEl)
.setName('Number format')
.setDesc('How to format star counts')
@ -657,6 +996,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')

View file

@ -1,10 +1,10 @@
{
"id": "github-stars",
"name": "GitHub Stars",
"version": "1.2.0",
"version": "1.5.2",
"minAppVersion": "1.8.9",
"description": "Displays the number of stars for GitHub repositories mentioned in notes.",
"author": "Flying Nobita",
"authorUrl": "https://github.com/flyingnobita",
"isDesktopOnly": false
}
}

2406
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,15 @@
{
"name": "obsidian-github-stars",
"version": "1.2.0",
"version": "1.5.2",
"description": "Obsidian plugin that displays the number of stars for GitHub repositories mentioned in notes",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
"test": "vitest run",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": [
"obsidian",
@ -14,16 +17,24 @@
"github",
"stars"
],
"author": "Your Name",
"author": "Flying Nobita",
"license": "MIT",
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"@eslint/js": "9.30.1",
"@codemirror/state": "6.5.0",
"@codemirror/view": "6.38.6",
"@types/node": "^20.19.37",
"esbuild": "0.25.5",
"eslint": "9.30.1",
"eslint-plugin-obsidianmd": "0.1.9",
"globals": "14.0.0",
"jiti": "2.6.1",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "5.8.3",
"typescript-eslint": "8.35.1",
"vitest": "^1.6.1"
},
"dependencies": {
"obsidian": "latest"
}
}

4174
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View file

@ -2,5 +2,11 @@
"1.0.0": "0.15.0",
"1.0.1": "1.8.9",
"1.1.0": "1.8.9",
"1.2.0": "1.8.9"
}
"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.5.0": "1.8.9",
"1.5.1": "1.8.9",
"1.5.2": "1.8.9"
}