Initial commit

This commit is contained in:
flyingnobita 2025-03-15 01:35:24 +08:00
commit 3e33be66e7
18 changed files with 3155 additions and 0 deletions

10
.editorconfig Normal file
View file

@ -0,0 +1,10 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

3
.eslintignore Normal file
View file

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

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"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"
}
}

32
.gitignore vendored Normal file
View file

@ -0,0 +1,32 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

0
.hotreload Normal file
View file

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Flying Nobita
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
README.md Normal file
View file

@ -0,0 +1,61 @@
# Obsidian GitHub Stars Plugin
Display the number of stars next to GitHub repository links.
_Note: This plugin currently only display the stars in **Reading View**. In the future I hope to support it in Live Preview as well._
## 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
## 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)
## 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
## 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
## GitHub API Rate Limits
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).
To create a GitHub personal access token:
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
## Roadmap
- [ ] Support for Live Preview mode
## 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.

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = (process.argv[2] === "production");
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

430
main.ts Normal file
View file

@ -0,0 +1,430 @@
import { App, MarkdownView, Notice, Plugin, PluginSettingTab, Setting, MarkdownPostProcessorContext } from 'obsidian';
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
}
const DEFAULT_SETTINGS: GitHubStarsSettings = {
cacheExpiry: 60, // Default cache expiry: 60 minutes
displayFormat: "⭐ {stars}",
apiToken: "",
numberFormat: 'abbreviated'
}
// Interface for cache entries
interface CacheEntry {
stars: number;
timestamp: number;
}
export default class GitHubStarsPlugin extends Plugin {
settings: GitHubStarsSettings;
cache: Record<string, CacheEntry> = {};
async onload() {
// Load settings
await this.loadSettings();
// This creates an icon in the left ribbon.
const ribbonIconEl = this.addRibbonIcon('star', 'Github Stars', (evt: MouseEvent) => {
// 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');
}
});
// 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
this.registerMarkdownPostProcessor(this.processMarkdown.bind(this));
// Add settings tab
this.addSettingTab(new GitHubStarsSettingTab(this.app, this));
// Add a command to clear the cache
this.addCommand({
id: 'clear-github-stars-cache',
name: 'Clear GitHub Stars Cache',
callback: () => {
this.cache = {};
this.saveSettings();
new Notice('GitHub Stars cache cleared');
}
});
// Add a command to refresh star counts for the current note
this.addCommand({
id: 'refresh-github-stars',
name: 'Refresh GitHub Stars for Current Note',
checkCallback: (checking: boolean) => {
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...');
}
return true;
}
return false;
}
});
}
onunload() {
// Save settings when plugin is unloaded
this.saveSettings();
}
async loadSettings() {
const data = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, data);
// Store cache separately if it exists in the loaded data
if (data && data.cache) {
this.cache = data.cache;
}
}
async saveSettings() {
// Save both settings and cache in the same data object
const dataToSave = {
...this.settings,
cache: this.cache
};
await this.saveData(dataToSave);
}
/**
* Process markdown content to find and enhance GitHub links
*/
async processMarkdown(el: HTMLElement, ctx: MarkdownPostProcessorContext) {
// Find all links in the document
const links = el.querySelectorAll('a');
for (let i = 0; i < links.length; i++) {
const link = links[i];
const url = link.getAttribute('href');
if (!url) {
continue;
}
// Check if the link is a GitHub repository URL
const repoInfo = this.extractRepoInfo(url);
if (repoInfo) {
// Create a span to hold the star count with loading indicator
const starSpan = document.createElement('span');
starSpan.addClass('github-stars-count');
starSpan.addClass('github-stars-loading');
starSpan.setText('⭐ ...');
// Insert the star count after the link
link.after(starSpan);
// Get star count and update the span using async/await
try {
const stars = await this.getStarCount(repoInfo.owner, repoInfo.repo);
starSpan.removeClass('github-stars-loading');
if (stars !== null) {
const formattedStars = this.formatStarCount(stars);
starSpan.setText(formattedStars);
} else {
starSpan.setText('⭐ ?');
starSpan.addClass('github-stars-error');
}
} catch (error) {
console.error(`Error getting star count for ${repoInfo.owner}/${repoInfo.repo}:`, error);
starSpan.removeClass('github-stars-loading');
starSpan.setText('⭐ ?');
starSpan.addClass('github-stars-error');
}
}
}
}
/**
* Extract repository owner and name from a GitHub URL
* Returns null if the URL is not a valid GitHub repository URL
*/
extractRepoInfo(url: string): { owner: string, repo: string } | null {
// Match GitHub repository URLs
// Examples:
// - https://github.com/owner/repo
// - https://github.com/owner/repo/
// - https://github.com/owner/repo/tree/master
// - https://github.com/owner/repo/blob/master/README.md
// - https://www.github.com/owner/repo
// - http://github.com/owner/repo
// First, normalize the URL
const normalizedUrl = url.trim().toLowerCase();
// Check if it's a GitHub URL
if (!normalizedUrl.includes('github.com')) {
return null;
}
// Extract owner and repo using regex
const githubRegex = /https?:\/\/(www\.)?github\.com\/([^/\s]+)\/([^/\s#?]+)(\/.*)?$/;
const match = url.match(githubRegex);
if (match && match[2] && match[3]) {
const owner = match[2];
let repo = match[3];
// Remove .git suffix if present
if (repo.endsWith('.git')) {
repo = repo.slice(0, -4);
}
return { owner, repo };
}
return null;
}
/**
* 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}`;
// 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;
}
}
try {
// Fetch star count from GitHub API
const headers: Record<string, string> = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Obsidian-GitHub-Stars-Plugin'
};
// Add API token if available
if (this.settings.apiToken && this.settings.apiToken.trim() !== '') {
headers['Authorization'] = `token ${this.settings.apiToken.trim()}`;
}
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
const response = await fetch(apiUrl, {
headers,
method: 'GET'
});
// Handle rate limiting
const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining');
const rateLimitReset = response.headers.get('X-RateLimit-Reset');
if (rateLimitRemaining === '0' && rateLimitReset) {
const resetTime = new Date(parseInt(rateLimitReset) * 1000);
const now = new Date();
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;
}
if (response.status === 404) {
console.error(`Repository ${owner}/${repo} not found`);
return null;
}
if (!response.ok) {
console.error(`Failed to fetch star count for ${owner}/${repo}: ${response.status} ${response.statusText}`);
// If we have a cached value, use it even if expired
if (this.cache[cacheKey]) {
return this.cache[cacheKey].stars;
}
return null;
}
const data = await response.json();
if (!data || typeof data.stargazers_count !== 'number') {
console.error(`Invalid response data for ${owner}/${repo}`);
return null;
}
const stars = data.stargazers_count;
// Update cache
this.cache[cacheKey] = {
stars,
timestamp: Date.now()
};
// Save cache to disk
this.saveSettings();
return stars;
} catch (error) {
console.error(`Error fetching star count for ${owner}/${repo}:`, error);
// If we have a cached value, use it even if expired
if (this.cache[cacheKey]) {
return this.cache[cacheKey].stars;
}
return null;
}
}
/**
* Format star count according to settings
*/
formatStarCount(stars: number): string {
const formatted = this.settings.displayFormat.replace('{stars}', this.formatNumber(stars));
return formatted;
}
/**
* Format number with thousands separators or abbreviate for large numbers
*/
formatNumber(num: number): string {
// Use full format if configured
if (this.settings.numberFormat === 'full') {
return num.toLocaleString();
}
// Otherwise use abbreviated format
// For numbers less than 1000, just use locale string
if (num < 1000) {
return num.toLocaleString();
}
// For numbers 1000 or greater, use abbreviations
if (num < 10000) {
// 1.2k format for 1000-9999
return (num / 1000).toFixed(1) + 'k';
} else if (num < 1000000) {
// 12k format for 10000-999999
return Math.round(num / 1000) + 'k';
} else {
// 1.2M format for 1000000+
return (num / 1000000).toFixed(1) + 'M';
}
}
}
class GitHubStarsSettingTab extends PluginSettingTab {
plugin: GitHubStarsPlugin;
constructor(app: App, plugin: GitHubStarsPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName('Cache expiry')
.setDesc('Time in minutes before the GitHub star count cache expires')
.addText(text => text
.setPlaceholder('60')
.setValue(this.plugin.settings.cacheExpiry.toString())
.onChange(async (value) => {
const numValue = parseInt(value);
if (!isNaN(numValue) && numValue > 0) {
this.plugin.settings.cacheExpiry = numValue;
await this.plugin.saveSettings();
}
}));
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')
.addDropdown(dropdown => dropdown
.addOption('full', 'Full numbers (e.g., 1,234)')
.addOption('abbreviated', 'Abbreviated (e.g., 1.2k)')
.setValue(this.plugin.settings.numberFormat)
.onChange(async (value: 'full' | 'abbreviated') => {
this.plugin.settings.numberFormat = value;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('GitHub API token (optional)')
.setDesc('Personal access token for GitHub API to increase rate limits')
.addText(text => text
.setPlaceholder('ghp_xxxxxxxxxxxxxxxxxxxx')
.setValue(this.plugin.settings.apiToken)
.onChange(async (value) => {
this.plugin.settings.apiToken = value;
await this.plugin.saveSettings();
}));
// Add a button to clear the cache
new Setting(containerEl)
.setName('Clear cache')
.setDesc('Clear the GitHub stars cache')
.addButton(button => button
.setButtonText('Clear')
.onClick(async () => {
this.plugin.cache = {};
await this.plugin.saveSettings();
new Notice('GitHub Stars cache cleared');
}));
}
}

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "obsidian-github-stars",
"name": "GitHub Stars",
"version": "1.0.0",
"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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

2397
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "obsidian-github-stars",
"version": "1.0.0",
"description": "Obsidian plugin that displays the number of stars for GitHub repositories mentioned in notes",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
"obsidian",
"plugin",
"github",
"stars"
],
"author": "Your Name",
"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",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}

48
styles.css Normal file
View file

@ -0,0 +1,48 @@
/*
This CSS file will be included with your plugin, and
available in the app when your plugin is enabled.
If your plugin does not need CSS, delete this file.
*/
/* GitHub Stars Plugin Styles */
/* Style for the stars count span */
.github-stars-count {
display: inline-block;
margin-left: 5px;
padding: 2px 6px;
border-radius: 10px;
font-size: 0.85em;
background-color: var(--background-secondary);
color: var(--text-normal);
}
/* Loading state */
.github-stars-loading {
animation: github-stars-pulse 1.5s infinite;
}
@keyframes github-stars-pulse {
0% {
opacity: 0.6;
}
50% {
opacity: 1;
}
100% {
opacity: 0.6;
}
}
/* Error state */
.github-stars-error {
color: var(--text-error);
}
/* Hover effect */
a:hover + .github-stars-count {
background-color: var(--background-modifier-hover);
}

24
tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": [
"DOM",
"ES5",
"ES6",
"ES7"
]
},
"include": [
"**/*.ts"
]
}

14
version-bump.mjs Normal file
View file

@ -0,0 +1,14 @@
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
let manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t"));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, "\t"));

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "0.15.0"
}