annotate-kit 0.1.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.
@@ -0,0 +1,200 @@
1
+ // src/adapters/firebase.ts
2
+ import {
3
+ collection,
4
+ doc,
5
+ getDoc,
6
+ getDocs,
7
+ setDoc,
8
+ updateDoc,
9
+ deleteDoc,
10
+ query,
11
+ where,
12
+ orderBy,
13
+ limit,
14
+ arrayUnion,
15
+ runTransaction,
16
+ onSnapshot
17
+ } from "firebase/firestore";
18
+ import { ref, uploadBytes, getDownloadURL, deleteObject } from "firebase/storage";
19
+ function prune(o) {
20
+ if (Array.isArray(o)) return o.map((v) => prune(v));
21
+ if (o && typeof o === "object") {
22
+ const out = {};
23
+ for (const [k, v] of Object.entries(o)) if (v !== void 0) out[k] = prune(v);
24
+ return out;
25
+ }
26
+ return o;
27
+ }
28
+ function createFirebaseAdapter(config) {
29
+ const db = config.firestore;
30
+ const storage = config.storage;
31
+ const auth = config.auth;
32
+ const collPath = config.collectionPath ?? "annotations";
33
+ const settingsFullPath = config.settingsPath ?? "annotate_settings/global";
34
+ if (settingsFullPath.split("/").filter(Boolean).length % 2 !== 0) {
35
+ throw new Error(`annotate: settingsPath must be a document path like 'collection/doc' (got '${settingsFullPath}')`);
36
+ }
37
+ const storageDir = config.storagePath ?? "annotations";
38
+ const listLimit = config.listLimit ?? 1e3;
39
+ const col = collection(db, collPath);
40
+ const settingsRef = doc(db, settingsFullPath);
41
+ const urlCache = /* @__PURE__ */ new Map();
42
+ async function claims() {
43
+ if (!auth?.currentUser) return {};
44
+ try {
45
+ return (await auth.currentUser.getIdTokenResult()).claims;
46
+ } catch {
47
+ return {};
48
+ }
49
+ }
50
+ async function resolveUser() {
51
+ if (config.currentUser) return await config.currentUser() ?? {};
52
+ const u = auth?.currentUser;
53
+ if (!u) return {};
54
+ const c = await claims();
55
+ return { email: u.email ?? void 0, name: u.displayName ?? u.email ?? void 0, role: c.role || void 0 };
56
+ }
57
+ async function resolveIsAdmin(user) {
58
+ if (typeof config.isAdmin === "boolean") return config.isAdmin;
59
+ if (typeof config.isAdmin === "function") return !!await config.isAdmin(user);
60
+ const c = await claims();
61
+ return c.admin === true || c.role === "admin";
62
+ }
63
+ async function urlFor(path) {
64
+ if (!path) return null;
65
+ const hit = urlCache.get(path);
66
+ if (hit) return hit;
67
+ try {
68
+ const u = await getDownloadURL(ref(storage, path));
69
+ urlCache.set(path, u);
70
+ return u;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+ return {
76
+ async getAccess() {
77
+ const me = await resolveUser();
78
+ const isAdmin = await resolveIsAdmin(me);
79
+ let enabled = false;
80
+ try {
81
+ const snap = await getDoc(settingsRef);
82
+ if (snap.exists() && typeof snap.data().enabled === "boolean") enabled = snap.data().enabled;
83
+ } catch {
84
+ }
85
+ return { enabled, isAdmin, canUse: enabled || isAdmin, seeAll: isAdmin, me };
86
+ },
87
+ async setAccess(enabled) {
88
+ await setDoc(settingsRef, { enabled }, { merge: true });
89
+ return { message: enabled ? "Enabled for all users" : "ADMIN-only" };
90
+ },
91
+ async list() {
92
+ const me = await resolveUser();
93
+ const isAdmin = await resolveIsAdmin(me);
94
+ const seen = /* @__PURE__ */ new Map();
95
+ const collect = (docs) => {
96
+ for (const d of docs) {
97
+ const data = d.data();
98
+ const id = typeof data.id === "number" ? data.id : Number(d.id);
99
+ seen.set(id, { ...data, id });
100
+ }
101
+ };
102
+ if (isAdmin) {
103
+ const s = await getDocs(query(col, orderBy("id", "desc"), limit(listLimit)));
104
+ collect(s.docs);
105
+ } else {
106
+ const [pub, mine] = await Promise.all([
107
+ getDocs(query(col, where("visibility", "==", "public"), orderBy("id", "desc"), limit(listLimit))),
108
+ me.email ? getDocs(query(col, where("created_by", "==", me.email), orderBy("id", "desc"), limit(listLimit))) : Promise.resolve({ docs: [] })
109
+ ]);
110
+ collect(pub.docs);
111
+ collect(mine.docs);
112
+ }
113
+ const rows = [...seen.values()].sort((a, b) => b.id - a.id);
114
+ await Promise.all(rows.map(async (r) => {
115
+ if (r.screenshot_path) r.screenshot_url = await urlFor(r.screenshot_path);
116
+ }));
117
+ return rows;
118
+ },
119
+ async save(input) {
120
+ const me = await resolveUser();
121
+ const base = {
122
+ ...input,
123
+ status: "open",
124
+ created_by: me.email ?? "",
125
+ created_by_name: me.name ?? me.email ?? "",
126
+ created_by_role: me.role ?? null,
127
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
128
+ };
129
+ let id = 0;
130
+ await runTransaction(db, async (tx) => {
131
+ for (let i = 0; i < 6; i++) {
132
+ const cand = Date.now() * 1e3 + Math.floor(Math.random() * 1e3);
133
+ const dref = doc(col, String(cand));
134
+ const snap = await tx.get(dref);
135
+ if (!snap.exists()) {
136
+ id = cand;
137
+ tx.set(dref, prune({ ...base, id }));
138
+ return;
139
+ }
140
+ }
141
+ throw new Error("annotate: could not allocate a unique id \u2014 retry");
142
+ });
143
+ return { id };
144
+ },
145
+ async update(patch) {
146
+ const { id, ...rest } = patch;
147
+ await updateDoc(doc(col, String(id)), { ...prune(rest), updated_at: (/* @__PURE__ */ new Date()).toISOString() });
148
+ },
149
+ async remove(id) {
150
+ const snap = await getDoc(doc(col, String(id)));
151
+ const path = snap.exists() ? snap.data().screenshot_path : void 0;
152
+ await deleteDoc(doc(col, String(id)));
153
+ if (path) {
154
+ urlCache.delete(path);
155
+ try {
156
+ await deleteObject(ref(storage, path));
157
+ } catch (e) {
158
+ console.warn("[annotate] screenshot delete failed", e);
159
+ }
160
+ }
161
+ },
162
+ async addReply(id, note) {
163
+ const me = await resolveUser();
164
+ const reply = { note: note.slice(0, 2e3), author: me.name ?? me.email ?? "", at: (/* @__PURE__ */ new Date()).toISOString() };
165
+ await updateDoc(doc(col, String(id)), { replies: arrayUnion(reply), updated_at: (/* @__PURE__ */ new Date()).toISOString() });
166
+ },
167
+ async clearResolved() {
168
+ const s = await getDocs(query(col, where("status", "in", ["done", "dismissed"])));
169
+ await Promise.all(s.docs.map(async (d) => {
170
+ const path = d.data().screenshot_path;
171
+ await deleteDoc(d.ref);
172
+ if (path) {
173
+ urlCache.delete(path);
174
+ try {
175
+ await deleteObject(ref(storage, path));
176
+ } catch (e) {
177
+ console.warn("[annotate] screenshot delete failed", e);
178
+ }
179
+ }
180
+ }));
181
+ return { message: `Cleared ${s.docs.length} resolved` };
182
+ },
183
+ async uploadScreenshot(file, opts) {
184
+ const vis = opts?.visibility === "public" ? "public" : "private";
185
+ const uid = auth?.currentUser?.uid;
186
+ if (!uid) throw new Error("annotate: must be signed in to upload a screenshot");
187
+ const rand = Math.random().toString(36).slice(2, 8);
188
+ const path = `${storageDir}/${vis}/${uid}/${Date.now()}-${rand}.png`;
189
+ await uploadBytes(ref(storage, path), file, { contentType: file.type || "image/png" });
190
+ return { path };
191
+ },
192
+ subscribe(onChange) {
193
+ return onSnapshot(query(col, where("visibility", "==", "public")), () => onChange(), () => {
194
+ });
195
+ }
196
+ };
197
+ }
198
+ export {
199
+ createFirebaseAdapter
200
+ };
package/dist/idb.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { S as StorageAdapter } from './adapter-Cyqfwj6F.js';
2
+ import './types-BLXmj4Oi.js';
3
+
4
+ type IdbAdapterConfig = {
5
+ /** Database name (default 'annotate-kit'). */
6
+ dbName?: string;
7
+ /** The single local user shown as author. */
8
+ me?: {
9
+ email?: string;
10
+ name?: string;
11
+ role?: string;
12
+ };
13
+ /** IndexedDB factory (default globalThis.indexedDB). Inject a fake for tests / non-browser envs. */
14
+ factory?: IDBFactory;
15
+ };
16
+ declare function createIdbAdapter(config?: IdbAdapterConfig): StorageAdapter;
17
+
18
+ export { type IdbAdapterConfig, createIdbAdapter };
package/dist/idb.js ADDED
@@ -0,0 +1,202 @@
1
+ // src/adapters/idb.ts
2
+ var MARKS = "marks";
3
+ var SHOTS = "shots";
4
+ var SETTINGS = "settings";
5
+ function createIdbAdapter(config = {}) {
6
+ const dbName = config.dbName ?? "annotate-kit";
7
+ const me = config.me ?? { email: "you@local", name: "You" };
8
+ const idb = config.factory ?? (typeof indexedDB !== "undefined" ? indexedDB : void 0);
9
+ const now = () => (/* @__PURE__ */ new Date()).toISOString();
10
+ const canObjectUrl = typeof URL !== "undefined" && typeof URL.createObjectURL === "function";
11
+ const urlCache = /* @__PURE__ */ new Map();
12
+ let dbp = null;
13
+ function db() {
14
+ if (!idb) return Promise.reject(new Error("annotate: IndexedDB is unavailable \u2014 pass config.factory or use another adapter"));
15
+ if (!dbp) dbp = new Promise((resolve, reject) => {
16
+ const req = idb.open(dbName, 1);
17
+ req.onupgradeneeded = () => {
18
+ const d = req.result;
19
+ if (!d.objectStoreNames.contains(MARKS)) d.createObjectStore(MARKS, { keyPath: "id", autoIncrement: true });
20
+ if (!d.objectStoreNames.contains(SHOTS)) d.createObjectStore(SHOTS);
21
+ if (!d.objectStoreNames.contains(SETTINGS)) d.createObjectStore(SETTINGS);
22
+ };
23
+ req.onsuccess = () => resolve(req.result);
24
+ req.onerror = () => reject(req.error);
25
+ });
26
+ return dbp;
27
+ }
28
+ function run(stores, mode, body) {
29
+ return db().then((d) => new Promise((resolve, reject) => {
30
+ const t = d.transaction(stores, mode);
31
+ const out = {};
32
+ try {
33
+ body(t, out);
34
+ } catch (e) {
35
+ reject(e);
36
+ try {
37
+ t.abort();
38
+ } catch {
39
+ }
40
+ return;
41
+ }
42
+ t.oncomplete = () => resolve(out.value);
43
+ t.onerror = () => reject(t.error);
44
+ t.onabort = () => reject(t.error);
45
+ }));
46
+ }
47
+ return {
48
+ async getAccess() {
49
+ const enabled = await run([SETTINGS], "readonly", (t, out) => {
50
+ const g = t.objectStore(SETTINGS).get("enabled");
51
+ g.onsuccess = () => {
52
+ out.value = g.result === void 0 ? true : !!g.result;
53
+ };
54
+ });
55
+ return { enabled: enabled ?? true, isAdmin: true, canUse: true, seeAll: true, me };
56
+ },
57
+ async setAccess(enabled) {
58
+ await run([SETTINGS], "readwrite", (t) => {
59
+ t.objectStore(SETTINGS).put(enabled, "enabled");
60
+ });
61
+ return { message: enabled ? "Enabled" : "Disabled" };
62
+ },
63
+ async list() {
64
+ const rows = await run([MARKS, SHOTS], "readonly", (t, out) => {
65
+ const shots = t.objectStore(SHOTS);
66
+ const ga = t.objectStore(MARKS).getAll();
67
+ ga.onsuccess = () => {
68
+ const all = ga.result || [];
69
+ for (const r of all) {
70
+ if (r.screenshot_path && canObjectUrl) {
71
+ const cached = urlCache.get(r.screenshot_path);
72
+ if (cached) r.screenshot_url = cached;
73
+ else {
74
+ const sr = shots.get(r.screenshot_path);
75
+ sr.onsuccess = () => {
76
+ const b = sr.result;
77
+ if (b) {
78
+ const u = URL.createObjectURL(b);
79
+ urlCache.set(r.screenshot_path, u);
80
+ r.screenshot_url = u;
81
+ }
82
+ };
83
+ }
84
+ }
85
+ }
86
+ out.value = all;
87
+ };
88
+ });
89
+ return (rows ?? []).sort((a, b) => b.id - a.id);
90
+ },
91
+ async save(input) {
92
+ const row = {
93
+ ...input,
94
+ status: "open",
95
+ created_by: me.email ?? "",
96
+ created_by_name: me.name ?? me.email ?? "",
97
+ created_by_role: me.role ?? null,
98
+ created_at: now(),
99
+ updated_at: now()
100
+ };
101
+ const id = await run([MARKS], "readwrite", (t, out) => {
102
+ const add = t.objectStore(MARKS).add(row);
103
+ add.onsuccess = () => {
104
+ out.value = Number(add.result);
105
+ };
106
+ });
107
+ return { id: id ?? 0 };
108
+ },
109
+ async update(patch) {
110
+ const { id, ...rest } = patch;
111
+ await run([MARKS], "readwrite", (t) => {
112
+ const store = t.objectStore(MARKS);
113
+ const g = store.get(id);
114
+ g.onsuccess = () => {
115
+ const row = g.result;
116
+ if (row) {
117
+ Object.assign(row, rest, { updated_at: now() });
118
+ store.put(row);
119
+ }
120
+ };
121
+ });
122
+ },
123
+ async remove(id) {
124
+ await run([MARKS, SHOTS], "readwrite", (t) => {
125
+ const marks = t.objectStore(MARKS);
126
+ const g = marks.get(id);
127
+ g.onsuccess = () => {
128
+ const row = g.result;
129
+ marks.delete(id);
130
+ if (row?.screenshot_path) {
131
+ t.objectStore(SHOTS).delete(row.screenshot_path);
132
+ const u = urlCache.get(row.screenshot_path);
133
+ if (u && canObjectUrl) URL.revokeObjectURL(u);
134
+ urlCache.delete(row.screenshot_path);
135
+ }
136
+ };
137
+ });
138
+ },
139
+ async addReply(id, note) {
140
+ await run([MARKS], "readwrite", (t) => {
141
+ const store = t.objectStore(MARKS);
142
+ const g = store.get(id);
143
+ g.onsuccess = () => {
144
+ const row = g.result;
145
+ if (row) {
146
+ const reply = { note: note.slice(0, 2e3), author: me.name ?? me.email ?? "", at: now() };
147
+ row.replies = [...Array.isArray(row.replies) ? row.replies : [], reply];
148
+ row.updated_at = now();
149
+ store.put(row);
150
+ }
151
+ };
152
+ });
153
+ },
154
+ async clearResolved() {
155
+ const removed = await run([MARKS, SHOTS], "readwrite", (t, out) => {
156
+ const marks = t.objectStore(MARKS);
157
+ const shots = t.objectStore(SHOTS);
158
+ const ga = marks.getAll();
159
+ ga.onsuccess = () => {
160
+ const all = ga.result || [];
161
+ let n = 0;
162
+ const referenced = /* @__PURE__ */ new Set();
163
+ for (const r of all) {
164
+ if (r.status === "done" || r.status === "dismissed") {
165
+ marks.delete(r.id);
166
+ n++;
167
+ if (r.screenshot_path) {
168
+ const u = urlCache.get(r.screenshot_path);
169
+ if (u && canObjectUrl) URL.revokeObjectURL(u);
170
+ urlCache.delete(r.screenshot_path);
171
+ }
172
+ } else if (r.screenshot_path) referenced.add(r.screenshot_path);
173
+ }
174
+ const gk = shots.getAllKeys();
175
+ gk.onsuccess = () => {
176
+ for (const k of gk.result || []) {
177
+ const key = String(k);
178
+ if (!referenced.has(key)) {
179
+ shots.delete(k);
180
+ const u = urlCache.get(key);
181
+ if (u && canObjectUrl) URL.revokeObjectURL(u);
182
+ urlCache.delete(key);
183
+ }
184
+ }
185
+ };
186
+ out.value = n;
187
+ };
188
+ });
189
+ return { message: `Cleared ${removed ?? 0} resolved` };
190
+ },
191
+ async uploadScreenshot(file, _opts) {
192
+ const path = `shot-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
193
+ await run([SHOTS], "readwrite", (t) => {
194
+ t.objectStore(SHOTS).put(file, path);
195
+ });
196
+ return { path };
197
+ }
198
+ };
199
+ }
200
+ export {
201
+ createIdbAdapter
202
+ };