mirror of
https://github.com/flyingnobita/obsidian-github-stars.git
synced 2026-07-22 12:20:26 +00:00
Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a043454f | ||
|
|
bed0a1abd3 | ||
|
|
b4b9d71569 | ||
|
|
b0d434cc5c | ||
|
|
4bbe427722 | ||
|
|
731e300b00 | ||
|
|
082be15e07 | ||
|
|
1a8924e40f | ||
|
|
0faf143556 | ||
|
|
f77f6c2b4a | ||
|
|
5a3db79c30 | ||
|
|
b97207dd9b | ||
|
|
53707d6e68 | ||
|
|
dc81b2b893 | ||
|
|
a13c35f725 | ||
|
|
2a7c331e0f |
19 changed files with 5401 additions and 2654 deletions
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
|
||||
main.js
|
||||
23
.eslintrc
23
.eslintrc
|
|
@ -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
13
.github/pull_request_template.md
vendored
Normal 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
37
.github/workflows/ci.yml
vendored
Normal 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
12
AGENTS.md
Normal 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
91
CHANGELOG.md
Normal 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
|
||||
96
README.md
96
README.md
|
|
@ -1,61 +1,83 @@
|
|||
# GitHub Stars Plugin
|
||||
|
||||
Display the number of stars next to GitHub repository links.
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](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:
|
||||
|
||||

|
||||

|
||||
_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
64
dev-docs/SPECS.md
Normal 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.
|
||||
|
|
@ -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
45
eslint.config.mts
Normal 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
198
githubStarsCore.test.ts
Normal 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
145
githubStarsCore.ts
Normal 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
697
main.ts
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
2406
package-lock.json
generated
File diff suppressed because it is too large
Load diff
31
package.json
31
package.json
|
|
@ -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
4174
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue