diff --git a/src/components/ReleaseSourceSettingsModal.tsx b/src/components/ReleaseSourceSettingsModal.tsx new file mode 100644 index 0000000..0ce54bd --- /dev/null +++ b/src/components/ReleaseSourceSettingsModal.tsx @@ -0,0 +1,399 @@ +import React, { useMemo, useState } from 'react'; +import { Bell, ChevronDown, ChevronLeft, ChevronRight, Eye, EyeOff, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import type { CustomReleaseRepository, ReleaseSourceId } from '../types'; +import { useAppStore } from '../store/useAppStore'; +import { Modal } from './Modal'; +import { useDialog } from '../hooks/useDialog'; +import { GitHubApiService } from '../services/githubApi'; +import { + CUSTOM_RELEASE_SOURCE_ID, + RELEASE_SOURCE_LABELS, + STARRED_RELEASE_SOURCE_ID, + WATCH_CUSTOM_RELEASE_SOURCE_ID, + createCustomReleaseRepository, + normalizeRepoKey, + repositoryToCustomReleaseRepository, +} from '../utils/releaseSources'; + +interface ReleaseSourceSettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +interface RepoListEditorProps { + sourceId: ReleaseSourceId; + repos: CustomReleaseRepository[]; + title: string; + description: string; + placeholder: string; + language: 'zh' | 'en'; +} + +interface PaginatedRepoListProps { + repos: CustomReleaseRepository[]; + language: 'zh' | 'en'; + emptyText: string; + renderActions?: (repo: CustomReleaseRepository) => React.ReactNode; +} + +const PAGE_SIZE = 8; + +const PaginatedRepoList: React.FC = ({ repos, language, emptyText, renderActions }) => { + const [isExpanded, setIsExpanded] = useState(true); + const [page, setPage] = useState(1); + const t = (zh: string, en: string) => language === 'zh' ? zh : en; + const totalPages = Math.max(1, Math.ceil(repos.length / PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const visibleRepos = repos.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const goToPage = (nextPage: number) => { + setPage(Math.max(1, Math.min(nextPage, totalPages))); + }; + + return ( +
+ + + {isExpanded && ( +
+ {repos.length === 0 ? ( +

+ {emptyText} +

+ ) : visibleRepos.map(repo => ( +
+
+
{repo.full_name}
+
{repo.html_url}
+
+ {renderActions &&
{renderActions(repo)}
} +
+ ))} + + {repos.length > PAGE_SIZE && ( +
+ {t(`第 ${currentPage}/${totalPages} 页`, `Page ${currentPage}/${totalPages}`)} +
+ + +
+
+ )} +
+ )} +
+ ); +}; + +const RepoListEditor: React.FC = ({ + sourceId, + repos, + title, + description, + placeholder, + language, +}) => { + const addReleaseSourceRepository = useAppStore(state => state.addReleaseSourceRepository); + const removeReleaseSourceRepository = useAppStore(state => state.removeReleaseSourceRepository); + const { toast } = useDialog(); + const [input, setInput] = useState(''); + + const t = (zh: string, en: string) => language === 'zh' ? zh : en; + + const repoKeys = useMemo(() => new Set(repos.map(repo => normalizeRepoKey(repo.full_name))), [repos]); + + const handleAdd = () => { + const repo = createCustomReleaseRepository(input, sourceId); + if (!repo) { + toast(t('请输入有效的 GitHub 仓库地址,例如 owner/repo。', 'Enter a valid GitHub repository, for example owner/repo.'), 'error'); + return; + } + + if (repoKeys.has(normalizeRepoKey(repo.full_name))) { + toast(t('该仓库已在列表中。', 'This repository is already in the list.'), 'info'); + return; + } + + addReleaseSourceRepository(sourceId, repo); + setInput(''); + toast(t('已添加 Release 来源仓库。', 'Release source repository added.'), 'success'); + }; + + return ( +
+
+

{title}

+

{description}

+
+ +
+ setInput(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') handleAdd(); + }} + placeholder={placeholder} + className="min-w-0 flex-1 rounded-lg border border-black/[0.06] dark:border-white/[0.04] bg-white dark:bg-white/[0.04] px-3 py-2 text-sm text-gray-900 dark:text-text-primary focus:border-transparent focus:ring-2 focus:ring-brand-violet" + /> + +
+ + ( + + )} + /> +
+ ); +}; + +interface WatchCustomReleaseSyncPanelProps { + repos: CustomReleaseRepository[]; + language: 'zh' | 'en'; +} + +const WatchCustomReleaseSyncPanel: React.FC = ({ repos, language }) => { + const githubToken = useAppStore(state => state.githubToken); + const setReleaseSourceRepositories = useAppStore(state => state.setReleaseSourceRepositories); + const updateReleaseSourceRepository = useAppStore(state => state.updateReleaseSourceRepository); + const { toast } = useDialog(); + const [isSyncing, setIsSyncing] = useState(false); + + const t = (zh: string, en: string) => language === 'zh' ? zh : en; + + const handleSync = async () => { + if (!githubToken || isSyncing) return; + + setIsSyncing(true); + try { + const githubApi = new GitHubApiService(githubToken); + const watchedRepos = await githubApi.getAllWatchedRepositoriesForCurrentUser(); + const hiddenByRepo = new Map(repos.map(repo => [normalizeRepoKey(repo.full_name), repo.release_hidden])); + const sourceRepos = watchedRepos.map(repo => ({ + ...repositoryToCustomReleaseRepository(repo, WATCH_CUSTOM_RELEASE_SOURCE_ID), + release_hidden: hiddenByRepo.get(normalizeRepoKey(repo.full_name)) || undefined, + })); + setReleaseSourceRepositories(WATCH_CUSTOM_RELEASE_SOURCE_ID, sourceRepos); + toast( + t( + `已同步 ${sourceRepos.length} 个 Watch 仓库。`, + `Synced ${sourceRepos.length} Watch repositories.` + ), + 'success' + ); + } catch (error) { + console.error('Failed to sync watched repositories:', error); + toast(t('同步 Watch 仓库失败,请检查网络或 Token 权限。', 'Failed to sync Watch repositories. Check network or token permissions.'), 'error'); + } finally { + setIsSyncing(false); + } + }; + + return ( +
+
+
+

{t('Watch 仓库同步', 'Watch repository sync')}

+

+ {t( + '点击同步会拉取当前 GitHub 账号 Watch 的仓库,并作为 Release 来源。', + 'Sync pulls repositories watched by the current GitHub account and uses them as release sources.' + )} +

+
+ +
+ + { + const hidden = !!repo.release_hidden; + return ( + + ); + }} + /> +
+ ); +}; + +export const ReleaseSourceSettingsModal: React.FC = ({ isOpen, onClose }) => { + const language = useAppStore(state => state.language); + const releaseSourceSettings = useAppStore(state => state.releaseSourceSettings); + const releaseSubscriptions = useAppStore(state => state.releaseSubscriptions); + const toggleReleaseSource = useAppStore(state => state.toggleReleaseSource); + const { toast } = useDialog(); + + const t = (zh: string, en: string) => language === 'zh' ? zh : en; + const enabledSources = new Set(releaseSourceSettings.enabledSourceIds); + + const sourceRows: Array<{ id: ReleaseSourceId; title: string; description: string; count: number }> = [ + { + id: STARRED_RELEASE_SOURCE_ID, + title: t('星标铃铛订阅', 'Starred bell subscriptions'), + description: t('当前在仓库卡片点击铃铛订阅的 Release 来源,默认启用。', 'Existing release source from repository cards where the bell is enabled. Enabled by default.'), + count: releaseSubscriptions.size, + }, + { + id: WATCH_CUSTOM_RELEASE_SOURCE_ID, + title: RELEASE_SOURCE_LABELS[WATCH_CUSTOM_RELEASE_SOURCE_ID][language], + description: t('从 Watch 仓库同步的 Release 来源。', 'Release source synced from Watch repositories.'), + count: releaseSourceSettings.watchCustomReleaseRepos.length, + }, + { + id: CUSTOM_RELEASE_SOURCE_ID, + title: t('自定义 Release 来源', 'Custom release source'), + description: t('手动输入 GitHub 仓库地址,刷新时一并检查 Release。', 'Manually enter GitHub repositories to check during release refresh.'), + count: releaseSourceSettings.customReleaseRepos.length, + }, + ]; + + const handleToggle = (sourceId: ReleaseSourceId) => { + if (enabledSources.has(sourceId) && enabledSources.size === 1) { + toast(t('至少需要保留一个 Release 来源。', 'Keep at least one release source enabled.'), 'error'); + return; + } + toggleReleaseSource(sourceId); + }; + + return ( + +
+
+ {t( + '选择刷新 Release 时要检查的来源。多个来源包含同一仓库时会自动去重。', + 'Choose the sources checked when refreshing releases. Repositories appearing in multiple sources are deduplicated.' + )} +
+ +
+ {sourceRows.map(source => { + const checked = enabledSources.has(source.id); + return ( + + ); + })} +
+ + {enabledSources.has(WATCH_CUSTOM_RELEASE_SOURCE_ID) && ( + + )} + + {enabledSources.has(CUSTOM_RELEASE_SOURCE_ID) && ( + + )} + +
+ +
+
+
+ ); +}; diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index 26c74a2..028fc44 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; -import { Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, LayoutGrid, CalendarDays, ChevronDown, CheckCircle, Filter } from 'lucide-react'; +import { Package, Bell, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, LayoutGrid, CalendarDays, ChevronDown, CheckCircle, Filter, Settings } from 'lucide-react'; import { Release } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; @@ -9,13 +9,25 @@ import { formatDistanceToNow } from 'date-fns'; import { AssetFilterManager } from './AssetFilterManager'; import { PRESET_FILTERS } from '../constants/presetFilters'; import ReleaseCard from './ReleaseCard'; +import { ReleaseSourceSettingsModal } from './ReleaseSourceSettingsModal'; import { useDialog } from '../hooks/useDialog'; +import { + STARRED_RELEASE_SOURCE_ID, + WATCH_CUSTOM_RELEASE_SOURCE_ID, + CUSTOM_RELEASE_SOURCE_ID, + getReleaseSourceLabel, + getSourcesForReleaseRepository, + normalizeRepoKey, + releaseBelongsToResolvedSources, + resolveReleaseSources, +} from '../utils/releaseSources'; export const ReleaseTimeline: React.FC = () => { const { releases, repositories, releaseSubscriptions, + releaseSourceSettings, readReleases, githubToken, language, @@ -24,8 +36,10 @@ export const ReleaseTimeline: React.FC = () => { markReleaseAsRead, markAllReleasesAsRead, batchUnsubscribeReleases, - removeReleasesByRepoId, + removeReleasesByRepoFullName, updateRepository, + removeReleaseSourceRepository, + updateReleaseSourceRepository, // Release Timeline View State from global store releaseViewMode, releaseSelectedFilters, @@ -56,6 +70,7 @@ export const ReleaseTimeline: React.FC = () => { // 视图切换下拉菜单状态(本地UI状态) const [isViewDropdownOpen, setIsViewDropdownOpen] = useState(false); const [isShowModeDropdownOpen, setIsShowModeDropdownOpen] = useState(false); + const [isReleaseSourceSettingsOpen, setIsReleaseSourceSettingsOpen] = useState(false); const [isMarkingAllRead, setIsMarkingAllRead] = useState(false); // 使用全局状态的别名,保持代码一致性 @@ -64,6 +79,13 @@ export const ReleaseTimeline: React.FC = () => { const searchQuery = releaseSearchQuery; const expandedRepositories = releaseExpandedRepositories; + const resolvedReleaseSources = useMemo(() => resolveReleaseSources({ + repositories, + releaseSubscriptions, + releaseSourceSettings, + }), [repositories, releaseSubscriptions, releaseSourceSettings]); + const activeReleaseRepoCount = resolvedReleaseSources.repositories.length; + // Format file size helper function const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B'; @@ -189,10 +211,10 @@ export const ReleaseTimeline: React.FC = () => { const subscribedReleases = useMemo(() => releases.filter(release => - releaseSubscriptions.has(release.repository.id) && + releaseBelongsToResolvedSources(release, resolvedReleaseSources) && (includePreRelease || !release.prerelease) ), - [releases, releaseSubscriptions, includePreRelease] + [releases, resolvedReleaseSources, includePreRelease] ); // 未读模式下,快照当前未读 release ID,避免标记已读后立即消失 @@ -203,7 +225,7 @@ export const ReleaseTimeline: React.FC = () => { const state = useAppStore.getState(); const ids = new Set(); releases.forEach(r => { - if (releaseSubscriptions.has(r.repository.id) && + if (releaseBelongsToResolvedSources(r, resolvedReleaseSources) && (includePreRelease || !r.prerelease) && !state.readReleases.has(r.id)) { ids.add(r.id); @@ -211,7 +233,7 @@ export const ReleaseTimeline: React.FC = () => { }); unreadSnapshotRef.current = ids; setSnapshotVersion(v => v + 1); - }, [releases, releaseSubscriptions, includePreRelease, releaseShowMode]); + }, [releases, resolvedReleaseSources, includePreRelease, releaseShowMode]); // 预计算每个 release 的下载链接和过滤后的链接 const releasesWithLinks = useMemo(() => { @@ -341,18 +363,24 @@ export const ReleaseTimeline: React.FC = () => { return; } + const state = useAppStore.getState(); + const resolvedSources = resolveReleaseSources(state); + const subscribedRepos = resolvedSources.repositories; + + if (resolvedSources.enabledSourceIds.length === 0) { + toast(language === 'zh' ? '没有启用的 Release 来源。' : 'No release sources enabled.', 'error'); + return; + } + + if (subscribedRepos.length === 0) { + toast(language === 'zh' ? '所选来源中没有可检查的仓库。' : 'No repositories to check in the selected sources.', 'error'); + return; + } + setReleaseIsRefreshing(true); try { const githubApi = new GitHubApiService(githubToken); - // Only fetch releases for repos that are subscribed to releases - const subscribedRepos = repositories.filter(repo => releaseSubscriptions.has(repo.id)); - - if (subscribedRepos.length === 0) { - toast(language === 'zh' ? '没有订阅的仓库。' : 'No subscribed repositories.', 'error'); - return; - } - // Use the new getMultipleRepositoryReleases with options const { releases: newReleases, failedRepos } = await githubApi.getMultipleRepositoryReleases( subscribedRepos, { includePreRelease } @@ -361,15 +389,33 @@ export const ReleaseTimeline: React.FC = () => { // Update repository sync metadata only for repos that succeeded const now = new Date().toISOString(); const failedRepoIds = new Set(failedRepos.map(repo => repo.repoId)); - for (const repo of subscribedRepos) { + for (const entry of resolvedSources.entries) { + const repo = entry.repository; if (failedRepoIds.has(repo.id)) { continue; } - updateRepository({ - ...repo, - has_fetched_releases: true, - last_release_fetch_time: now, - }); + if (entry.sources.includes(STARRED_RELEASE_SOURCE_ID)) { + const starredRepo = state.repositories.find(item => normalizeRepoKey(item.full_name) === normalizeRepoKey(repo.full_name)); + if (starredRepo) { + updateRepository({ + ...starredRepo, + has_fetched_releases: true, + last_release_fetch_time: now, + }); + } + } + if (entry.sources.includes(WATCH_CUSTOM_RELEASE_SOURCE_ID)) { + updateReleaseSourceRepository(WATCH_CUSTOM_RELEASE_SOURCE_ID, repo.full_name, { + has_fetched_releases: true, + last_release_fetch_time: now, + }); + } + if (entry.sources.includes(CUSTOM_RELEASE_SOURCE_ID)) { + updateReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, repo.full_name, { + has_fetched_releases: true, + last_release_fetch_time: now, + }); + } } // Filter out existing releases and add new ones @@ -530,15 +576,42 @@ export const ReleaseTimeline: React.FC = () => { }, [paginatedReleases, paginatedRepositoryGroups, getTruncatedBody]); const handleUnsubscribeRelease = async (repoId: number) => { - const repo = repositories.find((item) => item.id === repoId); - if (!repo) { + const release = releases.find(item => item.repository.id === repoId); + const releaseRepo = release?.repository; + if (!releaseRepo) { toast(t('仓库信息不完整,无法取消订阅。', 'Repository information missing. Cannot unsubscribe.'), 'error'); return; } - const confirmMessage = language === 'zh' - ? `确定取消订阅 "${repo.full_name}" 的 Release 吗?` - : `Unsubscribe from releases for "${repo.full_name}"?`; + const stateBeforeConfirm = useAppStore.getState(); + const repoKey = normalizeRepoKey(releaseRepo.full_name); + const starredRepo = stateBeforeConfirm.repositories.find(item => normalizeRepoKey(item.full_name) === repoKey); + const sourcesToRemove = getSourcesForReleaseRepository(stateBeforeConfirm, releaseRepo); + const isOrphanRelease = sourcesToRemove.length === 0; + const sourceLabels = sourcesToRemove.map(sourceId => getReleaseSourceLabel(sourceId, language)); + + let confirmMessage: string; + if (isOrphanRelease) { + confirmMessage = language === 'zh' + ? `"${releaseRepo.full_name}" 当前不在任何 Release 来源中。确认后仅移除本地已缓存的 Release 记录。` + : `"${releaseRepo.full_name}" is not in any release source. Confirming will only remove locally cached releases.`; + } else if (sourcesToRemove.length > 1) { + confirmMessage = language === 'zh' + ? `"${releaseRepo.full_name}" 同时来自多个 Release 来源:${sourceLabels.join('、')}。确认后将从这些来源中一并取消订阅。` + : `"${releaseRepo.full_name}" comes from multiple release sources: ${sourceLabels.join(', ')}. Confirming will unsubscribe it from all of these sources.`; + } else if (sourcesToRemove[0] === WATCH_CUSTOM_RELEASE_SOURCE_ID) { + confirmMessage = language === 'zh' + ? `确定取消订阅 "${releaseRepo.full_name}" 吗?确认后将一并取消 Watch 仓库来源。` + : `Unsubscribe from "${releaseRepo.full_name}"? This will also remove it from Watch repositories.`; + } else if (sourcesToRemove[0] === CUSTOM_RELEASE_SOURCE_ID) { + confirmMessage = language === 'zh' + ? `确定取消订阅 "${releaseRepo.full_name}" 吗?确认后将从自定义仓库列表中移除。` + : `Unsubscribe from "${releaseRepo.full_name}"? This will remove it from the custom repository list.`; + } else { + confirmMessage = language === 'zh' + ? `确定取消订阅 "${releaseRepo.full_name}" 的 Release 吗?` + : `Unsubscribe from releases for "${releaseRepo.full_name}"?`; + } const confirmed = await confirm( t('取消订阅确认', 'Unsubscribe Confirmation'), @@ -549,25 +622,39 @@ export const ReleaseTimeline: React.FC = () => { return; } - const removedReleases = releases.filter(r => r.repository.id === repoId); - const removedReadIds = new Set(removedReleases.map(r => r.id)); + const rollbackState = { + repositories: stateBeforeConfirm.repositories, + searchResults: stateBeforeConfirm.searchResults, + releaseSubscriptions: new Set(stateBeforeConfirm.releaseSubscriptions), + releaseSourceSettings: stateBeforeConfirm.releaseSourceSettings, + releases: stateBeforeConfirm.releases, + readReleases: new Set(stateBeforeConfirm.readReleases), + releaseExpandedRepositories: new Set(stateBeforeConfirm.releaseExpandedRepositories), + }; + + if (sourcesToRemove.includes(STARRED_RELEASE_SOURCE_ID) && starredRepo) { + updateRepository({ ...starredRepo, subscribed_to_releases: false }); + batchUnsubscribeReleases([starredRepo.id]); + } + if (sourcesToRemove.includes(WATCH_CUSTOM_RELEASE_SOURCE_ID)) { + removeReleaseSourceRepository(WATCH_CUSTOM_RELEASE_SOURCE_ID, releaseRepo.full_name); + } + if (sourcesToRemove.includes(CUSTOM_RELEASE_SOURCE_ID)) { + removeReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, releaseRepo.full_name); + } - const updatedRepo = { ...repo, subscribed_to_releases: false }; - updateRepository(updatedRepo); - batchUnsubscribeReleases([repo.id]); - removeReleasesByRepoId(repo.id); + const stateAfterRemoval = useAppStore.getState(); + const stillActive = resolveReleaseSources(stateAfterRemoval).entries + .some(entry => normalizeRepoKey(entry.repository.full_name) === repoKey); + if (!stillActive) { + removeReleasesByRepoFullName(releaseRepo.full_name); + } try { await forceSyncToBackend(); } catch (error) { console.error('Failed to unsubscribe release:', error); - updateRepository({ ...repo, subscribed_to_releases: true }); - const state = useAppStore.getState(); - useAppStore.setState({ - releaseSubscriptions: new Set([...state.releaseSubscriptions, repo.id]), - releases: [...state.releases, ...removedReleases], - readReleases: new Set([...state.readReleases, ...removedReadIds]), - }); + useAppStore.setState(rollbackState); toast(t('取消订阅失败,请检查后端连接。', 'Failed to unsubscribe. Please check backend connection.'), 'error'); return; } @@ -576,9 +663,10 @@ export const ReleaseTimeline: React.FC = () => { }; if (subscribedReleases.length === 0) { - const subscribedRepoCount = releaseSubscriptions.size; - + const subscribedRepoCount = activeReleaseRepoCount; + return ( + <>

@@ -612,15 +700,25 @@ export const ReleaseTimeline: React.FC = () => { - {/* Refresh button */} - +
+ {/* Refresh button */} + + +
{lastRefreshTime && (

{t('上次刷新:', 'Last refresh:')} {formatDistanceToNow(new Date(lastRefreshTime), { addSuffix: true })} @@ -652,11 +750,29 @@ export const ReleaseTimeline: React.FC = () => { {t('点击仓库卡片上的铃铛图标', 'Click the bell icon on any repository card')}

+
+

+ {t('也可以通过 Watch 仓库同步或自定义仓库列表作为 Release 来源。', 'You can also use Watch repository sync or a custom repository list as release sources.')} +

+ +
)} + setIsReleaseSourceSettingsOpen(false)} + /> + ); } @@ -670,7 +786,7 @@ export const ReleaseTimeline: React.FC = () => { {t('Release时间线', 'Release Timeline')}

- {t(`来自您的 ${releaseSubscriptions.size} 个订阅仓库的最新Release`, `Latest releases from your ${releaseSubscriptions.size} subscribed repositories`)} + {t(`来自您的 ${activeReleaseRepoCount} 个订阅仓库的最新Release`, `Latest releases from your ${activeReleaseRepoCount} subscribed repositories`)}

@@ -708,6 +824,14 @@ export const ReleaseTimeline: React.FC = () => { {releaseIsRefreshing ? t('刷新中...', 'Refreshing...') : t('刷新', 'Refresh')} +
@@ -1149,6 +1273,10 @@ export const ReleaseTimeline: React.FC = () => { )} + setIsReleaseSourceSettingsOpen(false)} + /> ); }; diff --git a/src/components/settings/DataManagementPanel.tsx b/src/components/settings/DataManagementPanel.tsx index 4717c9b..5341dd4 100644 --- a/src/components/settings/DataManagementPanel.tsx +++ b/src/components/settings/DataManagementPanel.tsx @@ -37,8 +37,13 @@ import type { SubscriptionChannel, SearchFilters, ProxyConfig, - RpcDownloadConfig + RpcDownloadConfig, + ReleaseSourceSettings } from '../../types'; +import { + mergeReleaseSourceSettings, + normalizeReleaseSourceSettings, +} from '../../utils/releaseSources'; interface DataManagementPanelProps { t: (zh: string, en: string) => string; @@ -90,6 +95,7 @@ interface ExportData { subscriptionLastRefresh?: Record; subscriptionChannels?: SubscriptionChannel[]; releaseSubscriptions?: number[]; + releaseSourceSettings?: ReleaseSourceSettings; readReleases?: number[]; searchFilters?: SearchFilters; hiddenDefaultCategoryIds?: string[]; @@ -151,6 +157,7 @@ export const DataManagementPanel: React.FC = ({ t }) = discoveryRepos, subscriptionRepos, releaseSubscriptions, + releaseSourceSettings, readReleases, language, setRepositories, @@ -408,8 +415,9 @@ export const DataManagementPanel: React.FC = ({ t }) = const deleteReleaseSubscriptions = async () => { try { - useAppStore.setState({ + useAppStore.setState({ releaseSubscriptions: new Set(), + releaseSourceSettings: normalizeReleaseSourceSettings(null), readReleases: new Set() }); addLog(t('删除 Release 订阅与已读', 'Delete release subscriptions & read'), true); @@ -506,6 +514,7 @@ export const DataManagementPanel: React.FC = ({ t }) = } if (selectedTypes.includes('releaseSubscriptions')) { exportDataObj.data.releaseSubscriptions = Array.from(store.releaseSubscriptions); + exportDataObj.data.releaseSourceSettings = store.releaseSourceSettings; exportDataObj.data.readReleases = Array.from(store.readReleases); } if (selectedTypes.includes('searchFilters')) { @@ -654,12 +663,9 @@ export const DataManagementPanel: React.FC = ({ t }) = } } if (selectedTypes.includes('releaseSubscriptions')) { - if (importedData.releaseSubscriptions) { - useAppStore.setState({ releaseSubscriptions: new Set(importedData.releaseSubscriptions) }); - } - if (importedData.readReleases) { - useAppStore.setState({ readReleases: new Set(importedData.readReleases) }); - } + useAppStore.setState({ releaseSubscriptions: new Set(importedData.releaseSubscriptions || []) }); + store.setReleaseSourceSettings(normalizeReleaseSourceSettings(importedData.releaseSourceSettings || null)); + useAppStore.setState({ readReleases: new Set(importedData.readReleases || []) }); } if (selectedTypes.includes('searchFilters') && importedData.searchFilters) { useAppStore.setState({ searchFilters: importedData.searchFilters }); @@ -755,10 +761,18 @@ export const DataManagementPanel: React.FC = ({ t }) = const newFilters = importedData.assetFilters.filter(f => !existingIds.has(f.id)); useAppStore.setState({ assetFilters: [...store.assetFilters, ...newFilters] }); } - if (selectedTypes.includes('releaseSubscriptions') && importedData.releaseSubscriptions) { - const existingSubs = store.releaseSubscriptions; - const newSubs = new Set([...Array.from(existingSubs), ...importedData.releaseSubscriptions]); - useAppStore.setState({ releaseSubscriptions: newSubs }); + if (selectedTypes.includes('releaseSubscriptions')) { + if (importedData.releaseSubscriptions) { + const existingSubs = store.releaseSubscriptions; + const newSubs = new Set([...Array.from(existingSubs), ...importedData.releaseSubscriptions]); + useAppStore.setState({ releaseSubscriptions: newSubs }); + } + if (importedData.releaseSourceSettings) { + store.setReleaseSourceSettings(mergeReleaseSourceSettings( + store.releaseSourceSettings, + normalizeReleaseSourceSettings(importedData.releaseSourceSettings) + )); + } } } @@ -1215,9 +1229,9 @@ export const DataManagementPanel: React.FC = ({ t }) = { key: 'releaseSubscriptions', label: t('Release 订阅与已读', 'Release Subscriptions & Read'), - description: t('已订阅 Release 的仓库列表和已读标记。删除后 Release 时间线将不显示订阅状态和已读标记。', - 'Subscribed repo list and read marks for releases. Subscription status and read marks lost after deletion.'), - count: releaseSubscriptions.size, + description: t('已订阅 Release 的仓库列表、来源设置和已读标记。删除后 Release 时间线将不显示订阅状态和已读标记。', + 'Subscribed repo list, source settings, and read marks for releases. Subscription status and read marks lost after deletion.'), + count: releaseSubscriptions.size + releaseSourceSettings.watchCustomReleaseRepos.length + releaseSourceSettings.customReleaseRepos.length, icon: , color: 'text-gray-700 dark:text-text-secondary', bgColor: 'bg-gray-100 dark:bg-white/[0.04]', diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 918d478..b64a755 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -222,6 +222,9 @@ export async function syncFromBackend(): Promise { if (Array.isArray(settings.assetFilters)) { useAppStore.setState({ assetFilters: settings.assetFilters }); } + if (settings.releaseSourceSettings && typeof settings.releaseSourceSettings === 'object') { + state.setReleaseSourceSettings(settings.releaseSourceSettings as typeof state.releaseSourceSettings); + } if (typeof settings.collapsedSidebarCategoryCount === 'number' && settings.collapsedSidebarCategoryCount >= 1) { useAppStore.setState({ collapsedSidebarCategoryCount: settings.collapsedSidebarCategoryCount }); } @@ -276,6 +279,7 @@ export async function syncToBackend(): Promise { categoryOrder: state.categoryOrder, customCategories: state.customCategories, assetFilters: state.assetFilters, + releaseSourceSettings: state.releaseSourceSettings, collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, }), ]); @@ -303,6 +307,7 @@ export async function syncToBackend(): Promise { categoryOrder: state.categoryOrder, customCategories: state.customCategories, assetFilters: state.assetFilters, + releaseSourceSettings: state.releaseSourceSettings, collapsedSidebarCategoryCount: state.collapsedSidebarCategoryCount, }); } @@ -366,6 +371,7 @@ export function startAutoSync(): () => void { state.categoryOrder !== prevState.categoryOrder || state.customCategories !== prevState.customCategories || state.assetFilters !== prevState.assetFilters || + state.releaseSourceSettings !== prevState.releaseSourceSettings || state.collapsedSidebarCategoryCount !== prevState.collapsedSidebarCategoryCount; if (!changed) return; diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 1b9ca34..f524aa9 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -230,6 +230,46 @@ export class GitHubApiService { return allRepos; } + async getWatchedRepositories(page = 1, perPage = 100, username?: string): Promise { + const endpoint = username + ? `/users/${encodeURIComponent(username)}/subscriptions?page=${page}&per_page=${perPage}` + : `/user/subscriptions?page=${page}&per_page=${perPage}`; + return this.makeRequest(endpoint); + } + + async getAllWatchedRepositories(username?: string): Promise { + let allRepos: Repository[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const repos = await this.getWatchedRepositories(page, perPage, username); + if (repos.length === 0) break; + + allRepos = [...allRepos, ...repos]; + + if (repos.length < perPage) break; + page++; + + await new Promise(resolve => setTimeout(resolve, 100)); + } + + return allRepos; + } + + async getAllWatchedRepositoriesForCurrentUser(): Promise { + const currentUser = await this.getCurrentUser(); + const [privateAware, publicProfile] = await Promise.all([ + this.getAllWatchedRepositories(), + this.getAllWatchedRepositories(currentUser.login), + ]); + const reposByName = new Map(); + [...privateAware, ...publicProfile].forEach(repo => { + reposByName.set(repo.full_name.toLowerCase(), repo); + }); + return Array.from(reposByName.values()); + } + private decodeContentResponse(response: GitHubContentResponse): string { if (response.encoding === 'base64' && response.content) { // 使用 TextDecoder 正确处理 UTF-8 编码,避免中文乱码 diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts index 7eb519d..da5dcbc 100644 --- a/src/store/useAppStore.test.ts +++ b/src/store/useAppStore.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Repository } from '../types'; +import { Repository, defaultReleaseSourceSettings } from '../types'; +import { CUSTOM_RELEASE_SOURCE_ID, createCustomReleaseRepository } from '../utils/releaseSources'; let useAppStore: typeof import('./useAppStore').useAppStore; @@ -31,6 +32,40 @@ const createRepository = (id: number, overrides: Partial = {}): Repo ...overrides, }); +describe('useAppStore release source settings', () => { + beforeEach(() => { + useAppStore.setState({ + releaseSourceSettings: defaultReleaseSourceSettings, + releaseSubscriptions: new Set(), + releases: [], + readReleases: new Set(), + }); + }); + + it('keeps the starred release subscription source enabled by default', () => { + expect(useAppStore.getState().releaseSourceSettings.enabledSourceIds).toEqual(['starred-release-subscription']); + }); + + it('dedupes custom release repositories by full name', () => { + const first = createCustomReleaseRepository('owner/repo', CUSTOM_RELEASE_SOURCE_ID)!; + const duplicate = createCustomReleaseRepository('https://github.com/OWNER/repo', CUSTOM_RELEASE_SOURCE_ID)!; + + useAppStore.getState().addReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, first); + useAppStore.getState().addReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, duplicate); + + expect(useAppStore.getState().releaseSourceSettings.customReleaseRepos).toHaveLength(1); + }); + + it('removes custom release repositories by full name', () => { + const repo = createCustomReleaseRepository('owner/repo', CUSTOM_RELEASE_SOURCE_ID)!; + + useAppStore.getState().addReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, repo); + useAppStore.getState().removeReleaseSourceRepository(CUSTOM_RELEASE_SOURCE_ID, 'OWNER/repo'); + + expect(useAppStore.getState().releaseSourceSettings.customReleaseRepos).toHaveLength(0); + }); +}); + describe('useAppStore repository performance guards', () => { beforeEach(() => { useAppStore.setState({ diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 71fbb8f..cf89d61 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -25,9 +25,18 @@ import { TrendingTimeRange, TopicCategory, SubscriptionChannel, - defaultSubscriptionChannels + CustomReleaseRepository, + ReleaseSourceId, + ReleaseSourceSettings, + defaultSubscriptionChannels, + defaultReleaseSourceSettings } from '../types'; import { indexedDBStorage } from '../services/indexedDbStorage'; +import { + WATCH_CUSTOM_RELEASE_SOURCE_ID, + normalizeReleaseSourceSettings, + normalizeRepoKey, +} from '../utils/releaseSources'; import { logger } from '../services/logger'; import { PRESET_FILTERS } from '../constants/presetFilters'; @@ -269,8 +278,16 @@ interface AppActions { toggleReleaseSubscription: (repoId: number) => void; batchUnsubscribeReleases: (repoIds: number[]) => void; removeReleasesByRepoId: (repoId: number) => void; + removeReleasesByRepoFullName: (fullName: string) => void; markReleaseAsRead: (releaseId: number) => void; markAllReleasesAsRead: () => void; + setReleaseSourceSettings: (settings: ReleaseSourceSettings) => void; + setReleaseEnabledSources: (sourceIds: ReleaseSourceId[]) => void; + toggleReleaseSource: (sourceId: ReleaseSourceId) => void; + setReleaseSourceRepositories: (sourceId: ReleaseSourceId, repos: CustomReleaseRepository[]) => void; + addReleaseSourceRepository: (sourceId: ReleaseSourceId, repo: CustomReleaseRepository) => void; + removeReleaseSourceRepository: (sourceId: ReleaseSourceId, fullName: string) => void; + updateReleaseSourceRepository: (sourceId: ReleaseSourceId, fullName: string, updates: Partial) => void; // Fork actions setForks: (forks: ForkRepo[]) => void; @@ -401,6 +418,7 @@ type PersistedAppState = Partial< | 'activeWebDAVConfig' | 'lastBackup' | 'releases' + | 'releaseSourceSettings' | 'customCategories' | 'hiddenDefaultCategoryIds' | 'defaultCategoryOverrides' @@ -506,6 +524,7 @@ const normalizePersistedState = ( releases, searchResults: migratedRepositories, releaseSubscriptions: normalizeNumberSet(safePersisted.releaseSubscriptions), + releaseSourceSettings: normalizeReleaseSourceSettings(safePersisted.releaseSourceSettings), readReleases: normalizeNumberSet(safePersisted.readReleases), readForks: normalizeNumberSet(safePersisted.readForks), forks: Array.isArray(safePersisted.forks) ? safePersisted.forks : [], @@ -893,6 +912,7 @@ export const useAppStore = create()( searchResults: [], releases: [], releaseSubscriptions: new Set(), + releaseSourceSettings: defaultReleaseSourceSettings, readReleases: new Set(), customCategories: [], hiddenDefaultCategoryIds: [], @@ -970,6 +990,7 @@ export const useAppStore = create()( repositories: [], releases: [], releaseSubscriptions: new Set(), + releaseSourceSettings: defaultReleaseSourceSettings, readReleases: new Set(), forks: [], readForks: new Set(), @@ -1157,6 +1178,94 @@ export const useAppStore = create()( releaseExpandedRepositories: nextExpandedRepos, }; }), + removeReleasesByRepoFullName: (fullName) => set((state) => { + const targetKey = normalizeRepoKey(fullName); + const filteredReleases = state.releases.filter(release => normalizeRepoKey(release.repository.full_name) !== targetKey); + const remainingReleaseIds = new Set(filteredReleases.map(r => r.id)); + const removedRepoIds = new Set( + state.releases + .filter(release => normalizeRepoKey(release.repository.full_name) === targetKey) + .map(release => release.repository.id) + ); + const nextExpandedRepos = new Set(state.releaseExpandedRepositories); + removedRepoIds.forEach(repoId => nextExpandedRepos.delete(repoId)); + return { + releases: filteredReleases, + readReleases: new Set(Array.from(state.readReleases).filter(releaseId => remainingReleaseIds.has(releaseId))), + releaseExpandedRepositories: nextExpandedRepos, + }; + }), + setReleaseSourceSettings: (settings) => set({ releaseSourceSettings: normalizeReleaseSourceSettings(settings) }), + setReleaseEnabledSources: (sourceIds) => set((state) => ({ + releaseSourceSettings: normalizeReleaseSourceSettings({ + ...state.releaseSourceSettings, + enabledSourceIds: sourceIds, + }), + })), + toggleReleaseSource: (sourceId) => set((state) => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const enabled = new Set(settings.enabledSourceIds); + if (enabled.has(sourceId)) { + if (enabled.size === 1) return state; + enabled.delete(sourceId); + } else { + enabled.add(sourceId); + } + return { + releaseSourceSettings: { + ...settings, + enabledSourceIds: Array.from(enabled), + }, + }; + }), + setReleaseSourceRepositories: (sourceId, repos) => set((state) => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const listKey = sourceId === WATCH_CUSTOM_RELEASE_SOURCE_ID ? 'watchCustomReleaseRepos' : 'customReleaseRepos'; + return { + releaseSourceSettings: normalizeReleaseSourceSettings({ + ...settings, + [listKey]: repos, + }), + }; + }), + addReleaseSourceRepository: (sourceId, repo) => set((state) => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const listKey = sourceId === WATCH_CUSTOM_RELEASE_SOURCE_ID ? 'watchCustomReleaseRepos' : 'customReleaseRepos'; + const repoKey = normalizeRepoKey(repo.full_name); + if (settings[listKey].some(item => normalizeRepoKey(item.full_name) === repoKey)) { + return state; + } + return { + releaseSourceSettings: { + ...settings, + [listKey]: [...settings[listKey], repo], + }, + }; + }), + removeReleaseSourceRepository: (sourceId, fullName) => set((state) => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const listKey = sourceId === WATCH_CUSTOM_RELEASE_SOURCE_ID ? 'watchCustomReleaseRepos' : 'customReleaseRepos'; + const repoKey = normalizeRepoKey(fullName); + return { + releaseSourceSettings: { + ...settings, + [listKey]: settings[listKey].filter(repo => normalizeRepoKey(repo.full_name) !== repoKey), + }, + }; + }), + updateReleaseSourceRepository: (sourceId, fullName, updates) => set((state) => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const listKey = sourceId === WATCH_CUSTOM_RELEASE_SOURCE_ID ? 'watchCustomReleaseRepos' : 'customReleaseRepos'; + const repoKey = normalizeRepoKey(fullName); + return { + releaseSourceSettings: { + ...settings, + [listKey]: settings[listKey].map(repo => + normalizeRepoKey(repo.full_name) === repoKey ? { ...repo, ...updates } : repo + ), + }, + }; + }), markReleaseAsRead: (releaseId) => set((state) => { const newReadReleases = new Set(state.readReleases); newReadReleases.add(releaseId); @@ -1612,7 +1721,7 @@ export const useAppStore = create()( }), { name: 'github-stars-manager', - version: 7, + version: 8, storage: debouncedPersistStorage, partialize: (state) => ({ // 持久化用户信息和认证状态 @@ -1633,8 +1742,9 @@ export const useAppStore = create()( activeWebDAVConfig: state.activeWebDAVConfig, lastBackup: state.lastBackup, - // 持久化Release订阅和已读状态 + // 持久化Release订阅、来源和已读状态 releaseSubscriptions: Array.from(state.releaseSubscriptions), + releaseSourceSettings: state.releaseSourceSettings, readReleases: Array.from(state.readReleases), releases: state.releases, @@ -1720,6 +1830,13 @@ export const useAppStore = create()( // 版本升级适配处理 const state = persistedState as PersistedAppState | undefined; + if (state && !state.releaseSourceSettings) { + console.log('Migrating from old version: initializing releaseSourceSettings'); + state.releaseSourceSettings = defaultReleaseSourceSettings; + } else if (state) { + state.releaseSourceSettings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + } + // 从旧版本升级时,确保 categoryOrder 字段存在 if (state && !Array.isArray(state.categoryOrder)) { console.log('Migrating from old version: initializing categoryOrder'); diff --git a/src/types/index.ts b/src/types/index.ts index 3fce23a..d985ab7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -63,6 +63,35 @@ export interface Release { is_read?: boolean; } +export type ReleaseSourceId = 'starred-release-subscription' | 'watch-custom-release' | 'custom-release'; + +export interface CustomReleaseRepository { + id: number; + name: string; + full_name: string; + html_url: string; + owner: { + login: string; + avatar_url: string; + }; + has_fetched_releases?: boolean; + last_release_fetch_time?: string; + source_added_at?: string; + release_hidden?: boolean; +} + +export interface ReleaseSourceSettings { + enabledSourceIds: ReleaseSourceId[]; + watchCustomReleaseRepos: CustomReleaseRepository[]; + customReleaseRepos: CustomReleaseRepository[]; +} + +export const defaultReleaseSourceSettings: ReleaseSourceSettings = { + enabledSourceIds: ['starred-release-subscription'], + watchCustomReleaseRepos: [], + customReleaseRepos: [], +}; + // Fork types export interface GitHubOrganization { id: number; @@ -247,6 +276,7 @@ export interface AppState { // Releases releases: Release[]; releaseSubscriptions: Set; + releaseSourceSettings: ReleaseSourceSettings; readReleases: Set; // 新增:已读Release // Categories diff --git a/src/utils/releaseSources.test.ts b/src/utils/releaseSources.test.ts new file mode 100644 index 0000000..3981183 --- /dev/null +++ b/src/utils/releaseSources.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; +import type { AppState, Repository } from '../types'; +import { defaultReleaseSourceSettings } from '../types'; +import { + CUSTOM_RELEASE_SOURCE_ID, + STARRED_RELEASE_SOURCE_ID, + WATCH_CUSTOM_RELEASE_SOURCE_ID, + createCustomReleaseRepository, + getSourcesForReleaseRepository, + normalizeGitHubRepoInput, + resolveReleaseSources, +} from './releaseSources'; + +const createRepository = (id: number, fullName: string): Repository => { + const [owner, name] = fullName.split('/'); + return { + id, + name, + full_name: fullName, + description: null, + html_url: `https://github.com/${fullName}`, + stargazers_count: 1, + forks_count: 0, + forks: 0, + language: 'TypeScript', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + pushed_at: '2026-01-01T00:00:00.000Z', + owner: { login: owner, avatar_url: `https://github.com/${owner}.png` }, + topics: [], + }; +}; + +const createState = (overrides: Partial): Pick => ({ + repositories: [], + releaseSubscriptions: new Set(), + releaseSourceSettings: defaultReleaseSourceSettings, + ...overrides, +}); + +describe('releaseSources utilities', () => { + it('normalizes GitHub repo inputs', () => { + expect(normalizeGitHubRepoInput('owner/repo')).toMatchObject({ full_name: 'owner/repo' }); + expect(normalizeGitHubRepoInput('github.com/owner/repo')).toMatchObject({ full_name: 'owner/repo' }); + expect(normalizeGitHubRepoInput('https://github.com/owner/repo/')).toMatchObject({ full_name: 'owner/repo' }); + expect(normalizeGitHubRepoInput('https://example.com/owner/repo')).toBeNull(); + expect(normalizeGitHubRepoInput('owner')).toBeNull(); + }); + + it('dedupes the same repository across selected sources', () => { + const starred = createRepository(1, 'owner/repo'); + const watchRepo = createCustomReleaseRepository('OWNER/repo', WATCH_CUSTOM_RELEASE_SOURCE_ID)!; + const customRepo = createCustomReleaseRepository('owner/repo', CUSTOM_RELEASE_SOURCE_ID)!; + + const resolved = resolveReleaseSources(createState({ + repositories: [starred], + releaseSubscriptions: new Set([1]), + releaseSourceSettings: { + enabledSourceIds: [STARRED_RELEASE_SOURCE_ID, WATCH_CUSTOM_RELEASE_SOURCE_ID, CUSTOM_RELEASE_SOURCE_ID], + watchCustomReleaseRepos: [watchRepo], + customReleaseRepos: [customRepo], + }, + })); + + expect(resolved.repositories).toHaveLength(1); + expect(resolved.entries[0].sources).toEqual([ + STARRED_RELEASE_SOURCE_ID, + WATCH_CUSTOM_RELEASE_SOURCE_ID, + CUSTOM_RELEASE_SOURCE_ID, + ]); + }); + + it('skips hidden watch-custom-release repositories during source resolution', () => { + const watchRepo = { + ...createCustomReleaseRepository('owner/repo', WATCH_CUSTOM_RELEASE_SOURCE_ID)!, + release_hidden: true, + }; + + const resolved = resolveReleaseSources(createState({ + releaseSourceSettings: { + enabledSourceIds: [WATCH_CUSTOM_RELEASE_SOURCE_ID], + watchCustomReleaseRepos: [watchRepo], + customReleaseRepos: [], + }, + })); + + expect(resolved.repositories).toHaveLength(0); + }); + + it('reports source memberships for unsubscribe prompts', () => { + const starred = createRepository(1, 'owner/repo'); + const customRepo = createCustomReleaseRepository('owner/repo', CUSTOM_RELEASE_SOURCE_ID)!; + + const sources = getSourcesForReleaseRepository(createState({ + repositories: [starred], + releaseSubscriptions: new Set([1]), + releaseSourceSettings: { + enabledSourceIds: [STARRED_RELEASE_SOURCE_ID, CUSTOM_RELEASE_SOURCE_ID], + watchCustomReleaseRepos: [], + customReleaseRepos: [customRepo], + }, + }), { + id: 1, + name: 'repo', + full_name: 'owner/repo', + }); + + expect(sources).toEqual([STARRED_RELEASE_SOURCE_ID, CUSTOM_RELEASE_SOURCE_ID]); + }); + + it('reports disabled source memberships so unsubscribe removes hidden entries too', () => { + const starred = createRepository(1, 'owner/repo'); + const customRepo = createCustomReleaseRepository('owner/repo', CUSTOM_RELEASE_SOURCE_ID)!; + + const sources = getSourcesForReleaseRepository(createState({ + repositories: [starred], + releaseSubscriptions: new Set([1]), + releaseSourceSettings: { + enabledSourceIds: [STARRED_RELEASE_SOURCE_ID], + watchCustomReleaseRepos: [], + customReleaseRepos: [customRepo], + }, + }), { + id: 1, + name: 'repo', + full_name: 'owner/repo', + }); + + expect(sources).toEqual([STARRED_RELEASE_SOURCE_ID, CUSTOM_RELEASE_SOURCE_ID]); + }); +}); diff --git a/src/utils/releaseSources.ts b/src/utils/releaseSources.ts new file mode 100644 index 0000000..4c9b960 --- /dev/null +++ b/src/utils/releaseSources.ts @@ -0,0 +1,332 @@ +import type { + AppState, + CustomReleaseRepository, + Release, + ReleaseSourceId, + ReleaseSourceSettings, + Repository, +} from '../types'; +import { defaultReleaseSourceSettings } from '../types'; + +export const STARRED_RELEASE_SOURCE_ID: ReleaseSourceId = 'starred-release-subscription'; +export const WATCH_CUSTOM_RELEASE_SOURCE_ID: ReleaseSourceId = 'watch-custom-release'; +export const CUSTOM_RELEASE_SOURCE_ID: ReleaseSourceId = 'custom-release'; + +export const RELEASE_SOURCE_LABELS: Record = { + 'starred-release-subscription': { zh: '星标订阅', en: 'Starred subscriptions' }, + 'watch-custom-release': { zh: 'Watch 仓库', en: 'Watch repositories' }, + 'custom-release': { zh: '自定义订阅', en: 'Custom subscriptions' }, +}; + +const RELEASE_SOURCE_IDS: ReleaseSourceId[] = [ + STARRED_RELEASE_SOURCE_ID, + WATCH_CUSTOM_RELEASE_SOURCE_ID, + CUSTOM_RELEASE_SOURCE_ID, +]; + +export interface NormalizedGitHubRepoInput { + owner: string; + name: string; + full_name: string; + html_url: string; +} + +export interface ResolvedReleaseSourceRepository { + repository: Repository; + sources: ReleaseSourceId[]; +} + +export interface ResolvedReleaseSources { + repositories: Repository[]; + entries: ResolvedReleaseSourceRepository[]; + enabledSourceIds: ReleaseSourceId[]; +} + +export const normalizeRepoKey = (fullName: string): string => fullName.trim().toLowerCase(); + +export const isReleaseSourceId = (value: unknown): value is ReleaseSourceId => ( + typeof value === 'string' && RELEASE_SOURCE_IDS.includes(value as ReleaseSourceId) +); + +export const getReleaseSourceLabel = (sourceId: ReleaseSourceId, language: 'zh' | 'en'): string => { + const label = RELEASE_SOURCE_LABELS[sourceId]; + return language === 'zh' ? label.zh : label.en; +}; + +export const normalizeGitHubRepoInput = (input: string): NormalizedGitHubRepoInput | null => { + const trimmed = input.trim(); + if (!trimmed) return null; + + let candidate = trimmed.replace(/^github\.com\//i, ''); + try { + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://github.com/${candidate}`; + const url = new URL(withProtocol); + if (url.hostname.toLowerCase() !== 'github.com') return null; + candidate = url.pathname.replace(/^\/+|\/+$/g, ''); + } catch { + candidate = trimmed.replace(/^github\.com\//i, '').replace(/^\/+|\/+$/g, ''); + } + + const [owner, rawRepo, ...rest] = candidate.split('/'); + if (!owner || !rawRepo || rest.length > 0) return null; + + const name = rawRepo.replace(/\.git$/i, ''); + const ownerPattern = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; + const repoPattern = /^[A-Za-z0-9._-]+$/; + if (!ownerPattern.test(owner) || !repoPattern.test(name)) return null; + + return { + owner, + name, + full_name: `${owner}/${name}`, + html_url: `https://github.com/${owner}/${name}`, + }; +}; + +export const getLocalReleaseRepoId = (fullName: string, sourceId?: ReleaseSourceId): number => { + const input = `${sourceId || 'release-source'}:${normalizeRepoKey(fullName)}`; + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0; + } + return -Math.abs(hash || 1); +}; + +export const createCustomReleaseRepository = ( + input: string, + sourceId: ReleaseSourceId = CUSTOM_RELEASE_SOURCE_ID, + now = new Date().toISOString() +): CustomReleaseRepository | null => { + const normalized = normalizeGitHubRepoInput(input); + if (!normalized) return null; + + return { + id: getLocalReleaseRepoId(normalized.full_name, sourceId), + name: normalized.name, + full_name: normalized.full_name, + html_url: normalized.html_url, + owner: { + login: normalized.owner, + avatar_url: `https://github.com/${normalized.owner}.png`, + }, + source_added_at: now, + }; +}; + +export const repositoryToCustomReleaseRepository = ( + repository: Repository, + sourceId: ReleaseSourceId, + now = new Date().toISOString() +): CustomReleaseRepository => ({ + id: getLocalReleaseRepoId(repository.full_name, sourceId), + name: repository.name, + full_name: repository.full_name, + html_url: repository.html_url, + owner: repository.owner, + has_fetched_releases: repository.has_fetched_releases, + last_release_fetch_time: repository.last_release_fetch_time, + source_added_at: now, +}); + +export const customReleaseRepositoryToRepository = ( + repo: CustomReleaseRepository, + sourceId: ReleaseSourceId +): Repository => ({ + id: repo.id || getLocalReleaseRepoId(repo.full_name, sourceId), + name: repo.name, + full_name: repo.full_name, + description: null, + html_url: repo.html_url, + stargazers_count: 0, + forks_count: 0, + forks: 0, + language: null, + created_at: repo.source_added_at || new Date(0).toISOString(), + updated_at: repo.last_release_fetch_time || repo.source_added_at || new Date(0).toISOString(), + pushed_at: repo.last_release_fetch_time || repo.source_added_at || new Date(0).toISOString(), + owner: repo.owner, + topics: [], + has_fetched_releases: repo.has_fetched_releases, + last_release_fetch_time: repo.last_release_fetch_time, + subscribed_to_releases: true, +}); + +const normalizeCustomReleaseRepo = ( + value: unknown, + sourceId: ReleaseSourceId +): CustomReleaseRepository | null => { + if (!value || typeof value !== 'object') return null; + const record = value as Record; + const fullName = typeof record.full_name === 'string' ? record.full_name : ''; + const parsed = normalizeGitHubRepoInput(fullName); + if (!parsed) return null; + + const ownerRecord = record.owner && typeof record.owner === 'object' + ? record.owner as Record + : {}; + + return { + id: typeof record.id === 'number' && Number.isFinite(record.id) + ? record.id + : getLocalReleaseRepoId(parsed.full_name, sourceId), + name: typeof record.name === 'string' && record.name ? record.name : parsed.name, + full_name: parsed.full_name, + html_url: typeof record.html_url === 'string' && record.html_url ? record.html_url : parsed.html_url, + owner: { + login: typeof ownerRecord.login === 'string' && ownerRecord.login ? ownerRecord.login : parsed.owner, + avatar_url: typeof ownerRecord.avatar_url === 'string' && ownerRecord.avatar_url + ? ownerRecord.avatar_url + : `https://github.com/${parsed.owner}.png`, + }, + has_fetched_releases: typeof record.has_fetched_releases === 'boolean' ? record.has_fetched_releases : undefined, + last_release_fetch_time: typeof record.last_release_fetch_time === 'string' ? record.last_release_fetch_time : undefined, + source_added_at: typeof record.source_added_at === 'string' ? record.source_added_at : undefined, + release_hidden: typeof record.release_hidden === 'boolean' ? record.release_hidden : undefined, + }; +}; + +export const normalizeCustomReleaseRepositories = ( + value: unknown, + sourceId: ReleaseSourceId +): CustomReleaseRepository[] => { + if (!Array.isArray(value)) return []; + + const seen = new Set(); + const normalized: CustomReleaseRepository[] = []; + for (const item of value) { + const repo = normalizeCustomReleaseRepo(item, sourceId); + if (!repo) continue; + const key = normalizeRepoKey(repo.full_name); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(repo); + } + return normalized; +}; + +export const normalizeReleaseSourceSettings = (value: unknown): ReleaseSourceSettings => { + if (!value || typeof value !== 'object') return defaultReleaseSourceSettings; + const record = value as Record; + const enabledSourceIds = Array.isArray(record.enabledSourceIds) + ? record.enabledSourceIds.filter(isReleaseSourceId) + : defaultReleaseSourceSettings.enabledSourceIds; + + return { + enabledSourceIds: enabledSourceIds.length > 0 + ? Array.from(new Set(enabledSourceIds)) + : defaultReleaseSourceSettings.enabledSourceIds, + watchCustomReleaseRepos: normalizeCustomReleaseRepositories( + record.watchCustomReleaseRepos, + WATCH_CUSTOM_RELEASE_SOURCE_ID + ), + customReleaseRepos: normalizeCustomReleaseRepositories( + record.customReleaseRepos, + CUSTOM_RELEASE_SOURCE_ID + ), + }; +}; + +export const mergeReleaseSourceSettings = ( + current: ReleaseSourceSettings, + incoming: ReleaseSourceSettings +): ReleaseSourceSettings => { + const mergeRepos = ( + a: CustomReleaseRepository[], + b: CustomReleaseRepository[] + ): CustomReleaseRepository[] => { + const merged = new Map(); + [...a, ...b].forEach(repo => { + const key = normalizeRepoKey(repo.full_name); + if (!merged.has(key)) merged.set(key, repo); + }); + return Array.from(merged.values()); + }; + + return normalizeReleaseSourceSettings({ + enabledSourceIds: Array.from(new Set([...current.enabledSourceIds, ...incoming.enabledSourceIds])), + watchCustomReleaseRepos: mergeRepos(current.watchCustomReleaseRepos, incoming.watchCustomReleaseRepos), + customReleaseRepos: mergeRepos(current.customReleaseRepos, incoming.customReleaseRepos), + }); +}; + +export const resolveReleaseSources = ( + state: Pick +): ResolvedReleaseSources => { + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const enabled = new Set(settings.enabledSourceIds); + const entriesByKey = new Map(); + + const addRepository = (repository: Repository, sourceId: ReleaseSourceId) => { + const key = normalizeRepoKey(repository.full_name); + const existing = entriesByKey.get(key); + if (existing) { + if (!existing.sources.includes(sourceId)) existing.sources.push(sourceId); + return; + } + entriesByKey.set(key, { repository, sources: [sourceId] }); + }; + + if (enabled.has(STARRED_RELEASE_SOURCE_ID)) { + state.repositories + .filter(repo => state.releaseSubscriptions.has(repo.id)) + .forEach(repo => addRepository(repo, STARRED_RELEASE_SOURCE_ID)); + } + + if (enabled.has(WATCH_CUSTOM_RELEASE_SOURCE_ID)) { + settings.watchCustomReleaseRepos + .filter(repo => !repo.release_hidden) + .forEach(repo => { + addRepository(customReleaseRepositoryToRepository(repo, WATCH_CUSTOM_RELEASE_SOURCE_ID), WATCH_CUSTOM_RELEASE_SOURCE_ID); + }); + } + + if (enabled.has(CUSTOM_RELEASE_SOURCE_ID)) { + settings.customReleaseRepos.forEach(repo => { + addRepository(customReleaseRepositoryToRepository(repo, CUSTOM_RELEASE_SOURCE_ID), CUSTOM_RELEASE_SOURCE_ID); + }); + } + + const entries = Array.from(entriesByKey.values()); + return { + entries, + repositories: entries.map(entry => entry.repository), + enabledSourceIds: settings.enabledSourceIds, + }; +}; + +export const getSourcesForReleaseRepository = ( + state: Pick, + repository: Release['repository'] +): ReleaseSourceId[] => { + const repoKey = normalizeRepoKey(repository.full_name); + const settings = normalizeReleaseSourceSettings(state.releaseSourceSettings); + const sources: ReleaseSourceId[] = []; + + if ( + state.repositories.some(repo => normalizeRepoKey(repo.full_name) === repoKey && state.releaseSubscriptions.has(repo.id)) + ) { + sources.push(STARRED_RELEASE_SOURCE_ID); + } + + if ( + settings.watchCustomReleaseRepos.some(repo => normalizeRepoKey(repo.full_name) === repoKey) + ) { + sources.push(WATCH_CUSTOM_RELEASE_SOURCE_ID); + } + + if ( + settings.customReleaseRepos.some(repo => normalizeRepoKey(repo.full_name) === repoKey) + ) { + sources.push(CUSTOM_RELEASE_SOURCE_ID); + } + + return sources; +}; + +export const releaseBelongsToResolvedSources = ( + release: Release, + resolved: ResolvedReleaseSources +): boolean => { + const repoKey = normalizeRepoKey(release.repository.full_name); + return resolved.entries.some(entry => normalizeRepoKey(entry.repository.full_name) === repoKey); +};