From b3f5b37de47bd473b94b2b4a03539d4fd2695f39 Mon Sep 17 00:00:00 2001 From: brayo Date: Tue, 26 Mar 2024 12:21:41 +0300 Subject: [PATCH 01/10] add querying support --- src/firebase/data.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/firebase/data.ts b/src/firebase/data.ts index 4702d7c..0e52c78 100644 --- a/src/firebase/data.ts +++ b/src/firebase/data.ts @@ -50,10 +50,14 @@ export async function addScreenTimeData(userId: number, data: ScreenTimeData) { } } -export async function getScreenTimeData(userId: string): Promise { - const colPath = `screentime/${userId}/${userId}` - const colRef = collection(db, colPath) - const snapshot = await getDocs(colRef) +export async function getScreenTimeData(userId: string, since: Date | null = null, _public: Boolean = true): Promise { + const q = query( + collection(db, 'screentime/' + userId + '/' + userId), + // where('date', '>=', since || new Date('1900-1-1')), + where('public', '==', _public) + ) + + const snapshot = await getDocs(q) if (snapshot.empty) { return null } From e07d150549f394822e792667510815839feda964 Mon Sep 17 00:00:00 2001 From: brayo Date: Thu, 28 Mar 2024 16:56:53 +0300 Subject: [PATCH 02/10] Redirect to Login page if not authenticated --- src/components/Dashboard.vue | 1 - src/components/Leaderboard.vue | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/Dashboard.vue b/src/components/Dashboard.vue index 95217c2..a52d48d 100644 --- a/src/components/Dashboard.vue +++ b/src/components/Dashboard.vue @@ -23,7 +23,6 @@ fetchSummary() const summaries = summary if (!isAuthenticated) { - logout() router.push({ name: 'Login' }) } diff --git a/src/components/Leaderboard.vue b/src/components/Leaderboard.vue index 7a3ff12..f5007a0 100644 --- a/src/components/Leaderboard.vue +++ b/src/components/Leaderboard.vue @@ -24,12 +24,17 @@ import { ref, onMounted } from 'vue' import { useLeaderboardStore } from '@/stores/leaderboard' import { type ScreenTimeSummary } from '@/types' import AWLHeader from '@/components/Header.vue' +import { useAuthStore } from '@/stores/auth' +import router from '@/router' export default { name: 'AWLLeaderboard', setup() { const entries = ref([] as ScreenTimeSummary[] | null) - + const { isAuthenticated } = useAuthStore() + if (!isAuthenticated) { + router.push({ name: 'Login' }) + } onMounted(async () => { try { const { fetchLeaderboardData, leaderboardData} = useLeaderboardStore() From 449e6b078d6ad583b15766decc952035d7f8859b Mon Sep 17 00:00:00 2001 From: brayo Date: Tue, 2 Apr 2024 15:52:14 +0300 Subject: [PATCH 03/10] add write and update triggers, create getApiKey --- functions/src/index.ts | 88 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 8 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 9462d2c..0ca7bde 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -7,13 +7,85 @@ * See a full list of supported triggers at https://firebase.google.com/docs/functions */ -import {onRequest} from "firebase-functions/v2/https"; -import * as logger from "firebase-functions/logger"; +// import {onSchedule} from "firebase-functions/v2/scheduler"; +import { + onDocumentCreated, + onDocumentUpdated +} from "firebase-functions/v2/firestore"; -// Start writing functions -// https://firebase.google.com/docs/functions/typescript +import * as admin from "firebase-admin"; +import {error, info} from "firebase-functions/lib/logger"; -// export const helloWorld = onRequest((request, response) => { -// logger.info("Hello logs!", {structuredData: true}); -// response.send("Hello from Firebase!"); -// }); +admin.initializeApp(); + +exports.CalculateCategoryTotals = onDocumentCreated( + "leaderboard/{userId}", (event) => { + info("Calculating totals"); + const snapshot = event.data; + if (!snapshot) { + error("Document does not exist"); + return; + } + const data = snapshot.data(); + const categoriesTotals = data.CategoryTotals as { [key: string]: number }; + let total = 0; + for (const category in categoriesTotals) { + /* eslint guard-for-in: 1 */ + total += categoriesTotals[category]; + } + const db = admin.firestore(); + const colpath = "leaderboard"; + db.collection(colpath).doc(snapshot.id).update({total: total}).then(() => { + info("Total updated"); + }).catch((error) => { + error("Error updating total: ", error); + }); + }); + +exports.UpdateCategoryTotals = onDocumentUpdated( + "leaderboard/{userId}", (event) => { + info("Updating totals on document update"); + const snapshot = event.data; + if (!snapshot) { + error("Document does not exist"); + return; + } + const data = snapshot.after.data(); + const categoriesTotals = data.CategoryTotals as { [key: string]: number }; + let total = 0; + for (const category in categoriesTotals) { + /* eslint guard-for-in: 1 */ + total += categoriesTotals[category]; + } + const db = admin.firestore(); + const colpath = "leaderboard"; + db.collection(colpath).doc(snapshot.after.id) + .update({total: total}).then(() => { + info("Total updated"); + }).catch((error) => { + error("Error updating total: ", error); + }); + }); + + export const getApiKey = functions.https.onCall(async (_, context) => { + info("Generating ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } + const db = admin.firestore(); + const colpath = "apiKeys"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + info("ApiKey found"); + return {apiKey: doc.data()?.apiKey}; + } else { + const apiKey = genKey.generateApiKey(); + await docref.set({apiKey: apiKey}); + info("ApiKey set and sent to client"); + return {apiKey: apiKey}; + } + }); From fc3da44900a2f3cced94dfd9f47b273513df8a46 Mon Sep 17 00:00:00 2001 From: brayo Date: Fri, 5 Apr 2024 14:08:32 +0300 Subject: [PATCH 04/10] Update package.json and add new types and functions --- functions/package-lock.json | 75 +++++++++++++++++++++- functions/package.json | 5 +- functions/src/index.ts | 120 ++++++++++++++++++++++++++++++------ functions/src/types.ts | 38 ++++++++++++ 4 files changed, 215 insertions(+), 23 deletions(-) create mode 100644 functions/src/types.ts diff --git a/functions/package-lock.json b/functions/package-lock.json index e040f12..c92556d 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -7,7 +7,8 @@ "name": "functions", "dependencies": { "firebase-admin": "^11.8.0", - "firebase-functions": "^4.3.1" + "firebase-functions": "^4.3.1", + "generate-api-key": "^1.0.2" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.12.0", @@ -19,7 +20,7 @@ "typescript": "^4.9.0" }, "engines": { - "node": "18" + "node": "20" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -2413,6 +2414,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "devOptional": true }, + "node_modules/base-x": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", + "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2649,6 +2655,11 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chance": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.11.tgz", + "integrity": "sha512-kqTg3WWywappJPqtgrdvbA380VoXO2eu9VCV895JgbyHsaErXdyHK9LOZ911OvAk6L0obK7kDk9CGs8+oBawVA==" + }, "node_modules/char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -3999,6 +4010,28 @@ "node": ">=12" } }, + "node_modules/generate-api-key": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/generate-api-key/-/generate-api-key-1.0.2.tgz", + "integrity": "sha512-4rPSpXyboIXfugOTN3/0Qaoqpzbk0sepzPS0XyxPh3UMuu+Trk+0JMyJ6mB/7FEgp7oZ1juqsRW+8wSYeKDbfA==", + "dependencies": { + "base-x": "^4.0.0", + "chance": "^1.1.8", + "rfc4648": "^1.5.2", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/generate-api-key/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6884,6 +6917,11 @@ "node": ">=0.10.0" } }, + "node_modules/rfc4648": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz", + "integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -9805,6 +9843,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "devOptional": true }, + "base-x": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", + "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9964,6 +10007,11 @@ "supports-color": "^7.1.0" } }, + "chance": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/chance/-/chance-1.1.11.tgz", + "integrity": "sha512-kqTg3WWywappJPqtgrdvbA380VoXO2eu9VCV895JgbyHsaErXdyHK9LOZ911OvAk6L0obK7kDk9CGs8+oBawVA==" + }, "char-regex": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", @@ -11020,6 +11068,24 @@ "json-bigint": "^1.0.0" } }, + "generate-api-key": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/generate-api-key/-/generate-api-key-1.0.2.tgz", + "integrity": "sha512-4rPSpXyboIXfugOTN3/0Qaoqpzbk0sepzPS0XyxPh3UMuu+Trk+0JMyJ6mB/7FEgp7oZ1juqsRW+8wSYeKDbfA==", + "requires": { + "base-x": "^4.0.0", + "chance": "^1.1.8", + "rfc4648": "^1.5.2", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -13177,6 +13243,11 @@ "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true }, + "rfc4648": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.3.tgz", + "integrity": "sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==" + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", diff --git a/functions/package.json b/functions/package.json index 87f9bee..41ba3ee 100644 --- a/functions/package.json +++ b/functions/package.json @@ -1,7 +1,7 @@ { "name": "functions", "scripts": { - "lint": "eslint --ext .js,.ts .", + "lint": "eslint --fix --ext .js,.ts .", "build": "tsc", "build:watch": "tsc --watch", "serve": "npm run build && firebase emulators:start --only functions", @@ -16,7 +16,8 @@ "main": "lib/index.js", "dependencies": { "firebase-admin": "^11.8.0", - "firebase-functions": "^4.3.1" + "firebase-functions": "^4.3.1", + "generate-api-key": "^1.0.2" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.12.0", diff --git a/functions/src/index.ts b/functions/src/index.ts index 0ca7bde..3462e80 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -13,8 +13,14 @@ import { onDocumentUpdated } from "firebase-functions/v2/firestore"; +import {onRequest} from "firebase-functions/v2/https"; +import {onObjectFinalized} from "firebase-functions/v2/storage"; import * as admin from "firebase-admin"; import {error, info} from "firebase-functions/lib/logger"; +import * as functions from "firebase-functions"; +import * as genKey from "generate-api-key"; + +import {Event, RawEvent} from "./types"; admin.initializeApp(); @@ -67,25 +73,101 @@ exports.UpdateCategoryTotals = onDocumentUpdated( }); }); - export const getApiKey = functions.https.onCall(async (_, context) => { - info("Generating ApiKey"); - info("Request data: ", context); - if (!context.auth) { - error("Not authenticated"); - return {error: "Not authenticated"}; - } +export const getApiKey = functions.https.onCall(async (_, context) => { + info("Generating ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } + const db = admin.firestore(); + const colpath = "apiKeys"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + info("ApiKey found"); + return {apiKey: doc.data()?.apiKey}; + } else { + const apiKey = genKey.generateApiKey(); + await docref.set({apiKey: apiKey}); + info("ApiKey set and sent to client"); + return {apiKey: apiKey}; + } +}); + + +exports.uploadData = onRequest(async (request, response) => { + info("Storing data"); + info("Request data: ", request.body); + if (!request.body.apiKey) { + error("No apiKey provided!"); + response.status(400).send({error: "No apiKey provided!"}); + return; + } + if (!request.body.data) { + error("No data provided to store!"); + response.status(400).send({error: "No data provided!"}); + return; + } + + const apiKey = request.body.apiKey; + const db = admin.firestore(); + const querySnapshot = await db.collection("apiKeys") + .where("apiKey", "==", apiKey) + .get(); + info("QuerySnapshot: ", querySnapshot); + if (querySnapshot.empty) { + error("Invalid apiKey provided!"); + response.status(403).send({error: "Invalid apiKey provided!"}); + return; + } + const userId = querySnapshot.docs[0].data().userId; + const bucket = admin.storage().bucket(); + const dataToStore = request.body.data; + + const filename = `${userId}/${Date.now()}.json`; + const file = bucket.file(filename); + await file.save(dataToStore); + + response.send({message: "Data stored successfully!"}); +}); + +exports.onUploadData = onObjectFinalized( + {cpu: 4}, async (event) => { + info("Processing uploaded data"); + const file = event.data; + const bucket = admin.storage().bucket(); + const data = await bucket.file(file.name).download(); + const userId = file.name.split("/")[0]; + const dataString = data.toString(); + const jsonData: [RawEvent] = JSON.parse(dataString); const db = admin.firestore(); - const colpath = "apiKeys"; - const docpath = context.auth?.uid; - const docref = db.collection(colpath).doc(docpath); - const doc = await docref.get(); - if (doc.exists && doc.data() && doc.data()?.apiKey) { - info("ApiKey found"); - return {apiKey: doc.data()?.apiKey}; - } else { - const apiKey = genKey.generateApiKey(); - await docref.set({apiKey: apiKey}); - info("ApiKey set and sent to client"); - return {apiKey: apiKey}; + const colpath = `screentime/${userId}/${userId}`; + const promises = []; + for (const rawEvent of jsonData) { + // reduce from type RawEvent to Event + const event:Event = { + timestamp: rawEvent.timestamp, + duration: rawEvent.duration, + data: rawEvent.data, + }; + // check if a doc with the same date exists + // date in yyyy-mm-dd format + let date = new Date(event.timestamp).toISOString().split("T")[0]; + date = date.replace(/\//g, "-"); + const doc = await db.collection(colpath).doc(date).get(); + if (doc.exists) { + const events = doc.data()?.events as Event[]; + events.push(event); + const promise = db.collection(colpath).doc(date) + .update({events: events}); + promises.push(promise); + } else { + const promise = db.collection(colpath).doc(date).set({events: [event]}); + promises.push(promise); + } } + await Promise.all(promises); + info("Data processed successfully"); }); diff --git a/functions/src/types.ts b/functions/src/types.ts new file mode 100644 index 0000000..41fe274 --- /dev/null +++ b/functions/src/types.ts @@ -0,0 +1,38 @@ +export interface Event { + timestamp: string + duration: number + data: [] +} +export interface RawEvent { + id: number + timestamp: string + duration: number + data: [] +} +// Screentime is stored in day-level objects that have +// their `events` appended when new data is added. +export interface ScreenTimeData { + userId: string + public: boolean + date: string + events: Event[] +} + +// Same as above, but with events aggregated +export interface ScreenTimeSummary { + userId: string + total: number + date: string + categoryTotals: { [key: string]: number } +} + +export interface ChartDataset { + label: string + data: number[] + backgroundColor: string +} + +export interface ChartData { + labels: string[] + datasets: ChartDataset[] +} From 8fe1971489267027cc1dc78a7dff373a3da1e160 Mon Sep 17 00:00:00 2001 From: brayo Date: Fri, 5 Apr 2024 15:55:47 +0300 Subject: [PATCH 05/10] Refactor code to improve performance and readability --- functions/src/index.ts | 242 +++++++++++++++++++++++------------------ 1 file changed, 137 insertions(+), 105 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index 3462e80..b8e99be 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,12 +1,3 @@ -/** - * Import function triggers from their respective submodules: - * - * import {onCall} from "firebase-functions/v2/https"; - * import {onDocumentWritten} from "firebase-functions/v2/firestore"; - * - * See a full list of supported triggers at https://firebase.google.com/docs/functions - */ - // import {onSchedule} from "firebase-functions/v2/scheduler"; import { onDocumentCreated, @@ -22,8 +13,24 @@ import * as genKey from "generate-api-key"; import {Event, RawEvent} from "./types"; -admin.initializeApp(); +admin.initializeApp(); // TODO: cleanup global scope imports and inits +exports.onUserCreated = functions.auth.user().onCreate((user) => { + info("User created: ", user.uid); + const db = admin.firestore(); + const colpath = "users"; + const docpath = user.uid; + const apiKey = genKey.generateApiKey(); + const jsonObj = JSON.parse(`{"apiKey": "${apiKey}"}`); + return db.collection(colpath).doc(docpath).set(jsonObj); +}); +exports.onUserDeleted = functions.auth.user().onDelete((user) => { + info("User deleted: ", user.uid); + const db = admin.firestore(); + const colpath = "users"; + const docpath = user.uid; + return db.collection(colpath).doc(docpath).delete(); +}); exports.CalculateCategoryTotals = onDocumentCreated( "leaderboard/{userId}", (event) => { info("Calculating totals"); @@ -73,101 +80,126 @@ exports.UpdateCategoryTotals = onDocumentUpdated( }); }); -export const getApiKey = functions.https.onCall(async (_, context) => { - info("Generating ApiKey"); - info("Request data: ", context); - if (!context.auth) { - error("Not authenticated"); - return {error: "Not authenticated"}; - } - const db = admin.firestore(); - const colpath = "apiKeys"; - const docpath = context.auth?.uid; - const docref = db.collection(colpath).doc(docpath); - const doc = await docref.get(); - if (doc.exists && doc.data() && doc.data()?.apiKey) { - info("ApiKey found"); - return {apiKey: doc.data()?.apiKey}; - } else { - const apiKey = genKey.generateApiKey(); - await docref.set({apiKey: apiKey}); - info("ApiKey set and sent to client"); - return {apiKey: apiKey}; - } -}); - - -exports.uploadData = onRequest(async (request, response) => { - info("Storing data"); - info("Request data: ", request.body); - if (!request.body.apiKey) { - error("No apiKey provided!"); - response.status(400).send({error: "No apiKey provided!"}); - return; - } - if (!request.body.data) { - error("No data provided to store!"); - response.status(400).send({error: "No data provided!"}); - return; - } - - const apiKey = request.body.apiKey; - const db = admin.firestore(); - const querySnapshot = await db.collection("apiKeys") - .where("apiKey", "==", apiKey) - .get(); - info("QuerySnapshot: ", querySnapshot); - if (querySnapshot.empty) { - error("Invalid apiKey provided!"); - response.status(403).send({error: "Invalid apiKey provided!"}); - return; - } - const userId = querySnapshot.docs[0].data().userId; - const bucket = admin.storage().bucket(); - const dataToStore = request.body.data; - - const filename = `${userId}/${Date.now()}.json`; - const file = bucket.file(filename); - await file.save(dataToStore); - - response.send({message: "Data stored successfully!"}); -}); - -exports.onUploadData = onObjectFinalized( - {cpu: 4}, async (event) => { - info("Processing uploaded data"); - const file = event.data; - const bucket = admin.storage().bucket(); - const data = await bucket.file(file.name).download(); - const userId = file.name.split("/")[0]; - const dataString = data.toString(); - const jsonData: [RawEvent] = JSON.parse(dataString); + export const getApiKey = functions.https.onCall(async (_, context) => { + /** A callable function only executed when the user is logged in */ + info("Getting ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } const db = admin.firestore(); - const colpath = `screentime/${userId}/${userId}`; - const promises = []; - for (const rawEvent of jsonData) { - // reduce from type RawEvent to Event - const event:Event = { - timestamp: rawEvent.timestamp, - duration: rawEvent.duration, - data: rawEvent.data, - }; - // check if a doc with the same date exists - // date in yyyy-mm-dd format - let date = new Date(event.timestamp).toISOString().split("T")[0]; - date = date.replace(/\//g, "-"); - const doc = await db.collection(colpath).doc(date).get(); - if (doc.exists) { - const events = doc.data()?.events as Event[]; - events.push(event); - const promise = db.collection(colpath).doc(date) - .update({events: events}); - promises.push(promise); - } else { - const promise = db.collection(colpath).doc(date).set({events: [event]}); - promises.push(promise); - } + const colpath = "users"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + info("ApiKey found and sent to client"); + return {apiKey: doc.data()?.apiKey}; + } else { + const apiKey = genKey.generateApiKey(); + await docref.set({apiKey: apiKey}); + info("ApiKey set and sent to client"); + return {apiKey: apiKey}; + } + }); + export const rotateApiKey = functions.https.onCall(async (_, context) => { + /** A callable function only executed when the user is logged in */ + info("Rotating ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } + const db = admin.firestore(); + const colpath = "apiKeys"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + const apiKey = genKey.generateApiKey(); + await docref.update({apiKey: apiKey}); + info("ApiKey rotated and sent to client"); + return {apiKey: apiKey}; + } else { + await docref.set({apiKey: genKey.generateApiKey()}); + info("ApiKey set and sent to client"); + return {apiKey: doc.data()?.apiKey}; + } + }); + + exports.uploadData = onRequest(async (request, response) => { + info("Storing data"); + info("Request data: ", request.body); + if (!request.body.apiKey) { + error("No apiKey provided!"); + response.status(400).send({error: "No apiKey provided!"}); + return; } - await Promise.all(promises); - info("Data processed successfully"); + if (!request.body.data) { + error("No data provided to store!"); + response.status(400).send({error: "No data provided!"}); + return; + } + + const apiKey = request.body.apiKey; + const db = admin.firestore(); + const querySnapshot = await db.collection("users") + .where("apiKey", "==", apiKey) + .get(); + info("QuerySnapshot: ", querySnapshot); + if (querySnapshot.empty) { + error("Invalid apiKey provided!"); + response.status(403).send({error: "Invalid apiKey provided!"}); + return; + } + const userId = querySnapshot.docs[0].id; + const bucket = admin.storage().bucket(); + const dataToStore = request.body.data; + + const filename = `${userId}/${Date.now()}.json`; + const file = bucket.file(filename); + await file.save(dataToStore); + + response.send({message: "Data stored successfully!"}); }); + + exports.onUploadData = onObjectFinalized( + {cpu: 4}, async (event) => { + info("Processing uploaded data"); + const file = event.data; + const bucket = admin.storage().bucket(); + const data = await bucket.file(file.name).download(); + const userId = file.name.split("/")[0]; + const dataString = data.toString(); + const jsonData: [RawEvent] = JSON.parse(dataString); + const db = admin.firestore(); + const colpath = `screentime/${userId}/${userId}`; + const promises = []; + for (const rawEvent of jsonData) { + // reduce from type RawEvent to Event + const event:Event = { + timestamp: rawEvent.timestamp, + duration: rawEvent.duration, + data: rawEvent.data, + }; + // check if a doc with the same date exists + // date in yyyy-mm-dd format + let date = new Date(event.timestamp).toISOString().split("T")[0]; + date = date.replace(/\//g, "-"); + const doc = await db.collection(colpath).doc(date).get(); + if (doc.exists) { + const events = doc.data()?.events as Event[]; + events.push(event); + const promise = db.collection(colpath).doc(date) + .update({events: events}); + promises.push(promise); + } else { + const promise = db.collection(colpath).doc(date).set({events: [event]}); + promises.push(promise); + } + } + await Promise.all(promises); + info("Data processed successfully"); + }); + \ No newline at end of file From 9a7038ac5a6959a76c7e27fb0c41dddca9ab5fed Mon Sep 17 00:00:00 2001 From: brayo Date: Fri, 5 Apr 2024 16:49:12 +0300 Subject: [PATCH 06/10] delete firebase rules --- src/firebase/firebase.rules | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 src/firebase/firebase.rules diff --git a/src/firebase/firebase.rules b/src/firebase/firebase.rules deleted file mode 100644 index e483f81..0000000 --- a/src/firebase/firebase.rules +++ /dev/null @@ -1,19 +0,0 @@ -rules_version = '2'; - -service cloud.firestore { - match /databases/{database}/documents { - - // This rule allows anyone with your Firestore database reference to view, edit, - // and delete all data in your Firestore database. It is useful for getting - // started, but it is configured to expire after 30 days because it - // leaves your app open to attackers. At that time, all client - // requests to your Firestore database will be denied. - // - // Make sure to write security rules for your app before that time, or else - // all client requests to your Firestore database will be denied until you Update - // your rules - match /{document=**} { - allow read, write: if request.time < timestamp.date(2023, 8, 4); - } - } -} \ No newline at end of file From 6ed7e112a2e4c4465ce1d14acdce859381a6d09a Mon Sep 17 00:00:00 2001 From: brayo Date: Fri, 5 Apr 2024 16:50:44 +0300 Subject: [PATCH 07/10] update and symlink rules --- functions/firestore.indexes.json | 19 +++++++++++++++++++ functions/firestore.rules | 16 ++++++++++++++++ functions/storage.rules | 6 ++++++ 3 files changed, 41 insertions(+) create mode 100644 functions/firestore.indexes.json create mode 100644 functions/firestore.rules create mode 100644 functions/storage.rules diff --git a/functions/firestore.indexes.json b/functions/firestore.indexes.json new file mode 100644 index 0000000..b0b8193 --- /dev/null +++ b/functions/firestore.indexes.json @@ -0,0 +1,19 @@ +{ + "indexes": [ + { + "collectionGroup": "O2TIwM6QoIhCBbcCCUggf0c7xMJ3", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "public", + "order": "ASCENDING" + }, + { + "fieldPath": "date", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} diff --git a/functions/firestore.rules b/functions/firestore.rules new file mode 100644 index 0000000..d164847 --- /dev/null +++ b/functions/firestore.rules @@ -0,0 +1,16 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + match /screentime/{userId}/{document=**} { + allow read, write: if request.auth.uid == userId; + } + match /leaderboard/{document=**} { + allow read: if true; + allow write: if false; + } + match /users/{userId}/{document=**} { + allow read, write: if request.auth.uid == userId; + } + } +} \ No newline at end of file diff --git a/functions/storage.rules b/functions/storage.rules new file mode 100644 index 0000000..0fd93d4 --- /dev/null +++ b/functions/storage.rules @@ -0,0 +1,6 @@ +rules_version = '2'; +service cloud.storage { + match /{userId}/{allPaths=**} { + allow read, write: if request.auth.uid == userId; + } +} From 71adac00ff798f5df81e911b458801673cfe56ac Mon Sep 17 00:00:00 2001 From: brayo Date: Fri, 5 Apr 2024 16:55:41 +0300 Subject: [PATCH 08/10] remove volar from the README.md deprecated --- README.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a16e022..bdfd532 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,15 @@ The below was automatically generated on repo initialization. ## Recommended IDE Setup -[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). - -## Type Support for `.vue` Imports in TS - -TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. - -If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: - -1. Disable the built-in TypeScript Extension - 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette - 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` -2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. +[VSCode](https://code.visualstudio.com/) + [TypeScript Vue Plugin](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). ## Customize configuration See [Vite Configuration Reference](https://vitejs.dev/config/). ## Project Setup + + ### Clone the Repo and its submodules ```sh git clone --recurse-submodules https://github.com/ActivityWatch/aw-leaderboard-firebase From fb2072be49e93a5ea695fa5202e3b83b635aa0d0 Mon Sep 17 00:00:00 2001 From: brayo Date: Sun, 7 Apr 2024 12:41:32 +0300 Subject: [PATCH 09/10] Update types.ts to include new interfaces and defaultCategories --- functions/src/index.ts | 373 ++++++++++++++++++++++------------------- functions/src/types.ts | 123 +++++++++++--- 2 files changed, 304 insertions(+), 192 deletions(-) diff --git a/functions/src/index.ts b/functions/src/index.ts index b8e99be..a8199a3 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,19 +1,14 @@ -// import {onSchedule} from "firebase-functions/v2/scheduler"; -import { - onDocumentCreated, - onDocumentUpdated -} from "firebase-functions/v2/firestore"; - import {onRequest} from "firebase-functions/v2/https"; import {onObjectFinalized} from "firebase-functions/v2/storage"; +import {onSchedule} from "firebase-functions/v2/scheduler"; import * as admin from "firebase-admin"; -import {error, info} from "firebase-functions/lib/logger"; import * as functions from "firebase-functions"; import * as genKey from "generate-api-key"; +import {info, error} from "firebase-functions/logger"; +import {RawEvent, defaultCategories, Event} from "./types"; -import {Event, RawEvent} from "./types"; +admin.initializeApp(); -admin.initializeApp(); // TODO: cleanup global scope imports and inits exports.onUserCreated = functions.auth.user().onCreate((user) => { info("User created: ", user.uid); const db = admin.firestore(); @@ -31,175 +26,209 @@ exports.onUserDeleted = functions.auth.user().onDelete((user) => { const docpath = user.uid; return db.collection(colpath).doc(docpath).delete(); }); -exports.CalculateCategoryTotals = onDocumentCreated( - "leaderboard/{userId}", (event) => { - info("Calculating totals"); - const snapshot = event.data; - if (!snapshot) { - error("Document does not exist"); - return; - } - const data = snapshot.data(); - const categoriesTotals = data.CategoryTotals as { [key: string]: number }; - let total = 0; - for (const category in categoriesTotals) { - /* eslint guard-for-in: 1 */ - total += categoriesTotals[category]; - } - const db = admin.firestore(); - const colpath = "leaderboard"; - db.collection(colpath).doc(snapshot.id).update({total: total}).then(() => { - info("Total updated"); - }).catch((error) => { - error("Error updating total: ", error); - }); - }); - -exports.UpdateCategoryTotals = onDocumentUpdated( - "leaderboard/{userId}", (event) => { - info("Updating totals on document update"); - const snapshot = event.data; - if (!snapshot) { - error("Document does not exist"); - return; - } - const data = snapshot.after.data(); - const categoriesTotals = data.CategoryTotals as { [key: string]: number }; - let total = 0; - for (const category in categoriesTotals) { - /* eslint guard-for-in: 1 */ - total += categoriesTotals[category]; - } - const db = admin.firestore(); - const colpath = "leaderboard"; - db.collection(colpath).doc(snapshot.after.id) - .update({total: total}).then(() => { - info("Total updated"); - }).catch((error) => { - error("Error updating total: ", error); - }); - }); - export const getApiKey = functions.https.onCall(async (_, context) => { - /** A callable function only executed when the user is logged in */ - info("Getting ApiKey"); - info("Request data: ", context); - if (!context.auth) { - error("Not authenticated"); - return {error: "Not authenticated"}; - } +export const UpdateLeaderboardData = onSchedule( + "every day 00:00", async (event) => { + // WIP + info("Updating leaderboard data"); const db = admin.firestore(); - const colpath = "users"; - const docpath = context.auth?.uid; - const docref = db.collection(colpath).doc(docpath); - const doc = await docref.get(); - if (doc.exists && doc.data() && doc.data()?.apiKey) { - info("ApiKey found and sent to client"); - return {apiKey: doc.data()?.apiKey}; - } else { - const apiKey = genKey.generateApiKey(); - await docref.set({apiKey: apiKey}); - info("ApiKey set and sent to client"); - return {apiKey: apiKey}; - } - }); - export const rotateApiKey = functions.https.onCall(async (_, context) => { - /** A callable function only executed when the user is logged in */ - info("Rotating ApiKey"); - info("Request data: ", context); - if (!context.auth) { - error("Not authenticated"); - return {error: "Not authenticated"}; - } - const db = admin.firestore(); - const colpath = "apiKeys"; - const docpath = context.auth?.uid; - const docref = db.collection(colpath).doc(docpath); - const doc = await docref.get(); - if (doc.exists && doc.data() && doc.data()?.apiKey) { - const apiKey = genKey.generateApiKey(); - await docref.update({apiKey: apiKey}); - info("ApiKey rotated and sent to client"); - return {apiKey: apiKey}; - } else { - await docref.set({apiKey: genKey.generateApiKey()}); - info("ApiKey set and sent to client"); - return {apiKey: doc.data()?.apiKey}; - } - }); - - exports.uploadData = onRequest(async (request, response) => { - info("Storing data"); - info("Request data: ", request.body); - if (!request.body.apiKey) { - error("No apiKey provided!"); - response.status(400).send({error: "No apiKey provided!"}); - return; - } - if (!request.body.data) { - error("No data provided to store!"); - response.status(400).send({error: "No data provided!"}); - return; + const destinationColpath = "leaderboard"; + const screentimeColpath = "screentime/"; + const screenTimeDocs = await db.collection(screentimeColpath) + .listDocuments(); + const totals = new Map(); + const promises = []; + for (const userDoc of screenTimeDocs) { + const sourceColpath = "screentime/" + userDoc.id + "/" + userDoc.id; + const screenTimeUserDocs = await db.collection(sourceColpath) + .listDocuments(); + for (const screenTimeUserDoc of screenTimeUserDocs) { + const doc = await screenTimeUserDoc.get(); + const events = doc.data()?.events as Event[]; + // info("events: ", events); + totals.set("total", 0); + for (const event of events) { + const category = event.category.length > 0 ? + event.category[0] as string : "Other"; + // info("category: ", category)"; + totals.set(category, (totals.get(category) || 0) + event.duration); + totals.set("total", (totals.get("total") || 0) + event.duration); + } + // const total = totals.get("Total"); + const userId = (await screenTimeUserDoc.get()).data()?.userId; + const date = (await screenTimeUserDoc.get()).data()?.date; + totals.delete("total"); + for (const [category, total] of totals.entries()) { + const docpath = date; + const docref = db.collection(destinationColpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists) { + const categoryTotals = doc.data() + ?.CategoryTotals as { [key: string]: number }; + const keys = Object.keys(categoryTotals); + if (keys.includes(category)) { + categoryTotals[category] += total; + } else { + categoryTotals[category] = total; + } + const promise = docref + .update({CategoryTotals: categoryTotals, + total: total + doc.data()?.total}); + promises.push(promise); + } else { + const jsonObj = Object.fromEntries(totals); + const promise = docref + .set({CategoryTotals: jsonObj, + total: total, userId: userId, date: date}); + promises.push(promise); + } + } + } + await Promise.all(promises); } - - const apiKey = request.body.apiKey; + } +); +export const getApiKey = functions.https.onCall(async (_, context) => { + /** A callable function only executed when the user is logged in */ + info("Getting ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } + const db = admin.firestore(); + const colpath = "users"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + info("ApiKey found and sent to client"); + return {apiKey: doc.data()?.apiKey}; + } else { + const apiKey = genKey.generateApiKey(); + await docref.set({apiKey: apiKey}); + info("ApiKey set and sent to client"); + return {apiKey: apiKey}; + } +}); +export const rotateApiKey = functions.https.onCall(async (_, context) => { + /** A callable function only executed when the user is logged in */ + info("Rotating ApiKey"); + info("Request data: ", context); + if (!context.auth) { + error("Not authenticated"); + return {error: "Not authenticated"}; + } + const db = admin.firestore(); + const colpath = "apiKeys"; + const docpath = context.auth?.uid; + const docref = db.collection(colpath).doc(docpath); + const doc = await docref.get(); + if (doc.exists && doc.data() && doc.data()?.apiKey) { + const apiKey = genKey.generateApiKey(); + await docref.update({apiKey: apiKey}); + info("ApiKey rotated and sent to client"); + return {apiKey: apiKey}; + } else { + await docref.set({apiKey: genKey.generateApiKey()}); + info("ApiKey set and sent to client"); + return {apiKey: doc.data()?.apiKey}; + } +}); + +exports.uploadData = onRequest(async (request, response) => { + info("Storing data"); + info("Request data: ", request.body); + if (!request.body.apiKey) { + error("No apiKey provided!"); + response.status(400).send({error: "No apiKey provided!"}); + return; + } + if (!request.body.data) { + error("No data provided to store!"); + response.status(400).send({error: "No data provided!"}); + return; + } + + const apiKey = request.body.apiKey; + const db = admin.firestore(); + const querySnapshot = await db.collection("users") + .where("apiKey", "==", apiKey) + .get(); + info("QuerySnapshot: ", querySnapshot); + if (querySnapshot.empty) { + error("Invalid apiKey provided!"); + response.status(403).send({error: "Invalid apiKey provided!"}); + return; + } + const userId = querySnapshot.docs[0].id; + const bucket = admin.storage().bucket(); + const dataToStore = request.body.data; + + const filename = `${userId}/${Date.now()}.json`; + const file = bucket.file(filename); + await file.save(dataToStore); + + response.send({message: "Data stored successfully!"}); +}); + +exports.onUploadData = onObjectFinalized( + {cpu: 4, memory: "2GiB"}, async (event) => { + info("Processing uploaded data"); + const file = event.data; + const bucket = admin.storage().bucket(); + const data = await bucket.file(file.name).download(); + const userId = file.name.split("/")[0]; + const dataString = data.toString(); + const jsonData: [RawEvent] = JSON.parse(dataString); const db = admin.firestore(); - const querySnapshot = await db.collection("users") - .where("apiKey", "==", apiKey) - .get(); - info("QuerySnapshot: ", querySnapshot); - if (querySnapshot.empty) { - error("Invalid apiKey provided!"); - response.status(403).send({error: "Invalid apiKey provided!"}); - return; + const batch = db.batch(); + const colpath = `screentime/${userId}/${userId}`; + const promises = []; + const dateMap = new Map(); + for (const rawEvent of jsonData) { + // reduce from type RawEvent to Event + let category = [] as string[]; + for (const cat of defaultCategories) { + if (cat.rule.regex?.test(rawEvent.data?.app)) { + category = cat.name; + break; + } + } + const event:Event = { + timestamp: rawEvent.timestamp, + duration: rawEvent.duration, + data: rawEvent.data, + category: category, + }; + + let date = new Date(event.timestamp).toISOString().split("T")[0]; + date = date.replace(/\//g, "-"); + if (dateMap.has(date)) { + dateMap.get(date)?.push(event); + } else { + dateMap.set(date, [event]); + } } - const userId = querySnapshot.docs[0].id; - const bucket = admin.storage().bucket(); - const dataToStore = request.body.data; - - const filename = `${userId}/${Date.now()}.json`; - const file = bucket.file(filename); - await file.save(dataToStore); - - response.send({message: "Data stored successfully!"}); - }); - - exports.onUploadData = onObjectFinalized( - {cpu: 4}, async (event) => { - info("Processing uploaded data"); - const file = event.data; - const bucket = admin.storage().bucket(); - const data = await bucket.file(file.name).download(); - const userId = file.name.split("/")[0]; - const dataString = data.toString(); - const jsonData: [RawEvent] = JSON.parse(dataString); - const db = admin.firestore(); - const colpath = `screentime/${userId}/${userId}`; - const promises = []; - for (const rawEvent of jsonData) { - // reduce from type RawEvent to Event - const event:Event = { - timestamp: rawEvent.timestamp, - duration: rawEvent.duration, - data: rawEvent.data, - }; - // check if a doc with the same date exists - // date in yyyy-mm-dd format - let date = new Date(event.timestamp).toISOString().split("T")[0]; - date = date.replace(/\//g, "-"); - const doc = await db.collection(colpath).doc(date).get(); + const dates = dateMap.entries(); + for (const [date, events] of dates) { + const promise = db.collection(colpath).doc(date).get().then((doc) => { if (doc.exists) { const events = doc.data()?.events as Event[]; - events.push(event); - const promise = db.collection(colpath).doc(date) - .update({events: events}); - promises.push(promise); + events.push(...events); + batch.update(doc.ref, {events: events}); } else { - const promise = db.collection(colpath).doc(date).set({events: [event]}); - promises.push(promise); + batch.set(db.collection(colpath).doc(date), { + events: [...events], + userId: userId, + date: date, + public: true, + }); } - } - await Promise.all(promises); - info("Data processed successfully"); - }); - \ No newline at end of file + }); + promises.push(promise); + } + await Promise.all(promises); + await batch.commit(); + info("Data processed successfully"); + }); diff --git a/functions/src/types.ts b/functions/src/types.ts index 41fe274..f4e3d68 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -1,38 +1,121 @@ export interface Event { - timestamp: string - duration: number - data: [] + timestamp: string; + duration: number; + data: any; + category: string[]; } export interface RawEvent { - id: number - timestamp: string - duration: number - data: [] + id: number; + timestamp: string; + duration: number; + data: any; } // Screentime is stored in day-level objects that have // their `events` appended when new data is added. export interface ScreenTimeData { - userId: string - public: boolean - date: string - events: Event[] + userId: string; + public: boolean; + date: string; + events: Event[]; } // Same as above, but with events aggregated export interface ScreenTimeSummary { - userId: string - total: number - date: string - categoryTotals: { [key: string]: number } + userId: string; + total: number; + date: string; + categoryTotals: { [key: string]: number }; } export interface ChartDataset { - label: string - data: number[] - backgroundColor: string + label: string; + data: number[]; + backgroundColor: string; } export interface ChartData { - labels: string[] - datasets: ChartDataset[] + labels: string[]; + datasets: ChartDataset[]; } + +export interface Rule { + type: "regex" | "none"; + regex?: RegExp + ignore_case?: boolean; +} + +export interface Category { + id?: number; + name: string[]; + name_pretty?: string; + subname?: string; + rule: Rule; + data?: Record; + depth?: number; + parent?: string[]; + children?: Category[]; +} + +export const defaultCategories: Category[] = [ + { + name: ["Work"], + rule: {type: "regex", regex: /Google Docs|libreoffice|ReText/gi}, + }, + { + name: ["Work", "Programming"], + rule: { + type: "regex", + // eslint-disable-next-line max-len + regex: /GitHub|Stack Overflow|BitBucket|Gitlab|vim|Spyder|kate|Ghidra|Scite/gi, + }, + }, + { + name: ["Work", "Programming", "ActivityWatch"], + rule: {type: "regex", regex: /ActivityWatch|aw-/gi, ignore_case: true}, + }, + {name: ["Work", "Image"], rule: {type: "regex", regex: /GIMP|Inkscape/gi}}, + {name: ["Work", "Video"], rule: {type: "regex", regex: /Kdenlive/gi}}, + {name: ["Work", "Audio"], rule: {type: "regex", regex: /Audacity/gi}}, + {name: ["Work", "3D"], rule: {type: "regex", regex: /Blender/gi}}, + { + name: ["Media", "Games"], + rule: {type: "regex", regex: /Minecraft|RimWorld/gi}, + }, + { + name: ["Media", "Video"], + rule: {type: "regex", regex: /YouTube|Plex|VLC/gi}, + }, + { + name: ["Media", "Social Media"], + rule: { + type: "regex", + regex: /reddit|Facebook|Twitter|Instagram|devRant/gi, + ignore_case: true, + }, + }, + { + name: ["Media", "Music"], + rule: { + type: "regex", + regex: /Spotify|Deezer/gi, + ignore_case: true, + }, + }, + { + name: ["Comms"], + rule: { + type: "regex", + regex: /Slack|Riot|Element|Discord|Nheko|NeoChat|Mattermost/gi, + }, + }, + { + name: ["Comms", "IM"], + rule: { + type: "regex", + // eslint-disable-next-line max-len + regex: /Messenger|Telegram|Signal|WhatsApp|Rambox|Slack|Riot|Element|Discord|Nheko|NeoChat|Mattermost/gi, + }, + }, + // eslint-disable-next-line max-len + {name: ["Comms", "Email"], rule: {type: "regex", regex: /Gmail|Thunderbird|mutt|alpine/gi}}, +]; From ad0a1e33f5f861596c5ab96672b82c9c1833fcc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Fri, 12 Apr 2024 11:02:51 +0200 Subject: [PATCH 10/10] docs: added links to previous attempts, wrapped original readme in
tag --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bdfd532..0839a34 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,14 @@ It uses Vue 3, Firebase (for auth/storage), and Bootstrap. The goal was to make a leaderboard app that complements the local-first nature of ActivityWatch with basic social features like public leaderboards or sharing specific screentime data privately with a group. +Previous experiments/attempts were made in: + - [aw-leaderboard-rust](https://github.com/activitywatch/aw-leaderboard-rust). + - [aw-supabase](https://github.com/ActivityWatch/aw-supabase) + --- -The below was automatically generated on repo initialization. +
+Click to expand the initial README ## Recommended IDE Setup @@ -74,3 +79,5 @@ npm run test:e2e ```sh npm run lint ``` + +