Skip to content

Commit 2a1b21d

Browse files
committed
feat(sync): auto-discover fixed gist for cloud sync
1 parent 5500835 commit 2a1b21d

File tree

5 files changed

+93
-16
lines changed

5 files changed

+93
-16
lines changed

app/src/lib/apps/SyncPanel.svelte

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,7 @@
212212
async function handleGistImportConfirm() {
213213
const token = requireGithubToken();
214214
if (!token) return;
215-
const gistId = get(githubGistId);
216-
if (!gistId) {
217-
gistStatus = t('panels.sync.statuses.gistIdRequired');
218-
return;
219-
}
215+
const gistId = get(githubGistId) ?? undefined;
220216
221217
try {
222218
confirmBusy = true;
@@ -225,7 +221,7 @@
225221
const { result, effectsDone } = dispatchTermActionWithEffects({
226222
type: 'SYNC_GIST_IMPORT_REPLACE',
227223
token,
228-
gistId
224+
gistId: gistId ?? undefined
229225
});
230226
const dispatchResult = await result;
231227
if (!dispatchResult.ok) {
@@ -242,6 +238,7 @@
242238
const details = last?.details as Record<string, unknown> | undefined;
243239
if (last?.id.startsWith('sync:import-ok:')) {
244240
gistStatus = t('panels.sync.statuses.gistImportSuccess');
241+
if (details && typeof details.gistId === 'string') setGithubGistId(details.gistId);
245242
confirmOpen = false;
246243
return;
247244
}

app/src/lib/data/github/gistSync.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,63 @@ export interface GistFileContent {
1313
content: string;
1414
}
1515

16+
type GistListItem = {
17+
id: string;
18+
html_url?: string;
19+
description?: string | null;
20+
updated_at?: string;
21+
files?: Record<string, { filename?: string } | undefined>;
22+
};
23+
24+
async function fetchJson<T>(url: string, token: string): Promise<T> {
25+
const response = await fetch(url, {
26+
headers: {
27+
Authorization: `token ${token}`,
28+
Accept: 'application/vnd.github+json'
29+
}
30+
});
31+
if (!response.ok) {
32+
const error = await response.text().catch(() => '');
33+
throw new Error(`GitHub API 请求失败: ${response.status} ${response.statusText} - ${error}`);
34+
}
35+
return (await response.json()) as T;
36+
}
37+
38+
export async function findLatestGistId(config: {
39+
token: string;
40+
filename: string;
41+
descriptionIncludes?: string;
42+
maxPages?: number;
43+
}): Promise<string | null> {
44+
const maxPages = Math.max(1, Math.min(10, config.maxPages ?? 5));
45+
const needle = String(config.descriptionIncludes || '').trim();
46+
47+
let best: { id: string; updatedAt: number } | null = null;
48+
for (let page = 1; page <= maxPages; page++) {
49+
const list = await fetchJson<GistListItem[]>(`${API_ROOT}/gists?per_page=100&page=${page}`, config.token);
50+
if (!Array.isArray(list) || list.length === 0) break;
51+
52+
for (const gist of list) {
53+
const id = String(gist?.id || '').trim();
54+
if (!id) continue;
55+
const files = gist?.files ?? {};
56+
if (!files || typeof files !== 'object') continue;
57+
if (!Object.prototype.hasOwnProperty.call(files, config.filename)) continue;
58+
59+
if (needle) {
60+
const desc = String(gist?.description || '');
61+
if (!desc.includes(needle)) continue;
62+
}
63+
64+
const updatedAt = gist?.updated_at ? Date.parse(gist.updated_at) : 0;
65+
const score = Number.isFinite(updatedAt) ? updatedAt : 0;
66+
if (!best || score > best.updatedAt) best = { id, updatedAt: score };
67+
}
68+
}
69+
70+
return best?.id ?? null;
71+
}
72+
1673
export async function syncGist(config: GistSyncConfig) {
1774
const body = {
1875
description: config.description ?? 'SHU Course Scheduler data',

app/src/lib/data/termState/reducer.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1909,14 +1909,15 @@ export async function reduceTermState(state: TermState, action: TermAction): Pro
19091909
};
19101910
}
19111911
case 'SYNC_GIST_IMPORT_REPLACE': {
1912+
const gistId = action.gistId;
19121913
const next = appendHistory(state, {
19131914
id: `sync:import:${state.history.entries.length}`,
19141915
at: nowEpochMs(),
19151916
type: 'sync',
19161917
label: '从 Gist 导入覆盖',
1917-
details: { gistId: action.gistId }
1918+
details: gistId ? { gistId } : undefined
19181919
});
1919-
return { state: next, effects: [{ type: 'EFF_GIST_GET', token: action.token, gistId: action.gistId }] };
1920+
return { state: next, effects: [{ type: 'EFF_GIST_GET', token: action.token, gistId: gistId ?? undefined }] };
19201921
}
19211922
case 'SYNC_GIST_EXPORT_OK': {
19221923
const next = appendHistory(state, {

app/src/lib/data/termState/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ export type TermAction =
272272
| { type: 'SOLVER_RUN_ERR'; error: string }
273273
| { type: 'SOLVER_APPLY_RESULT'; resultId: string; mode: 'merge' | 'replace-all' }
274274
| { type: 'SOLVER_UNDO_LAST_APPLY' }
275-
| { type: 'SYNC_GIST_IMPORT_REPLACE'; token: string; gistId: string }
275+
| { type: 'SYNC_GIST_IMPORT_REPLACE'; token: string; gistId?: string }
276276
| { type: 'SYNC_GIST_EXPORT'; token: string; gistId?: string; note?: string; public?: boolean }
277277
| { type: 'SYNC_GIST_EXPORT_OK'; gistId: string; url: string }
278278
| { type: 'SYNC_GIST_EXPORT_ERR'; error: string }
@@ -289,7 +289,7 @@ export type TermEffect =
289289
| { type: 'EFF_AUTO_SOLVE_RUN'; mode: 'merge' | 'replace-all'; runId: string }
290290
| { type: 'EFF_AUTO_SOLVE_EXIT_EXPORT'; mode: 'merge' | 'replace-all'; runId: string }
291291
| { type: 'EFF_DATASET_REFRESH' }
292-
| { type: 'EFF_GIST_GET'; token: string; gistId: string }
292+
| { type: 'EFF_GIST_GET'; token: string; gistId?: string }
293293
| { type: 'EFF_GIST_PUT'; token: string; gistId?: string; note?: string; public?: boolean; payloadBase64: string };
294294

295295
export function assertNever(value: never, message = 'Unexpected value'): never {

app/src/lib/stores/termStateStore.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '../data/jwxt/jwxtApi';
2323
import { digestToMd5LikeHex } from '../data/termState/digest';
2424
import { courseCatalogMap, courseDataset } from '../data/catalog/courseCatalog';
25-
import { getGistFileContent, syncGist } from '../data/github/gistSync';
25+
import { findLatestGistId, getGistFileContent, syncGist } from '../data/github/gistSync';
2626
import { assertNever } from '../data/termState/types';
2727
import { deriveGroupKey } from '../data/termState/groupKey';
2828
import { collapseCoursesByName } from './courseDisplaySettings';
@@ -190,6 +190,7 @@ export function clearTermStateAlert() {
190190
}
191191

192192
const GIST_BUNDLE_FILENAME = 'term-state.json';
193+
const GIST_BUNDLE_DESCRIPTION = 'NeoSHUSchedulingHelper TermState Bundle';
193194
const GistBundleSchema = z.object({
194195
updatedAt: z.number(),
195196
payloadBase64: z.string().min(1)
@@ -1241,11 +1242,21 @@ async function runEffect(effect: TermEffect) {
12411242
try {
12421243
const updatedAt = Date.now();
12431244
const payload = JSON.stringify({ updatedAt, payloadBase64: effect.payloadBase64 } satisfies z.infer<typeof GistBundleSchema>);
1245+
1246+
// Auto-discover the existing cloud backup gist (prevents creating many same-name gists).
1247+
let gistId: string | undefined = effect.gistId ?? undefined;
1248+
if (!gistId) {
1249+
gistId = (await findLatestGistId({
1250+
token: effect.token,
1251+
filename: GIST_BUNDLE_FILENAME,
1252+
descriptionIncludes: GIST_BUNDLE_DESCRIPTION
1253+
})) ?? undefined;
1254+
}
12441255
const result = await syncGist({
12451256
token: effect.token,
1246-
gistId: effect.gistId,
1257+
gistId: gistId ?? undefined,
12471258
public: false,
1248-
description: 'SHU Course Scheduler TermState Bundle',
1259+
description: GIST_BUNDLE_DESCRIPTION,
12491260
files: {
12501261
[GIST_BUNDLE_FILENAME]: payload
12511262
}
@@ -1261,9 +1272,19 @@ async function runEffect(effect: TermEffect) {
12611272
}
12621273
case 'EFF_GIST_GET': {
12631274
try {
1275+
// Auto-discover cloud backup gist if not pinned locally yet.
1276+
let gistId: string | undefined = effect.gistId ?? undefined;
1277+
if (!gistId) {
1278+
gistId = (await findLatestGistId({
1279+
token: effect.token,
1280+
filename: GIST_BUNDLE_FILENAME,
1281+
descriptionIncludes: GIST_BUNDLE_DESCRIPTION
1282+
})) ?? undefined;
1283+
}
1284+
if (!gistId) throw new Error('No cloud backup yet');
12641285
const file = await getGistFileContent({
12651286
token: effect.token,
1266-
gistId: effect.gistId,
1287+
gistId,
12671288
filename: GIST_BUNDLE_FILENAME
12681289
});
12691290
const raw = JSON.parse(file.content.trim()) as unknown;
@@ -1278,14 +1299,15 @@ async function runEffect(effect: TermEffect) {
12781299

12791300
await dispatchTermAction({
12801301
type: 'SYNC_GIST_IMPORT_OK',
1281-
gistId: effect.gistId,
1302+
gistId,
12821303
state: bundle.termState,
12831304
generatedAt: bundle.generatedAt
12841305
});
12851306
} catch (error) {
1307+
const fallbackGistId = effect.gistId ?? 'auto';
12861308
await dispatchTermAction({
12871309
type: 'SYNC_GIST_IMPORT_ERR',
1288-
gistId: effect.gistId,
1310+
gistId: fallbackGistId,
12891311
error: error instanceof Error ? error.message : String(error)
12901312
});
12911313
}

0 commit comments

Comments
 (0)