feat: initial commit - GitHub Stars Manager
This commit is contained in:
127
services/categorizer.js
Normal file
127
services/categorizer.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// 语言 → 分类 的映射表
|
||||
// 根据仓库主要编程语言直接映射到对应的分类
|
||||
const languageCategoryMap = {
|
||||
'HTML': 'Web应用', 'CSS': 'Web应用', 'JavaScript': 'Web应用',
|
||||
'TypeScript': 'Web应用', 'Vue': 'Web应用', 'Svelte': 'Web应用',
|
||||
'PHP': 'Web应用', 'Ruby': 'Web应用', 'Elixir': 'Web应用',
|
||||
'Python': 'AI/机器学习', 'Jupyter Notebook': 'AI/机器学习',
|
||||
'R': '数据分析', 'Julia': '数据分析',
|
||||
'Java': '移动应用', 'Kotlin': '移动应用', 'Dart': '移动应用',
|
||||
'Swift': '移动应用', 'Objective-C': '移动应用',
|
||||
'C++': '桌面应用', 'C#': '桌面应用', 'Go': '桌面应用',
|
||||
'Rust': '桌面应用', 'C': '桌面应用', 'Zig': '桌面应用',
|
||||
'SQL': '数据库', 'PLpgSQL': '数据库',
|
||||
'Shell': '开发工具', 'Dockerfile': '开发工具', 'Makefile': '开发工具',
|
||||
'CMake': '开发工具', 'PowerShell': '开发工具',
|
||||
};
|
||||
|
||||
// 关键词规则:每个分类关联一组关键词,与仓库名称/描述匹配
|
||||
// 关键词越长代表越精确,匹配时按关键词长度加权
|
||||
const keywordRules = [
|
||||
{ category: 'AI/机器学习', keywords: ['ai', 'artificial intelligence', 'machine learning', 'deep learning', 'neural', 'llm', 'gpt', 'transformer', 'pytorch', 'tensorflow', 'keras', 'openai', 'langchain', 'rag', 'embedding', 'stable diffusion', 'huggingface', 'nlp', 'computer vision', 'reinforcement learning', 'generative'] },
|
||||
{ category: 'Web应用', keywords: ['web', 'website', 'api', 'rest', 'graphql', 'frontend', 'backend', 'fullstack', 'react', 'next.js', 'nuxt', 'express', 'django', 'flask', 'spring', 'laravel', 'rails', 'http', 'server', 'middleware', 'cors', 'websocket'] },
|
||||
{ category: '移动应用', keywords: ['mobile', 'android', 'ios', 'flutter', 'react native', 'swiftui', 'jetpack', 'compose', 'app', 'pwa'] },
|
||||
{ category: '桌面应用', keywords: ['desktop', 'electron', 'tauri', 'qt', 'gtk', 'winui', 'wpf', 'cli', 'terminal', 'tui'] },
|
||||
{ category: '数据库', keywords: ['database', 'sql', 'nosql', 'redis', 'postgresql', 'mysql', 'mongodb', 'sqlite', 'cassandra', 'dynamodb', 'elasticsearch', 'orm', 'prisma', 'sequelize'] },
|
||||
{ category: '开发工具', keywords: ['devtool', 'developer tool', 'ide', 'editor', 'vscode', 'plugin', 'extension', 'compiler', 'linter', 'formatter', 'debugger', 'docker', 'kubernetes', 'k8s', 'ci/cd', 'github action', 'automation', 'scaffold', 'boilerplate', 'template', 'sdk', 'library', 'framework', 'package'] },
|
||||
{ category: '安全工具', keywords: ['security', 'hack', 'penetration', 'vulnerability', 'exploit', 'encrypt', 'decrypt', 'auth', 'oauth', 'jwt', 'firewall', 'malware', 'ransomware', 'cve', 'bug bounty', 'sast', 'dast'] },
|
||||
{ category: '游戏', keywords: ['game', 'gaming', 'unity', 'unreal', 'godot', 'sprite', 'animation', 'physics engine', '3d', 'webgl', 'opengl', 'vulkan', 'shader'] },
|
||||
{ category: '设计工具', keywords: ['design', 'ui', 'ux', 'figma', 'sketch', 'svg', 'icon', 'font', 'typography', 'color', 'theme', 'animation', 'css', 'tailwind', 'bootstrap', 'material'] },
|
||||
{ category: '效率工具', keywords: ['productivity', 'utility', 'tool', 'workflow', 'automation', 'clipboard', 'note', 'todo', 'calendar', 'password', 'manager', 'sync'] },
|
||||
{ category: '教育学习', keywords: ['tutorial', 'course', 'learn', 'education', 'documentation', 'book', 'cheatsheet', 'roadmap', 'example', 'demo', 'guide', 'awesome', 'awesome-'] },
|
||||
{ category: '社交网络', keywords: ['social', 'chat', 'messaging', 'forum', 'community', 'blog', 'feed', 'timeline', 'notification', 'follower', 'friend'] },
|
||||
{ category: '数据分析', keywords: ['data', 'analytics', 'visualization', 'dashboard', 'chart', 'statistics', 'etl', 'big data', 'spark', 'hadoop', 'pandas', 'numpy', 'scipy', 'tableau', 'metabase', 'superset'] },
|
||||
];
|
||||
|
||||
// 文本标准化:转小写 + 去除首尾空格
|
||||
function normalize(text) {
|
||||
return (text || '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
// 关键词评分:遍历关键词列表,匹配到的每个关键词按长度累加得分
|
||||
function scoreByKeywords(text, keywords) {
|
||||
const normalized = normalize(text);
|
||||
let score = 0;
|
||||
for (const kw of keywords) {
|
||||
if (normalized.includes(kw)) {
|
||||
score += kw.length;
|
||||
}
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
// 对单个仓库进行自动分类(返回最多 3 个最匹配的分类)
|
||||
export function autoCategorize(repo, categories) {
|
||||
const lang = repo.language || '';
|
||||
const name = repo.full_name || '';
|
||||
const desc = repo.description || '';
|
||||
|
||||
const results = [];
|
||||
|
||||
// 遍历所有关键词规则进行评分
|
||||
for (const rule of keywordRules) {
|
||||
let score = 0;
|
||||
// 仓库名称权重 2 倍,描述权重 1 倍
|
||||
score += scoreByKeywords(name, rule.keywords) * 2;
|
||||
score += scoreByKeywords(desc, rule.keywords);
|
||||
|
||||
// 如果编程语言直接匹配当前分类,加基础分
|
||||
const langMatch = languageCategoryMap[lang];
|
||||
if (langMatch === rule.category) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
const matched = categories.find(c => c.name === rule.category);
|
||||
if (matched) {
|
||||
results.push({ category: matched, score });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关键词没匹配到:回退到仅按语言映射分类
|
||||
if (results.length === 0) {
|
||||
const langCategory = languageCategoryMap[lang];
|
||||
if (langCategory) {
|
||||
const matched = categories.find(c => c.name === langCategory);
|
||||
if (matched) return [matched];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按得分排序,取最高分的 60% 以上的分类(最多 3 个)
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
const maxScore = results[0].score;
|
||||
return results
|
||||
.filter(r => r.score >= maxScore * 0.6)
|
||||
.map(r => r.category)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
// 批量处理多个仓库的自动分类
|
||||
export function autoCategorizeAll(repos, categories) {
|
||||
const results = [];
|
||||
for (const repo of repos) {
|
||||
const matchedCategories = autoCategorize(repo, categories);
|
||||
results.push({
|
||||
repoId: repo.id,
|
||||
repoName: repo.full_name,
|
||||
categoryIds: matchedCategories.map(c => c.id),
|
||||
categoryNames: matchedCategories.map(c => c.name),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 执行自动分类的完整流程:获取未分类仓库 → 分类匹配 → 批量保存
|
||||
export async function runAutoCategorize(categories, getUncategorizedRepos, batchSetRepoCategories) {
|
||||
const uncategorized = await getUncategorizedRepos();
|
||||
if (uncategorized.length === 0) return 0;
|
||||
const results = autoCategorizeAll(uncategorized, categories);
|
||||
const assignments = results.filter(r => r.categoryIds.length > 0).map(r => ({
|
||||
repoId: r.repoId,
|
||||
categoryIds: r.categoryIds,
|
||||
}));
|
||||
if (assignments.length === 0) return 0;
|
||||
return await batchSetRepoCategories(assignments);
|
||||
}
|
||||
399
services/database.js
Normal file
399
services/database.js
Normal file
@@ -0,0 +1,399 @@
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
|
||||
let db = null;
|
||||
let initPromise = null;
|
||||
|
||||
// 初始化数据库:建表 + 首次运行时插入默认分类
|
||||
export async function initDatabase() {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = (async () => {
|
||||
db = await SQLite.openDatabaseAsync('github_stars.db');
|
||||
|
||||
// 分类表
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL DEFAULT '#0366d6',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
);
|
||||
|
||||
// 星标仓库表
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS starred_repos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
repo_id INTEGER UNIQUE NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
html_url TEXT NOT NULL,
|
||||
language TEXT,
|
||||
stargazers_count INTEGER DEFAULT 0,
|
||||
forks_count INTEGER DEFAULT 0,
|
||||
owner_avatar_url TEXT,
|
||||
owner_login TEXT,
|
||||
default_branch TEXT DEFAULT 'main',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);`
|
||||
);
|
||||
|
||||
// 兼容旧数据库:给已有表添加 default_branch 列(如已存在则静默忽略)
|
||||
try {
|
||||
await db.execAsync('ALTER TABLE starred_repos ADD COLUMN default_branch TEXT DEFAULT \'main\'');
|
||||
} catch (e) {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
// 仓库-分类 多对多关联表
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS repo_categories (
|
||||
repo_id INTEGER NOT NULL,
|
||||
category_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (repo_id, category_id),
|
||||
FOREIGN KEY (repo_id) REFERENCES starred_repos(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE
|
||||
);`
|
||||
);
|
||||
|
||||
// 应用设置 KV 表
|
||||
await db.execAsync(
|
||||
`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);`
|
||||
);
|
||||
|
||||
// 首次运行时插入 13 个默认分类
|
||||
const existingCount = await db.getFirstAsync('SELECT COUNT(*) AS count FROM categories');
|
||||
if (existingCount.count === 0) {
|
||||
const defaultCategories = [
|
||||
['Web应用', '#0366d6'],
|
||||
['移动应用', '#28a745'],
|
||||
['桌面应用', '#d73a4a'],
|
||||
['数据库', '#6f42c1'],
|
||||
['AI/机器学习', '#e36209'],
|
||||
['开发工具', '#19b5a0'],
|
||||
['安全工具', '#f0ad4e'],
|
||||
['游戏', '#8b5cf6'],
|
||||
['设计工具', '#1abc9c'],
|
||||
['效率工具', '#3498db'],
|
||||
['教育学习', '#9b59b6'],
|
||||
['社交网络', '#e67e22'],
|
||||
['数据分析', '#2c3e50'],
|
||||
];
|
||||
for (let i = 0; i < defaultCategories.length; i++) {
|
||||
await db.runAsync(
|
||||
'INSERT OR IGNORE INTO categories (name, color, sort_order) VALUES (?, ?, ?)',
|
||||
defaultCategories[i][0],
|
||||
defaultCategories[i][1],
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// 安全的字符串转换,防止 null/undefined 存入 DB
|
||||
function safeStr(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
// 安全的整数转换
|
||||
function safeInt(value) {
|
||||
if (value == null) return 0;
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? Math.floor(n) : 0;
|
||||
}
|
||||
|
||||
// === 通用设置(KV 存储) ===
|
||||
export async function getSetting(key) {
|
||||
await initDatabase();
|
||||
const row = await db.getFirstAsync(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
safeStr(key)
|
||||
);
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSetting(key, value) {
|
||||
await initDatabase();
|
||||
await db.runAsync(
|
||||
'INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)',
|
||||
safeStr(key),
|
||||
safeStr(value)
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteSetting(key) {
|
||||
await initDatabase();
|
||||
await db.runAsync('DELETE FROM app_settings WHERE key = ?', safeStr(key));
|
||||
}
|
||||
|
||||
// === Token 相关 ===
|
||||
export async function getGitHubToken() {
|
||||
return await getSetting('github_token');
|
||||
}
|
||||
|
||||
export async function setGitHubToken(token) {
|
||||
await setSetting('github_token', token);
|
||||
}
|
||||
|
||||
export async function clearGitHubToken() {
|
||||
await deleteSetting('github_token');
|
||||
}
|
||||
|
||||
// === 分类 CRUD ===
|
||||
export async function getAllCategories() {
|
||||
await initDatabase();
|
||||
return await db.getAllAsync(
|
||||
'SELECT * FROM categories ORDER BY sort_order ASC, created_at ASC'
|
||||
);
|
||||
}
|
||||
|
||||
export async function addCategory(name, color = '#0366d6') {
|
||||
await initDatabase();
|
||||
const maxOrder = await db.getFirstAsync(
|
||||
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM categories'
|
||||
);
|
||||
const result = await db.runAsync(
|
||||
'INSERT INTO categories (name, color, sort_order) VALUES (?, ?, ?)',
|
||||
safeStr(name),
|
||||
safeStr(color),
|
||||
safeInt(maxOrder?.next_order ?? 0)
|
||||
);
|
||||
return result.lastInsertRowId;
|
||||
}
|
||||
|
||||
export async function updateCategory(id, name, color) {
|
||||
await initDatabase();
|
||||
await db.runAsync(
|
||||
'UPDATE categories SET name = ?, color = ? WHERE id = ?',
|
||||
safeStr(name),
|
||||
safeStr(color),
|
||||
safeInt(id)
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteCategory(id) {
|
||||
await initDatabase();
|
||||
await db.runAsync('DELETE FROM repo_categories WHERE category_id = ?', safeInt(id));
|
||||
await db.runAsync('DELETE FROM categories WHERE id = ?', safeInt(id));
|
||||
}
|
||||
|
||||
// === 仓库数据同步 ===
|
||||
// 将 GitHub API 返回的仓库列表保存到本地,已存在则跳过(INSERT OR IGNORE)
|
||||
// 并更新 default_branch 字段以保持最新
|
||||
export async function saveRepos(repos) {
|
||||
await initDatabase();
|
||||
let insertedCount = 0;
|
||||
const errors = [];
|
||||
for (const repo of repos) {
|
||||
try {
|
||||
await db.runAsync(
|
||||
`INSERT OR IGNORE INTO starred_repos
|
||||
(repo_id, full_name, description, html_url, language, stargazers_count, forks_count, owner_avatar_url, owner_login, default_branch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
safeInt(repo?.id),
|
||||
safeStr(repo?.full_name),
|
||||
safeStr(repo?.description),
|
||||
safeStr(repo?.html_url),
|
||||
safeStr(repo?.language),
|
||||
safeInt(repo?.stargazers_count),
|
||||
safeInt(repo?.forks_count),
|
||||
safeStr(repo?.owner?.avatar_url),
|
||||
safeStr(repo?.owner?.login),
|
||||
safeStr(repo?.default_branch || 'main')
|
||||
);
|
||||
|
||||
// 更新已有记录的 default_branch(之前同步的可能没有此字段)
|
||||
await db.runAsync(
|
||||
`UPDATE starred_repos SET default_branch = ? WHERE repo_id = ? AND default_branch != ?`,
|
||||
safeStr(repo?.default_branch || 'main'),
|
||||
safeInt(repo?.id),
|
||||
safeStr(repo?.default_branch || 'main')
|
||||
);
|
||||
|
||||
insertedCount++;
|
||||
} catch (e) {
|
||||
errors.push({ repo: repo?.full_name, error: e.message });
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
console.warn('saveRepos errors:', errors.slice(0, 5));
|
||||
}
|
||||
return insertedCount;
|
||||
}
|
||||
|
||||
// === 仓库查询(含分类关联) ===
|
||||
// 通过 LEFT JOIN 将仓库与其所属分类合并成一条结果
|
||||
// 最终用 formatRepoRows 按 repo_id 聚合多个分类
|
||||
|
||||
// 将单行中的分类信息(cat_id:::cat_name:::cat_color)解析为对象
|
||||
function formatRepoRow(row) {
|
||||
if (!row) return row;
|
||||
const categories = row.categories_raw
|
||||
? row.categories_raw.split('|||').filter(Boolean).map(part => {
|
||||
const [id, name, color] = part.split(':::');
|
||||
return { id: Number(id), name, color };
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
...row,
|
||||
categories,
|
||||
category_name: categories.length > 0 ? categories[0].name : null,
|
||||
category_color: categories.length > 0 ? categories[0].color : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 将 LEFT JOIN 产生的多行合并,同一仓库的多个分类拼接到一个字段中
|
||||
function formatRepoRows(rows) {
|
||||
const map = new Map();
|
||||
for (const row of rows) {
|
||||
const key = row.repo_id;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...row, categories_raw: '' });
|
||||
}
|
||||
if (row.cat_id) {
|
||||
const existing = map.get(key);
|
||||
existing.categories_raw += `${row.cat_id}:::${row.cat_name || ''}:::${row.cat_color || ''}|||`;
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).map(formatRepoRow);
|
||||
}
|
||||
|
||||
// 基础 SELECT 语句,包含仓库字段 + 关联的分类字段
|
||||
const REPO_SELECT =
|
||||
`SELECT r.id, r.repo_id, r.full_name, r.description, r.html_url,
|
||||
r.language, r.stargazers_count, r.forks_count,
|
||||
r.owner_avatar_url, r.owner_login, r.default_branch, r.created_at,
|
||||
rc.category_id AS cat_id, c.name AS cat_name, c.color AS cat_color`;
|
||||
|
||||
export async function getAllRepos() {
|
||||
await initDatabase();
|
||||
const rows = await db.getAllAsync(
|
||||
`${REPO_SELECT}
|
||||
FROM starred_repos r
|
||||
LEFT JOIN repo_categories rc ON r.id = rc.repo_id
|
||||
LEFT JOIN categories c ON rc.category_id = c.id
|
||||
ORDER BY r.created_at DESC`
|
||||
);
|
||||
return formatRepoRows(rows);
|
||||
}
|
||||
|
||||
export async function getReposByCategory(categoryId) {
|
||||
await initDatabase();
|
||||
const rows = await db.getAllAsync(
|
||||
`${REPO_SELECT}
|
||||
FROM starred_repos r
|
||||
INNER JOIN repo_categories rc ON r.id = rc.repo_id
|
||||
LEFT JOIN categories c ON rc.category_id = c.id
|
||||
WHERE rc.category_id = ?
|
||||
ORDER BY r.created_at DESC`,
|
||||
safeInt(categoryId)
|
||||
);
|
||||
return formatRepoRows(rows);
|
||||
}
|
||||
|
||||
export async function getUncategorizedRepos() {
|
||||
await initDatabase();
|
||||
const rows = await db.getAllAsync(
|
||||
`${REPO_SELECT}
|
||||
FROM starred_repos r
|
||||
LEFT JOIN repo_categories rc ON r.id = rc.repo_id
|
||||
LEFT JOIN categories c ON rc.category_id = c.id
|
||||
WHERE rc.repo_id IS NULL
|
||||
ORDER BY r.created_at DESC`
|
||||
);
|
||||
return formatRepoRows(rows);
|
||||
}
|
||||
|
||||
// === 仓库-分类 关联操作 ===
|
||||
export async function getRepoCategories(repoId) {
|
||||
await initDatabase();
|
||||
return await db.getAllAsync(
|
||||
`SELECT c.id, c.name, c.color
|
||||
FROM repo_categories rc
|
||||
JOIN categories c ON rc.category_id = c.id
|
||||
WHERE rc.repo_id = ?
|
||||
ORDER BY c.sort_order ASC`,
|
||||
safeInt(repoId)
|
||||
);
|
||||
}
|
||||
|
||||
// 先删后插:清除旧关联后重新设置(用于手动调整分类)
|
||||
export async function setRepoCategories(repoId, categoryIds) {
|
||||
await initDatabase();
|
||||
await db.runAsync('DELETE FROM repo_categories WHERE repo_id = ?', safeInt(repoId));
|
||||
for (const catId of categoryIds) {
|
||||
if (catId == null) continue;
|
||||
await db.runAsync(
|
||||
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
|
||||
safeInt(repoId),
|
||||
safeInt(catId)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addRepoCategory(repoId, categoryId) {
|
||||
await initDatabase();
|
||||
await db.runAsync(
|
||||
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
|
||||
safeInt(repoId),
|
||||
safeInt(categoryId)
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeRepoCategory(repoId, categoryId) {
|
||||
await initDatabase();
|
||||
await db.runAsync(
|
||||
'DELETE FROM repo_categories WHERE repo_id = ? AND category_id = ?',
|
||||
safeInt(repoId),
|
||||
safeInt(categoryId)
|
||||
);
|
||||
}
|
||||
|
||||
// 批量设置仓库分类(用于自动分类引擎)
|
||||
export async function batchSetRepoCategories(assignments) {
|
||||
await initDatabase();
|
||||
let count = 0;
|
||||
for (const { repoId, categoryIds } of assignments) {
|
||||
if (!categoryIds || categoryIds.length === 0) continue;
|
||||
try {
|
||||
for (const catId of categoryIds) {
|
||||
await db.runAsync(
|
||||
'INSERT OR IGNORE INTO repo_categories (repo_id, category_id) VALUES (?, ?)',
|
||||
safeInt(repoId),
|
||||
safeInt(catId)
|
||||
);
|
||||
}
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.warn('batchSetRepoCategories error:', repoId, e.message);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// === 统计查询 ===
|
||||
export async function getRepoCountByCategory() {
|
||||
await initDatabase();
|
||||
return await db.getAllAsync(
|
||||
`SELECT c.id, c.name, c.color, COUNT(rc.repo_id) AS repo_count
|
||||
FROM categories c
|
||||
LEFT JOIN repo_categories rc ON c.id = rc.category_id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.sort_order ASC`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTotalRepoCount() {
|
||||
await initDatabase();
|
||||
const result = await db.getFirstAsync(
|
||||
'SELECT COUNT(*) AS count FROM starred_repos'
|
||||
);
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
154
services/github.js
Normal file
154
services/github.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
const GITHUB_API_URL = 'https://api.github.com';
|
||||
// 用于检查更新的仓库地址,从 app.json extra 中读取
|
||||
const APP_REPO = Constants.expoConfig?.extra?.appRepo || 'EchoZenith/GithubStarsManager-Android';
|
||||
// 当前版本号从 app.json 中读取(version 字段)
|
||||
const CURRENT_VERSION = Constants.expoConfig?.version || '1.0.0';
|
||||
|
||||
// 自定义错误类型:Token 过期或无效时抛出,供上层 UI 捕获后跳转 Token 输入页
|
||||
class TokenExpiredError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = 'TokenExpiredError';
|
||||
}
|
||||
}
|
||||
|
||||
export { TokenExpiredError };
|
||||
|
||||
// 构建 GitHub API 请求头
|
||||
function buildHeaders(token) {
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
}
|
||||
|
||||
// 分页获取用户所有星标仓库(每页 100 条,自动翻页直到取完)
|
||||
export async function fetchStarredRepos(token) {
|
||||
if (!token) {
|
||||
throw new TokenExpiredError('请先输入 GitHub Token');
|
||||
}
|
||||
|
||||
let allRepos = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await fetch(
|
||||
`${GITHUB_API_URL}/user/starred?per_page=100&page=${page}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: buildHeaders(token),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new TokenExpiredError('GitHub Token 已过期或无效,请重新输入');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API 错误!状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
allRepos = allRepos.concat(data);
|
||||
page++;
|
||||
}
|
||||
}
|
||||
|
||||
return allRepos;
|
||||
}
|
||||
|
||||
// 获取仓库的 README 原始内容(raw 格式直接返回 markdown 文本)
|
||||
export async function fetchReadme(token, fullName) {
|
||||
if (!token) {
|
||||
throw new TokenExpiredError('请先输入 GitHub Token');
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GITHUB_API_URL}/repos/${fullName}/readme`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...buildHeaders(token),
|
||||
Accept: 'application/vnd.github.raw',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new TokenExpiredError('GitHub Token 已过期或无效,请重新输入');
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`获取 README 失败!状态码: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
// 检查应用更新:对比当前版本与 GitHub Releases 最新版本号
|
||||
export async function checkUpdate(token) {
|
||||
try {
|
||||
const headers = {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${GITHUB_API_URL}/repos/${APP_REPO}/releases/latest`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
return { hasUpdate: false, error: null, message: '未找到发布版本' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { hasUpdate: false, error: '检查更新失败', message: null };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name.replace(/^v/, '');
|
||||
const currentParts = CURRENT_VERSION.split('.').map(Number);
|
||||
const latestParts = latestVersion.split('.').map(Number);
|
||||
|
||||
// 逐段比较版本号(major.minor.patch)
|
||||
let hasUpdate = false;
|
||||
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
|
||||
const cur = currentParts[i] || 0;
|
||||
const lat = latestParts[i] || 0;
|
||||
if (lat > cur) { hasUpdate = true; break; }
|
||||
if (lat < cur) break;
|
||||
}
|
||||
|
||||
return {
|
||||
hasUpdate,
|
||||
currentVersion: CURRENT_VERSION,
|
||||
latestVersion,
|
||||
releaseUrl: data.html_url,
|
||||
releaseName: data.name || data.tag_name,
|
||||
releaseBody: data.body ? data.body.split('\n').slice(0, 5).join('\n') : '',
|
||||
publishedAt: data.published_at,
|
||||
error: null,
|
||||
message: hasUpdate
|
||||
? `发现新版本 v${latestVersion}`
|
||||
: '已是最新版本',
|
||||
};
|
||||
} catch (e) {
|
||||
return { hasUpdate: false, error: e.message || '网络错误', message: null };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user