Skip to content

Commit 8469716

Browse files
committed
refactor: introduce modular storage adapters for GitHub and Google Drive sync support
1 parent 9a53a43 commit 8469716

9 files changed

Lines changed: 834 additions & 183 deletions

File tree

.env.example

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Google Cloud Project Client ID for Google Drive App Data sync
2+
# Set this to your OAuth Client ID (Web Application type) to enable a global project.
3+
# This prevents users from having to supply their own Client ID, unless they want to self-host.
4+
#
5+
# --- Setup Guide ---
6+
# 1. Go to the Google Cloud Console (https://console.cloud.google.com/)
7+
# 2. Create a new Project.
8+
# 3. Navigate to APIs & Services > Library and enable the "Google Drive API".
9+
# 4. Navigate to APIs & Services > OAuth consent screen.
10+
# - Choose User Type (External).
11+
# - Fill out the App information.
12+
# - Add the scope: https://www.googleapis.com/auth/drive.appdata
13+
# 5. Navigate to APIs & Services > Credentials.
14+
# - Click Create Credentials > OAuth client ID.
15+
# - Application type: Web application.
16+
# - Add your local development URL (e.g., http://localhost:5173) and your production URL (e.g., https://username.github.io) to "Authorized JavaScript origins".
17+
# 6. Copy the generated Client ID and paste it below.
18+
#
19+
VITE_GOOGLE_CLIENT_ID=

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- name: build
3333
env:
3434
BASE_PATH: '/${{ github.event.repository.name }}'
35+
VITE_GOOGLE_CLIENT_ID: ${{ secrets.VITE_GOOGLE_CLIENT_ID }}
3536
run: |
3637
npm run build
3738

src/app.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
1414
<meta name="theme-color" content="#3b82f6" />
1515
%sveltekit.head%
16+
<script src="https://accounts.google.com/gsi/client" async defer></script>
1617
</head>
1718
<body data-sveltekit-preload-data="hover">
1819
<div style="display: contents">%sveltekit.body%</div>

src/lib/stores/auth.svelte.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,57 @@
11
import { browser } from '$app/environment';
22

