skillet-shared 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cloud.d.ts +50 -0
- package/dist/cloud.js +140 -0
- package/dist/saved-list.d.ts +30 -0
- package/dist/saved-list.js +109 -0
- package/package.json +13 -3
package/dist/cloud.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { RecentSession, SavedItem } from './saved-list.ts';
|
|
2
|
+
import type { FirebaseApp, FirebaseOptions } from 'firebase/app';
|
|
3
|
+
import type { Auth } from 'firebase/auth';
|
|
4
|
+
import type { Firestore } from 'firebase/firestore/lite';
|
|
5
|
+
export interface CloudUser {
|
|
6
|
+
uid: string;
|
|
7
|
+
name: string | null;
|
|
8
|
+
photo: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface HomeDoc {
|
|
11
|
+
items: SavedItem[];
|
|
12
|
+
recent: RecentSession[];
|
|
13
|
+
}
|
|
14
|
+
export interface Sdk {
|
|
15
|
+
app: FirebaseApp;
|
|
16
|
+
auth: Auth;
|
|
17
|
+
db: Firestore;
|
|
18
|
+
fs: typeof import('firebase/firestore/lite');
|
|
19
|
+
authMod: typeof import('firebase/auth');
|
|
20
|
+
}
|
|
21
|
+
export interface CloudOptions {
|
|
22
|
+
config: FirebaseOptions;
|
|
23
|
+
collection: string;
|
|
24
|
+
listField: string;
|
|
25
|
+
signedInHint: string;
|
|
26
|
+
appCheckSiteKey: string;
|
|
27
|
+
recentMax: number;
|
|
28
|
+
storage: {
|
|
29
|
+
read: (key: string) => string | null;
|
|
30
|
+
write: (key: string, value: string) => void;
|
|
31
|
+
forget: (key: string) => void;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function createCloud(options: CloudOptions): {
|
|
35
|
+
wasSignedIn: () => boolean;
|
|
36
|
+
loadSdk: () => Promise<Sdk>;
|
|
37
|
+
userDoc: (uid: string) => Promise<{
|
|
38
|
+
db: Firestore;
|
|
39
|
+
fs: typeof import("firebase/firestore/lite");
|
|
40
|
+
ref: import("firebase/firestore/lite").DocumentReference<import("firebase/firestore/lite").DocumentData, import("firebase/firestore/lite").DocumentData>;
|
|
41
|
+
}>;
|
|
42
|
+
warmSignIn: () => void;
|
|
43
|
+
watchAuth: (onUser: (user: CloudUser | null) => void) => Promise<() => void>;
|
|
44
|
+
signIn: () => Promise<CloudUser>;
|
|
45
|
+
signOut: () => Promise<void>;
|
|
46
|
+
fetchHome: (uid: string) => Promise<HomeDoc>;
|
|
47
|
+
editItems: (uid: string, edit: (items: SavedItem[]) => SavedItem[]) => Promise<SavedItem[]>;
|
|
48
|
+
putSession: (uid: string, entry: RecentSession) => Promise<string[]>;
|
|
49
|
+
dropSession: (uid: string, id: string) => Promise<RecentSession[]>;
|
|
50
|
+
};
|
package/dist/cloud.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ITEM_MAX, pushRecent, readItems, readRecent, removeRecent, } from './saved-list.js';
|
|
2
|
+
const asCloudUser = (user) => ({
|
|
3
|
+
uid: user.uid,
|
|
4
|
+
name: user.displayName,
|
|
5
|
+
photo: user.photoURL,
|
|
6
|
+
});
|
|
7
|
+
const itemEntry = (item) => ({
|
|
8
|
+
name: item.name,
|
|
9
|
+
query: item.query,
|
|
10
|
+
...(item.id === undefined ? {} : { id: item.id }),
|
|
11
|
+
...(item.savedAt === undefined ? {} : { savedAt: item.savedAt }),
|
|
12
|
+
});
|
|
13
|
+
const sessionEntry = (s) => ({
|
|
14
|
+
id: s.id,
|
|
15
|
+
query: s.query,
|
|
16
|
+
at: s.at,
|
|
17
|
+
});
|
|
18
|
+
async function installAppCheck(app, siteKey) {
|
|
19
|
+
if (siteKey === '')
|
|
20
|
+
return;
|
|
21
|
+
const mod = await import('firebase/app-check');
|
|
22
|
+
mod.initializeAppCheck(app, {
|
|
23
|
+
provider: new mod.ReCaptchaEnterpriseProvider(siteKey),
|
|
24
|
+
isTokenAutoRefreshEnabled: true,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function createCloud(options) {
|
|
28
|
+
const { config, collection, listField, signedInHint, recentMax, storage } = options;
|
|
29
|
+
const wasSignedIn = () => storage.read(signedInHint) === '1';
|
|
30
|
+
let sdk = null;
|
|
31
|
+
function loadSdk() {
|
|
32
|
+
if (sdk !== null)
|
|
33
|
+
return sdk;
|
|
34
|
+
const load = (async () => {
|
|
35
|
+
const [appMod, authMod, fs] = await Promise.all([
|
|
36
|
+
import('firebase/app'),
|
|
37
|
+
import('firebase/auth'),
|
|
38
|
+
import('firebase/firestore/lite'),
|
|
39
|
+
]);
|
|
40
|
+
const app = appMod.initializeApp(config);
|
|
41
|
+
await installAppCheck(app, options.appCheckSiteKey);
|
|
42
|
+
return {
|
|
43
|
+
app,
|
|
44
|
+
auth: authMod.getAuth(app),
|
|
45
|
+
db: fs.getFirestore(app),
|
|
46
|
+
fs,
|
|
47
|
+
authMod,
|
|
48
|
+
};
|
|
49
|
+
})();
|
|
50
|
+
sdk = load;
|
|
51
|
+
load.catch(() => {
|
|
52
|
+
if (sdk === load)
|
|
53
|
+
sdk = null;
|
|
54
|
+
});
|
|
55
|
+
return load;
|
|
56
|
+
}
|
|
57
|
+
const warmSignIn = () => {
|
|
58
|
+
loadSdk().catch(() => undefined);
|
|
59
|
+
};
|
|
60
|
+
async function watchAuth(onUser) {
|
|
61
|
+
const { auth, authMod } = await loadSdk();
|
|
62
|
+
return authMod.onAuthStateChanged(auth, user => {
|
|
63
|
+
if (user === null)
|
|
64
|
+
storage.forget(signedInHint);
|
|
65
|
+
else
|
|
66
|
+
storage.write(signedInHint, '1');
|
|
67
|
+
onUser(user === null ? null : asCloudUser(user));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function signIn() {
|
|
71
|
+
const { auth, authMod } = await loadSdk();
|
|
72
|
+
const provider = new authMod.GoogleAuthProvider();
|
|
73
|
+
const result = await authMod.signInWithPopup(auth, provider);
|
|
74
|
+
storage.write(signedInHint, '1');
|
|
75
|
+
return asCloudUser(result.user);
|
|
76
|
+
}
|
|
77
|
+
async function signOut() {
|
|
78
|
+
const { auth } = await loadSdk();
|
|
79
|
+
storage.forget(signedInHint);
|
|
80
|
+
await auth.signOut();
|
|
81
|
+
}
|
|
82
|
+
async function userDoc(uid) {
|
|
83
|
+
const { db, fs } = await loadSdk();
|
|
84
|
+
return { db, fs, ref: fs.doc(db, collection, uid) };
|
|
85
|
+
}
|
|
86
|
+
async function fetchHome(uid) {
|
|
87
|
+
const { fs, ref } = await userDoc(uid);
|
|
88
|
+
const snap = await fs.getDoc(ref);
|
|
89
|
+
if (!snap.exists())
|
|
90
|
+
return { items: [], recent: [] };
|
|
91
|
+
const data = snap.data();
|
|
92
|
+
return {
|
|
93
|
+
items: readItems(data[listField]),
|
|
94
|
+
recent: readRecent(data.recent, recentMax, data.current),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function editItems(uid, edit) {
|
|
98
|
+
const { db, fs, ref } = await userDoc(uid);
|
|
99
|
+
return fs.runTransaction(db, async (tx) => {
|
|
100
|
+
const snap = await tx.get(ref);
|
|
101
|
+
const stored = snap.exists() ? readItems(snap.data()[listField]) : [];
|
|
102
|
+
const next = edit(stored).slice(0, ITEM_MAX);
|
|
103
|
+
tx.set(ref, { [listField]: next.map(itemEntry) }, { merge: true });
|
|
104
|
+
return next;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
async function putSession(uid, entry) {
|
|
108
|
+
const { db, fs, ref } = await userDoc(uid);
|
|
109
|
+
return fs.runTransaction(db, async (tx) => {
|
|
110
|
+
const snap = await tx.get(ref);
|
|
111
|
+
const data = snap.exists() ? snap.data() : undefined;
|
|
112
|
+
const { recent, dropped } = pushRecent(readRecent(data?.recent, recentMax, data?.current), entry, recentMax);
|
|
113
|
+
tx.set(ref, { recent: recent.map(sessionEntry), current: fs.deleteField() }, { merge: true });
|
|
114
|
+
return dropped;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async function dropSession(uid, id) {
|
|
118
|
+
const { db, fs, ref } = await userDoc(uid);
|
|
119
|
+
return fs.runTransaction(db, async (tx) => {
|
|
120
|
+
const snap = await tx.get(ref);
|
|
121
|
+
const data = snap.exists() ? snap.data() : undefined;
|
|
122
|
+
const recent = removeRecent(readRecent(data?.recent, recentMax, data?.current), id);
|
|
123
|
+
tx.set(ref, { recent: recent.map(sessionEntry) }, { merge: true });
|
|
124
|
+
return recent;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
wasSignedIn,
|
|
129
|
+
loadSdk,
|
|
130
|
+
userDoc,
|
|
131
|
+
warmSignIn,
|
|
132
|
+
watchAuth,
|
|
133
|
+
signIn,
|
|
134
|
+
signOut,
|
|
135
|
+
fetchHome,
|
|
136
|
+
editItems,
|
|
137
|
+
putSession,
|
|
138
|
+
dropSession,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface SavedItem {
|
|
2
|
+
name: string;
|
|
3
|
+
query: string;
|
|
4
|
+
id?: string;
|
|
5
|
+
savedAt?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CurrentSession {
|
|
8
|
+
query: string;
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
export interface RecentSession extends CurrentSession {
|
|
12
|
+
id: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const ITEM_MAX = 200;
|
|
15
|
+
export declare const QUERY_MAX = 8000;
|
|
16
|
+
export declare const ITEM_NAME_MAX = 40;
|
|
17
|
+
export declare const newItemId: () => string;
|
|
18
|
+
export declare const cleanItemName: (raw: string) => string;
|
|
19
|
+
export declare const readItems: (raw: unknown) => SavedItem[];
|
|
20
|
+
export declare function readCurrent(raw: unknown): CurrentSession | null;
|
|
21
|
+
export declare function readRecent(raw: unknown, recentMax: number, legacy?: unknown): RecentSession[];
|
|
22
|
+
export declare function pushRecent(recent: readonly RecentSession[], entry: RecentSession, recentMax: number): {
|
|
23
|
+
recent: RecentSession[];
|
|
24
|
+
dropped: string[];
|
|
25
|
+
};
|
|
26
|
+
export declare const removeRecent: (recent: readonly RecentSession[], id: string) => RecentSession[];
|
|
27
|
+
export declare function upsertItem(items: readonly SavedItem[], name: string, query: string, at?: number): SavedItem[];
|
|
28
|
+
export declare const removeItem: (items: readonly SavedItem[], name: string) => SavedItem[];
|
|
29
|
+
export declare function renameItem(items: readonly SavedItem[], from: string, to: string): SavedItem[];
|
|
30
|
+
export declare function suggestItemName(items: readonly SavedItem[], base: string, fallback: string): string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const ITEM_MAX = 200;
|
|
2
|
+
export const QUERY_MAX = 8000;
|
|
3
|
+
export const ITEM_NAME_MAX = 40;
|
|
4
|
+
export const newItemId = () => Math.random().toString(36).slice(2, 10);
|
|
5
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
6
|
+
export const cleanItemName = (raw) => raw.replaceAll(/\s+/g, ' ').trim().slice(0, ITEM_NAME_MAX).trim();
|
|
7
|
+
function readItem(raw) {
|
|
8
|
+
if (typeof raw !== 'object' || raw === null)
|
|
9
|
+
return undefined;
|
|
10
|
+
const name = 'name' in raw ? raw.name : undefined;
|
|
11
|
+
const query = 'query' in raw ? raw.query : undefined;
|
|
12
|
+
if (typeof name !== 'string' || typeof query !== 'string')
|
|
13
|
+
return undefined;
|
|
14
|
+
if (query.length > QUERY_MAX)
|
|
15
|
+
return undefined;
|
|
16
|
+
const clean = cleanItemName(name);
|
|
17
|
+
if (clean === '')
|
|
18
|
+
return undefined;
|
|
19
|
+
const id = 'id' in raw && typeof raw.id === 'string' ? raw.id : undefined;
|
|
20
|
+
const savedAt = 'savedAt' in raw ? num(raw.savedAt) : undefined;
|
|
21
|
+
return {
|
|
22
|
+
name: clean,
|
|
23
|
+
query,
|
|
24
|
+
...(id === undefined ? {} : { id }),
|
|
25
|
+
...(savedAt === undefined ? {} : { savedAt }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export const readItems = (raw) => (Array.isArray(raw) ? raw : [])
|
|
29
|
+
.flatMap(item => {
|
|
30
|
+
const parsed = readItem(item);
|
|
31
|
+
return parsed === undefined ? [] : [parsed];
|
|
32
|
+
})
|
|
33
|
+
.slice(0, ITEM_MAX);
|
|
34
|
+
export function readCurrent(raw) {
|
|
35
|
+
if (typeof raw !== 'object' || raw === null)
|
|
36
|
+
return null;
|
|
37
|
+
const query = 'query' in raw ? raw.query : undefined;
|
|
38
|
+
const at = 'at' in raw ? num(raw.at) : undefined;
|
|
39
|
+
if (typeof query !== 'string' || query.length > QUERY_MAX)
|
|
40
|
+
return null;
|
|
41
|
+
return at === undefined ? null : { query, at };
|
|
42
|
+
}
|
|
43
|
+
export function readRecent(raw, recentMax, legacy) {
|
|
44
|
+
if (!Array.isArray(raw)) {
|
|
45
|
+
const current = readCurrent(legacy);
|
|
46
|
+
return current === null ? [] : [{ id: '', ...current }];
|
|
47
|
+
}
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
return raw
|
|
50
|
+
.flatMap(item => {
|
|
51
|
+
const session = readCurrent(item);
|
|
52
|
+
const id = typeof item === 'object' && item !== null && 'id' in item
|
|
53
|
+
? item.id
|
|
54
|
+
: undefined;
|
|
55
|
+
if (session === null || typeof id !== 'string' || seen.has(id))
|
|
56
|
+
return [];
|
|
57
|
+
seen.add(id);
|
|
58
|
+
return [{ id, ...session }];
|
|
59
|
+
})
|
|
60
|
+
.slice(0, recentMax);
|
|
61
|
+
}
|
|
62
|
+
export function pushRecent(recent, entry, recentMax) {
|
|
63
|
+
const next = [
|
|
64
|
+
entry,
|
|
65
|
+
...recent.filter(s => s.id !== entry.id && s.query !== entry.query),
|
|
66
|
+
].slice(0, recentMax);
|
|
67
|
+
const kept = new Set(next.map(s => s.id));
|
|
68
|
+
return {
|
|
69
|
+
recent: next,
|
|
70
|
+
dropped: recent.flatMap(s => (kept.has(s.id) ? [] : [s.id])),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export const removeRecent = (recent, id) => recent.filter(item => item.id !== id);
|
|
74
|
+
export function upsertItem(items, name, query, at) {
|
|
75
|
+
const clean = cleanItemName(name);
|
|
76
|
+
if (clean === '')
|
|
77
|
+
return [...items];
|
|
78
|
+
const index = items.findIndex(item => item.name === clean);
|
|
79
|
+
const prior = index === -1 ? undefined : items[index];
|
|
80
|
+
const entry = { name: clean, query };
|
|
81
|
+
if (prior?.id !== undefined)
|
|
82
|
+
entry.id = prior.id;
|
|
83
|
+
if (at !== undefined) {
|
|
84
|
+
entry.id ??= newItemId();
|
|
85
|
+
entry.savedAt = at;
|
|
86
|
+
}
|
|
87
|
+
if (index !== -1)
|
|
88
|
+
return items.map((item, i) => (i === index ? entry : item));
|
|
89
|
+
return [...items, entry].slice(-ITEM_MAX);
|
|
90
|
+
}
|
|
91
|
+
export const removeItem = (items, name) => items.filter(item => item.name !== name);
|
|
92
|
+
export function renameItem(items, from, to) {
|
|
93
|
+
const clean = cleanItemName(to);
|
|
94
|
+
if (clean === '' || items.some(item => item.name === clean))
|
|
95
|
+
return [...items];
|
|
96
|
+
return items.map(item => item.name === from ? { ...item, name: clean } : item);
|
|
97
|
+
}
|
|
98
|
+
export function suggestItemName(items, base, fallback) {
|
|
99
|
+
const clean = cleanItemName(base);
|
|
100
|
+
const stem = clean === '' ? fallback : clean;
|
|
101
|
+
if (!items.some(item => item.name === stem))
|
|
102
|
+
return stem;
|
|
103
|
+
for (let n = 2; n < 1000; n++) {
|
|
104
|
+
const candidate = `${stem} ${n}`;
|
|
105
|
+
if (!items.some(item => item.name === candidate))
|
|
106
|
+
return candidate;
|
|
107
|
+
}
|
|
108
|
+
return stem;
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillet-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Code shared by bender and videoskillet",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Colin Diesh",
|
|
@@ -41,7 +41,15 @@
|
|
|
41
41
|
"./components/SocialMeta.astro": "./components/SocialMeta.astro",
|
|
42
42
|
"./components/SignedInHint.astro": "./components/SignedInHint.astro",
|
|
43
43
|
"./site.css": "./css/site.css",
|
|
44
|
-
"./footer.css": "./css/footer.css"
|
|
44
|
+
"./footer.css": "./css/footer.css",
|
|
45
|
+
"./saved-list": {
|
|
46
|
+
"types": "./dist/saved-list.d.ts",
|
|
47
|
+
"default": "./dist/saved-list.js"
|
|
48
|
+
},
|
|
49
|
+
"./cloud": {
|
|
50
|
+
"types": "./dist/cloud.d.ts",
|
|
51
|
+
"default": "./dist/cloud.js"
|
|
52
|
+
}
|
|
45
53
|
},
|
|
46
54
|
"files": [
|
|
47
55
|
"dist",
|
|
@@ -61,11 +69,13 @@
|
|
|
61
69
|
"postversion": "git push --follow-tags"
|
|
62
70
|
},
|
|
63
71
|
"peerDependencies": {
|
|
64
|
-
"astro": "^7.4.2"
|
|
72
|
+
"astro": "^7.4.2",
|
|
73
|
+
"firebase": "^13.1.0"
|
|
65
74
|
},
|
|
66
75
|
"devDependencies": {
|
|
67
76
|
"@types/node": "^26.6.5",
|
|
68
77
|
"astro": "^7.4.2",
|
|
78
|
+
"firebase": "13.1.0",
|
|
69
79
|
"typescript": "^7.0.2",
|
|
70
80
|
"vitest": "^5.0.5"
|
|
71
81
|
}
|