diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml
new file mode 100644
index 000000000..58ce30224
--- /dev/null
+++ b/.github/workflows/star-history.yml
@@ -0,0 +1,64 @@
+name: Star History
+
+on:
+ schedule:
+ - cron: "17 2 * * *"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+concurrency:
+ group: star-history
+ cancel-in-progress: true
+
+jobs:
+ update:
+ if: github.repository == 'Devin-AXIS/iPolloWork'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out updater
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ path: source
+ persist-credentials: false
+
+ - name: Check out chart data
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: star-history
+ path: chart
+
+ - name: Read current star count
+ id: repository
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ response="$(curl --fail --silent --show-error \
+ --header "Accept: application/vnd.github+json" \
+ --header "Authorization: Bearer ${GH_TOKEN}" \
+ --header "X-GitHub-Api-Version: 2022-11-28" \
+ "https://api.github.com/repos/${GITHUB_REPOSITORY}")"
+ count="$(jq --exit-status '.stargazers_count | select(type == "number" and . >= 0)' <<<"${response}")"
+ echo "count=${count}" >> "${GITHUB_OUTPUT}"
+
+ - name: Render chart
+ run: |
+ node source/scripts/update-star-history.mjs \
+ --repository "${GITHUB_REPOSITORY}" \
+ --count "${{ steps.repository.outputs.count }}" \
+ --data chart/docs/star-history/data.json \
+ --output-dir chart/docs/star-history
+
+ - name: Publish changed chart
+ working-directory: chart
+ run: |
+ if git diff --quiet -- docs/star-history; then
+ echo "Star history is already current."
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add docs/star-history/data.json docs/star-history/star-history-light.svg docs/star-history/star-history-dark.svg
+ git commit -m "chore: update star history [skip ci]"
+ git push origin HEAD:star-history
diff --git a/README.md b/README.md
index 2b2db44df..672b4ea93 100644
--- a/README.md
+++ b/README.md
@@ -212,6 +212,18 @@ git diff --check
See `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, and `SECURITY.md` for contribution, community, and security policies.
+## Star History
+
+
+
+
+
+
+
+
+
+
+
## License
iPolloWork uses the **iPolloWork Source Available License 1.0**:
diff --git a/docs/translations/README_JA.md b/docs/translations/README_JA.md
index 07f022841..baa04a7fd 100644
--- a/docs/translations/README_JA.md
+++ b/docs/translations/README_JA.md
@@ -199,6 +199,18 @@ git diff --check
コントリビューション、コミュニティ、セキュリティに関する方針は、`CONTRIBUTING.md`、`CODE_OF_CONDUCT.md`、`SECURITY.md` を参照してください。
+## Star の推移
+
+
+
+
+
+
+
+
+
+
+
## ライセンス
iPolloWork は **iPolloWork Source Available License 1.0** を使用します。
diff --git a/docs/translations/README_ZH.md b/docs/translations/README_ZH.md
index ee41dd95b..2c4dc9b16 100644
--- a/docs/translations/README_ZH.md
+++ b/docs/translations/README_ZH.md
@@ -172,6 +172,18 @@ iPolloWork 桌面/UI ──> iPolloWork Server ──> OpenCode
- 不连接 Cloud 时,iPolloWork 仍可完整本地运行。
- iPolloWork 不修改 OpenCode,OpenCode 可以继续独立升级。
+## Star 增长趋势
+
+
+
+
+
+
+
+
+
+
+
## 使用许可
- 仅个人自己使用免费;少于 3 名总用户的小规模内部使用免费。
diff --git a/docs/translations/README_ZH_hk.md b/docs/translations/README_ZH_hk.md
index 38aef2233..10a3b10a7 100644
--- a/docs/translations/README_ZH_hk.md
+++ b/docs/translations/README_ZH_hk.md
@@ -174,6 +174,18 @@ iPolloWork 桌面/UI ──> iPolloWork Server ──> OpenCode
- 不連接 Cloud 時,iPolloWork 仍可完整本地運行。
- iPolloWork 不修改 OpenCode,OpenCode 可以繼續獨立升級。
+## Star 增長趨勢
+
+
+
+
+
+
+
+
+
+
+
## 使用許可
- 僅個人自己使用免費;少於 3 名總用户的小規模內部使用免費。
diff --git a/scripts/update-star-history.mjs b/scripts/update-star-history.mjs
new file mode 100644
index 000000000..2d39c9e31
--- /dev/null
+++ b/scripts/update-star-history.mjs
@@ -0,0 +1,186 @@
+#!/usr/bin/env node
+
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+const args = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ const key = process.argv[index];
+ const value = process.argv[index + 1];
+ if (!key?.startsWith("--") || value === undefined) {
+ throw new Error(`Invalid argument near ${key ?? "end of command"}`);
+ }
+ args.set(key.slice(2), value);
+}
+
+const repository = args.get("repository");
+const count = Number(args.get("count"));
+const dataPath = args.get("data");
+const outputDir = args.get("output-dir");
+const updatedAt = args.get("updated-at") ?? new Date().toISOString();
+
+if (!repository?.match(/^[^/]+\/[^/]+$/)) {
+ throw new Error("--repository must use owner/name format");
+}
+if (!Number.isSafeInteger(count) || count < 0) {
+ throw new Error("--count must be a non-negative integer");
+}
+if (!dataPath || !outputDir || Number.isNaN(Date.parse(updatedAt))) {
+ throw new Error("--data, --output-dir, and a valid --updated-at are required");
+}
+
+const data = JSON.parse(await readFile(dataPath, "utf8"));
+if (data.repository !== repository || !Array.isArray(data.points)) {
+ throw new Error("Star history data does not match the requested repository");
+}
+
+const date = updatedAt.slice(0, 10);
+const lastPoint = data.points.at(-1);
+if (lastPoint && date < lastPoint.date) {
+ throw new Error(`Refusing to insert ${date} before existing point ${lastPoint.date}`);
+}
+if (lastPoint?.date === date && lastPoint.count !== count) {
+ lastPoint.count = count;
+ data.updatedAt = updatedAt;
+} else if (lastPoint?.date !== date) {
+ data.points.push({ date, count });
+ data.updatedAt = updatedAt;
+}
+
+await mkdir(outputDir, { recursive: true });
+await writeFile(dataPath, `${JSON.stringify(data, null, 2)}\n`);
+await Promise.all([
+ writeFile(path.join(outputDir, "star-history-light.svg"), renderChart(data, "light")),
+ writeFile(path.join(outputDir, "star-history-dark.svg"), renderChart(data, "dark")),
+]);
+
+function renderChart(history, themeName) {
+ const theme =
+ themeName === "dark"
+ ? {
+ background: "#0d1117",
+ grid: "#30363d",
+ muted: "#8b949e",
+ text: "#f0f6fc",
+ line: "#ff7b54",
+ fill: "#ff7b54",
+ }
+ : {
+ background: "#ffffff",
+ grid: "#d8dee4",
+ muted: "#57606a",
+ text: "#24292f",
+ line: "#f05a37",
+ fill: "#f05a37",
+ };
+ const width = 900;
+ const height = 500;
+ const margin = { top: 88, right: 40, bottom: 64, left: 76 };
+ const plotWidth = width - margin.left - margin.right;
+ const plotHeight = height - margin.top - margin.bottom;
+ const parsed = history.points.map((point) => ({
+ count: point.count,
+ date: point.date,
+ time: Date.parse(`${point.date}T00:00:00Z`),
+ }));
+ if (parsed.length === 0) {
+ throw new Error("Star history needs at least one point");
+ }
+
+ const minTime = parsed[0].time;
+ const maxTime = parsed.at(-1).time;
+ const maxCount = Math.max(...parsed.map((point) => point.count), 1);
+ const yMax = niceCeiling(maxCount);
+ const timeSpan = Math.max(maxTime - minTime, 1);
+ const sampled = samplePoints(parsed, 720);
+ const x = (time) => margin.left + ((time - minTime) / timeSpan) * plotWidth;
+ const y = (value) => margin.top + plotHeight - (value / yMax) * plotHeight;
+ const linePath = sampled
+ .map((point, index) => `${index === 0 ? "M" : "L"}${x(point.time).toFixed(1)} ${y(point.count).toFixed(1)}`)
+ .join(" ");
+ const fillPath = `${linePath} L${x(sampled.at(-1).time).toFixed(1)} ${margin.top + plotHeight} L${x(sampled[0].time).toFixed(1)} ${margin.top + plotHeight} Z`;
+ const identifier = `star-history-${themeName}`;
+ const latest = parsed.at(-1);
+
+ const yTicks = Array.from({ length: 6 }, (_, index) => (yMax * index) / 5);
+ const xTicks = Array.from({ length: 5 }, (_, index) => minTime + (timeSpan * index) / 4);
+ const grid = yTicks
+ .map(
+ (value) =>
+ `` +
+ `${formatCount(value)}`,
+ )
+ .join("");
+ const dates = xTicks
+ .map(
+ (time, index) =>
+ `${formatDate(time)}`,
+ )
+ .join("");
+
+ return `
+`;
+}
+
+function samplePoints(points, limit) {
+ if (points.length <= limit) return points;
+ const result = [];
+ for (let index = 0; index < limit; index += 1) {
+ result.push(points[Math.round((index * (points.length - 1)) / (limit - 1))]);
+ }
+ return result;
+}
+
+function niceCeiling(value) {
+ const magnitude = 10 ** Math.floor(Math.log10(value));
+ const normalized = value / magnitude;
+ const ceiling = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
+ return ceiling * magnitude;
+}
+
+function formatCount(value) {
+ if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}M`;
+ if (value >= 1_000) return `${trimDecimal(value / 1_000)}K`;
+ return String(Math.round(value));
+}
+
+function trimDecimal(value) {
+ return value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
+}
+
+function formatDate(time) {
+ return new Intl.DateTimeFormat("en", {
+ month: "short",
+ timeZone: "UTC",
+ year: "numeric",
+ }).format(new Date(time));
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}