3+
export type ActiveProvider = 'github' | 'gdrive' | 'none';
4+
35
class AuthStore {
4-
token = $state(browser ? localStorage.getItem('gh_pat') || '' : '');
5-
gistId = $state(browser ? localStorage.getItem('gh_gist_id') || '' : '');
6+
activeProvider = $state<ActiveProvider>(
7+
browser ? (localStorage.getItem('cp_active_provider') as ActiveProvider) || 'none' : 'none'
8+
);
9+
10+
github = $state({
11+
token: browser ? localStorage.getItem('gh_pat') || '' : '',
12+
gistId: browser ? localStorage.getItem('gh_gist_id') || '' : ''
13+
});
614

7-
save(token: string, gistId: string) {
8-
this.token = token;
9-
this.gistId = gistId;
15+
gdrive = $state({
16+
clientId: browser ? localStorage.getItem('gd_client_id') || import.meta.env.VITE_GOOGLE_CLIENT_ID || '' : '',
17+
accessToken: browser ? localStorage.getItem('gd_access_token') || '' : '',
18+
fileId: browser ? localStorage.getItem('gd_file_id') || '' : ''
19+
});
20+
21+
save() {
1022
if (browser) {
11-
localStorage.setItem('gh_pat', token);
12-
localStorage.setItem('gh_gist_id', gistId);
23+
localStorage.setItem('cp_active_provider', this.activeProvider);
24+
25+
// Github
26+
localStorage.setItem('gh_pat', this.github.token);
27+
localStorage.setItem('gh_gist_id', this.github.gistId);
28+
29+
// GDrive
30+
localStorage.setItem('gd_client_id', this.gdrive.clientId);
31+
localStorage.setItem('gd_access_token', this.gdrive.accessToken);
32+
localStorage.setItem('gd_file_id', this.gdrive.fileId);
1333
}
1434
}
1535

16-
clear() {
17-
this.token = '';
18-
this.gistId = '';
19-
if (browser) {
20-
localStorage.removeItem('gh_pat');
21-
localStorage.removeItem('gh_gist_id');
36+
clearProvider(provider: ActiveProvider) {
37+
if (provider === 'github') {
38+
this.github.token = '';
39+
this.github.gistId = '';
40+
} else if (provider === 'gdrive') {
41+
this.gdrive.accessToken = '';
42+
this.gdrive.fileId = '';
2243
}
44+
this.save();
2345
}
2446

2547
get isValid() {
26-
return this.token.length > 0 && this.gistId.length > 0;
48+
if (this.activeProvider === 'github') {
49+
return this.github.token.length > 0 && this.github.gistId.length > 0;
50+
}
51+
if (this.activeProvider === 'gdrive') {
52+
return this.gdrive.accessToken.length > 0; // FileId can be empty initially
53+
}
54+
return false;
2755
}
2856
}
2957

src/lib/stores/db.svelte.ts

Lines changed: 42 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { browser } from '$app/environment';
22
import { authStore } from './auth.svelte';
3-
3+
import { GithubProvider } from './providers/github';
4+
import { GDriveProvider } from './providers/gdrive';
5+
import type { StorageAdapter } from './providers/types';
46
export interface Language {
57
id: string;
68
name: string;
@@ -120,6 +122,12 @@ class DbStore {
120122
}
121123
}
122124

125+
private getAdapter(): StorageAdapter | null {
126+
if (authStore.activeProvider === 'github') return new GithubProvider();
127+
if (authStore.activeProvider === 'gdrive') return new GDriveProvider();
128+
return null;
129+
}
130+
123131
async sync() {
124132
if (!authStore.isValid) {
125133
this.syncStatus = 'Offline/No Auth';
@@ -130,54 +138,32 @@ class DbStore {
130138
return;
131139
}
132140

141+
const adapter = this.getAdapter();
142+
if (!adapter) {
143+
this.syncStatus = 'Local Only';
144+
return;
145+
}
146+
133147
this.isLoading = true;
134148
this.syncStatus = 'Syncing...';
135149
this.error = null;
150+
136151
try {
137-
const res = await fetch(`https://api.github.com/gists/${authStore.gistId}`, {
138-
headers: {
139-
Authorization: `Bearer ${authStore.token}`,
140-
Accept: 'application/vnd.github.v3+json'
141-
}
142-
});
143-
if (!res.ok) throw new Error('Failed to fetch gist');
144-
const gist = await res.json();
145-
const file = gist.files['copypasta.json'];
146-
if (file && file.content) {
147-
const remoteData = JSON.parse(file.content) as Database;
148-
if (remoteData?.settings?.languages) {
149-
remoteData.settings.languages.forEach((l: Language) => {
150-
if (l.showInMultiple === undefined) {
151-
l.showInMultiple = l.id === 'en' || l.id === 'de';
152-
}
153-
});
154-
}
155-
const localDate = new Date(this.data.updatedAt || 0).getTime();
156-
const remoteDate = new Date(remoteData.updatedAt || 0).getTime();
157-
const lastSynced = this.getLastSyncedAt();
158-
159-
if (!remoteData.updatedAt) {
160-
await this._pushToGist();
161-
} else {
162-
const isRemoteNewer = remoteDate > lastSynced;
163-
const isLocalNewer = localDate > lastSynced;
164-
165-
if (isRemoteNewer && isLocalNewer && localDate !== remoteDate) {
166-
this.conflictData = remoteData;
167-
this.syncStatus = 'Error';
168-
this.isLoading = false;
169-
return;
170-
} else if (isRemoteNewer) {
171-
this.forcePull(remoteData);
172-
} else if (isLocalNewer) {
173-
await this._pushToGist();
174-
} else {
175-
this.syncStatus = 'Synced';
176-
}
177-
}
152+
const result = await adapter.sync(this.data, this.getLastSyncedAt());
153+
154+
if (result.action === 'error') {
155+
this.error = result.error || 'Unknown sync error';
156+
this.syncStatus = 'Error';
157+
} else if (result.action === 'conflict') {
158+
this.conflictData = result.remoteData || null;
159+
this.syncStatus = 'Error';
160+
} else if (result.action === 'pulled' && result.remoteData) {
161+
this.forcePull(result.remoteData);
162+
} else if (result.action === 'pushed') {
163+
this.setLastSyncedAt(result.remoteDate || new Date(this.data.updatedAt).getTime());
164+
this.syncStatus = 'Synced';
178165
} else {
179-
// Initialize gist if empty
180-
await this._pushToGist();
166+
this.syncStatus = 'Synced';
181167
}
182168
} catch (err) {
183169
const e = err as Error;
@@ -206,13 +192,13 @@ class DbStore {
206192

207193
this._saveTimeout = setTimeout(() => {
208194
this._saveTimeout = null;
209-
this._pushToGist();
195+
this._pushToRemote();
210196
}, 1000);
211197
}
212198

213199
async forcePush() {
214200
this.conflictData = null;
215-
await this._pushToGist();
201+
await this._pushToRemote();
216202
}
217203

218204
forcePull(remoteData: Database) {
@@ -230,7 +216,7 @@ class DbStore {
230216
this.syncStatus = 'Synced';
231217
}
232218

233-
private async _pushToGist() {
219+
private async _pushToRemote() {
234220
if (!authStore.isValid) {
235221
this.syncStatus = 'Local Only';
236222
return;
@@ -240,27 +226,19 @@ class DbStore {
240226
return;
241227
}
242228

229+
const adapter = this.getAdapter();
230+
if (!adapter) {
231+
this.syncStatus = 'Local Only';
232+
return;
233+
}
234+
243235
this.isLoading = true;
244236
this.syncStatus = 'Syncing...';
245237
this.error = null;
238+
246239
try {
247-
const res = await fetch(`https://api.github.com/gists/${authStore.gistId}`, {
248-
method: 'PATCH',
249-
headers: {
250-
Authorization: `Bearer ${authStore.token}`,
251-
Accept: 'application/vnd.github.v3+json',
252-
'Content-Type': 'application/json'
253-
},
254-
body: JSON.stringify({
255-
files: {
256-
'copypasta.json': {
257-
content: JSON.stringify(this.data, null, 2)
258-
}
259-
}
260-
})
261-
});
262-
if (!res.ok) throw new Error('Failed to update gist');
263-
this.setLastSyncedAt(new Date(this.data.updatedAt).getTime());
240+
const result = await adapter.push(this.data);
241+
this.setLastSyncedAt(result.remoteDate);
264242
this.syncStatus = 'Synced';
265243
} catch (err) {
266244
const e = err as Error;

0 commit comments

Comments
 (0)