diff --git a/src/api/http.ts b/src/api/http.ts index 89a9822ca..934c4b483 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -32,6 +32,20 @@ export async function fetchPost(url: string, body?: T, option?: Option): Prom } } +export async function fetchPut(url: string, body?: T, option?: Option): Promise { + try { + const response = await fetch(url, { + ...(option || {}), + method: "PUT", + body: body ? JSON.stringify(body) : null, + }) + return response + } catch (e) { + console.error("Failed to fetch post", e) + throw Error(e) + } +} + export async function fetchPutText(url: string, bodyText?: string, option?: Option): Promise { try { const response = await fetch(url, { diff --git a/src/api/quantified-resume.ts b/src/api/quantified-resume.ts new file mode 100644 index 000000000..826852863 --- /dev/null +++ b/src/api/quantified-resume.ts @@ -0,0 +1,99 @@ +import { fetchDelete, fetchGet, fetchPost, fetchPut } from "./http" + +const QR_BUILTIN_TYPE = "BrowserTime" +export const DEFAULT_ENDPOINT = "http://localhost:12233" + +export type BucketPayload = Pick + +export type Bucket = { + id?: number + no?: number + name: string + builtin: "BrowserTime" + builtinRefId: string + status?: "Enabled" | "Disabled" + desc?: string + created?: number + lastModified?: number + payload?: BucketPayload +} + +export type Item = { + refId: string + timestamp: number + name?: string + action: string + metrics: { + visit: number + focus: number + host: string + } + payload?: Record +} + +export type QrRequestContext = { + endpoint?: string +} + +const headers = (): Headers => { + const headers = new Headers() + headers.append("Content-Type", "application/json") + return headers +} + +export const listBuckets = async (ctx: QrRequestContext, clientId?: string): Promise => { + const response = await fetchGet(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket?bt=${QR_BUILTIN_TYPE}&bt_rid=${clientId || ''}`) + return handleResponseJson(response) +} + +export const createBucket = async (ctx: QrRequestContext, bucket: Bucket): Promise => { + const response = await fetchPost(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket`, bucket, { headers: headers() }) + return handleResponseJson(response) +} + +async function handleResponseJson(response: Response): Promise { + await handleResponse(response) + return response.json() +} + +async function handleResponse(response: Response): Promise { + const status = response?.status + if (status === 200) { + return + } + let resMsg = null + try { + resMsg = (await response.json()).message + } catch { } + if (resMsg) { + throw new Error(resMsg) + } + if (status === 422) { + throw new Error("Failed to connect Quantified Resume, please contact the developer") + } else if (status === 500) { + throw new Error("Internal server error") + } else { + console.error(response) + throw new Error(`Unexpected status code: ${status}, url=${response.url}`) + } +} + +export const updateBucket = async (ctx: QrRequestContext, bucket: Bucket): Promise => { + const response = await fetchPut(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket/${bucket?.id}`, bucket, { headers: headers() }) + await handleResponse(response) +} + +export const batchCreateItems = async (ctx: QrRequestContext, bucketId: number, items: Item[]): Promise => { + const response = await fetchPost(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket/${bucketId}/item`, items, { headers: headers() }) + await handleResponse(response) +} + +export const listAllItems = async (ctx: QrRequestContext, bucketId: number): Promise => { + const response = await fetchGet(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket/${bucketId}/item`, { headers: headers() }) + return await handleResponseJson(response) +} + +export const removeBucket = async (ctx: QrRequestContext, bucketId: number): Promise => { + const response = await fetchDelete(`${ctx?.endpoint || DEFAULT_ENDPOINT}/api/0/bucket/${bucketId}?force=true`, { headers: headers() }) + await handleResponse(response) +} \ No newline at end of file diff --git a/src/app/components/Option/components/BackupOption/index.tsx b/src/app/components/Option/components/BackupOption/index.tsx index 3d73c59c8..c24c94229 100644 --- a/src/app/components/Option/components/BackupOption/index.tsx +++ b/src/app/components/Option/components/BackupOption/index.tsx @@ -8,6 +8,9 @@ import { DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, } from "@api/obsidian" +import { + DEFAULT_ENDPOINT as DEFAULT_QR_ENDPOINT, +} from "@api/quantified-resume" import { t } from "@app/locale" import { AUTHOR_EMAIL } from "@src/package" import { ElAlert, ElInput, ElOption, ElSelect } from "element-plus" @@ -25,13 +28,15 @@ const ALL_TYPES: timer.backup.Type[] = [ 'gist', 'web_dav', 'obsidian_local_rest_api', + 'quantified_resume', ] const TYPE_NAMES: { [t in timer.backup.Type]: string } = { none: t(msg => msg.option.backup.meta.none.label), gist: 'GitHub Gist', obsidian_local_rest_api: 'Obsidian - Local REST API', - web_dav: 'WebDAV' + web_dav: 'WebDAV', + quantified_resume: 'Quantified Resume', } const _default = defineComponent((_props, ctx) => { @@ -172,6 +177,20 @@ const _default = defineComponent((_props, ctx) => { /> } + {backupType.value === 'quantified_resume' && <> + msg.option.backup.label.endpoint} + v-slots={{ info: () => '' }} + > + setExtField('endpoint', val)} + /> + + } msg.option.backup.client}> coordinator: timer.backup.Coordinator errorMsg?: string } +function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { + const type = option?.backupType || 'none' + const token = option?.backupAuths?.[type] + const login = option.backupLogin?.[type] + return { token, login } +} + class CoordinatorContextWrapper implements timer.backup.CoordinatorContext { auth: timer.backup.Auth ext?: timer.backup.TypeExt cache: Cache type: timer.backup.Type cid: string + cname: string + + constructor(cid: string, option: timer.option.BackupOption) { + const { backupType, backupExts, clientName } = option || {} + this.type = backupType || 'none' + this.ext = backupExts?.[this.type] + this.auth = prepareAuth(option) - constructor(cid: string, auth: timer.backup.Auth, ext: timer.backup.TypeExt, type: timer.backup.Type) { this.cid = cid - this.auth = auth - this.ext = ext - this.type = type + this.cname = clientName } async init(): Promise> { @@ -133,13 +143,6 @@ function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClient return true } -function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { - const type = option?.backupType || 'none' - const token = option?.backupAuths?.[type] - const login = option.backupLogin?.[type] - return { token, login } -} - export type RemoteQueryParam = { start: Date end: Date @@ -158,18 +161,17 @@ class Processor { gist: new GistCoordinator(), obsidian_local_rest_api: new ObsidianCoordinator(), web_dav: new WebDAVCoordinator(), + quantified_resume: new QuantifiedResumeCoordinator(), } } async syncData(): Promise> { - const { option, auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + const { type, context, coordinator, errorMsg } = await this.checkAuth() if (errorMsg) return error(errorMsg) - - const cid = await lazyGetCid() - const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + let cid = context.cid const client: timer.backup.Client = { id: cid, - name: option.clientName, + name: context.cname, minDate: undefined, maxDate: undefined } @@ -191,49 +193,47 @@ class Processor { } async listClients(): Promise> { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + const { context, coordinator, errorMsg } = await this.checkAuth() if (errorMsg) return error(errorMsg) - const cid = await lazyGetCid() - const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + await context.init() const clients = await coordinator.listAllClients(context) return success(clients) } async checkAuth(): Promise { const option = (await optionService.getAllOption()) as timer.option.BackupOption - const type = option?.backupType || 'none' - const ext = option?.backupExts?.[type] - const auth = prepareAuth(option) + const type = option.backupType + const cid = await lazyGetCid() + const context = new CoordinatorContextWrapper(cid, option) - const coordinator: timer.backup.Coordinator = type && this.coordinators[type] + const coordinator: timer.backup.Coordinator = this.coordinators?.[context.type] if (!coordinator) { // no coordinator, do nothing - return { option, auth, ext, type, coordinator, errorMsg: "Invalid type" } + return { type, context, coordinator, errorMsg: "Invalid type" } } let errorMsg: string try { - errorMsg = await coordinator.testAuth(auth, ext) + errorMsg = await coordinator.testAuth(context.auth, context.ext) } catch (e) { errorMsg = (e as Error)?.message || 'Unknown error' } - return { option, auth, ext, type, coordinator, errorMsg } + return { type, context, coordinator, errorMsg } } async query(param: RemoteQueryParam): Promise { - const { type, coordinator, auth, ext, errorMsg } = await this.checkAuth() + const { coordinator, context, errorMsg } = await this.checkAuth() if (errorMsg || !coordinator) { return [] } const { start = getBirthday(), end, specCid, excludeLocal } = param - let localCid = await metaService.getCid() // 1. init context - const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() + await context.init() // 2. query all clients, and filter them let startStr = start ? formatTimeYMD(start) : undefined let endStr = end ? formatTimeYMD(end) : undefined const allClients = (await coordinator.listAllClients(context)) - .filter(c => filterClient(c, excludeLocal, localCid, startStr, endStr)) + .filter(c => filterClient(c, excludeLocal, context.cid, startStr, endStr)) .filter(c => !specCid || c.id === specCid) // 3. iterate clients const result: timer.stat.Row[] = [] @@ -255,10 +255,9 @@ class Processor { } async clear(cid: string): Promise> { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + const { context, coordinator, errorMsg } = await this.checkAuth() if (errorMsg) return error(errorMsg) - let localCid = await metaService.getCid() - const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() + await context.init() // 1. Find the client const allClients = await coordinator.listAllClients(context) const client = allClients?.filter(c => c?.id === cid)?.[0] diff --git a/src/common/backup/quantified-resume/coordinator.ts b/src/common/backup/quantified-resume/coordinator.ts new file mode 100644 index 000000000..d58a4b39d --- /dev/null +++ b/src/common/backup/quantified-resume/coordinator.ts @@ -0,0 +1,141 @@ +import { batchCreateItems, Bucket, createBucket, Item, listAllItems, listBuckets, removeBucket, updateBucket } from "@api/quantified-resume" +import { t } from "@i18n" +import metaMessages from "@i18n/message/common/meta" +import { groupBy } from "@util/array" +import { formatTimeYMD, parseTime } from "@util/time" + +export type QuantifiedResumeCache = { + bucketIds: { + // clientId => bucketId + [clientId: string]: number + } +} + +async function createNewBucket(context: timer.backup.CoordinatorContext): Promise { + const { cid, cname } = context || {} + const { endpoint } = context?.ext || {} + const appName = t(metaMessages, { key: msg => msg.name }) + const bucket: Bucket = { + name: `${appName}: ${cid}`, + builtin: "BrowserTime", + builtinRefId: cid, + payload: { name: cname } + } + return createBucket({ endpoint }, bucket) +} + +async function getBucketId(context: timer.backup.CoordinatorContext, specificCid?: string): Promise { + const cid = specificCid || context?.cid + const { cache } = context || {} + // 1. query from cache + let bucketId = cache?.bucketIds?.[cid] + if (bucketId) return bucketId + + const { endpoint } = context?.ext || {} + // 2. query again + bucketId = (await listBuckets({ endpoint }, cid))?.filter(b => b.builtinRefId === cid)?.[0]?.id + if (!bucketId) { + // 3. create one + bucketId = await createNewBucket(context) + } + return bucketId +} + +export default class QuantifiedResumeCoordinator implements timer.backup.Coordinator { + async updateClients(context: timer.backup.CoordinatorContext, clients: timer.backup.Client[]): Promise { + const { endpoint } = context?.ext || {} + const existBuckets = groupBy(await listBuckets({ endpoint }) || [], b => b.builtinRefId, l => l?.[0]) + if (!clients?.length) return + const promises = Promise.all(clients.map( + async ({ id, name, minDate, maxDate }) => { + const exist = existBuckets[id] + if (exist) { + // update payload + exist.payload = { name, minDate, maxDate } + await updateBucket({ endpoint }, exist) + } else { + await createNewBucket(context) + } + }) + ) + await promises + } + + async listAllClients(context: timer.backup.CoordinatorContext): Promise { + const { endpoint } = context?.ext || {} + const buckets = await listBuckets({ endpoint }) + + let result: timer.backup.Client[] = [] + let bucketIds: { [clientId: string]: number } = {} + buckets?.forEach(({ payload, id: bucketId, builtinRefId, name: bucketName }) => { + let { name, minDate, maxDate } = payload || {} + const client: timer.backup.Client = { + id: builtinRefId, + name: name || bucketName, + minDate, maxDate, + } + result.push(client) + bucketIds[builtinRefId] = bucketId + }) + + context.cache = { ...(context.cache || {}), bucketIds } + await context.handleCacheChanged() + + return result + } + + async download(context: timer.backup.CoordinatorContext, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + let bucketId = await getBucketId(context, targetCid) + if (!bucketId) return [] + const items = await listAllItems({ endpoint: context?.ext?.endpoint }, bucketId) + return items?.map(({ name, timestamp, metrics }) => ({ + host: name, + date: formatTimeYMD(timestamp), + focus: metrics?.focus, + time: metrics?.visit, + } satisfies timer.stat.RowBase)) || [] + } + + async upload(context: timer.backup.CoordinatorContext, rows: timer.stat.RowBase[]): Promise { + if (!rows?.length) return + + const bucketId = await getBucketId(context) + let items = rows.map(({ host, date, focus, time: visit }) => { + const time = parseTime(date) + time.setHours(0) + time.setMinutes(0) + time.setSeconds(0) + time.setMilliseconds(0) + const item: Item = { + refId: `${date}${host}`, + timestamp: time.getTime(), + metrics: { visit, focus, host }, + action: "web_time", + name: host, + payload: { date, host, cid: context.cid }, + } + return item + }) + const groups = groupBy(items, (_, idx) => Math.floor(idx / 2000), l => l) + + const { endpoint } = context?.ext || {} + for (const group of Object.values(groups)) { + await batchCreateItems({ endpoint }, bucketId, group) + } + } + + async testAuth(_auth: timer.backup.Auth, ext: timer.backup.TypeExt): Promise { + try { + const { endpoint } = ext || {} + await listBuckets({ endpoint }) + } catch (e) { + return (e as Error)?.message || e || 'Unknown error' + } + } + + async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { + const bucketId = await getBucketId(context, client.id) + if (!bucketId) return + await removeBucket({ endpoint: context?.ext?.endpoint }, bucketId) + } +} \ No newline at end of file diff --git a/src/common/backup/web-dav/coordinator.ts b/src/common/backup/web-dav/coordinator.ts index 8b191608e..779e46934 100644 --- a/src/common/backup/web-dav/coordinator.ts +++ b/src/common/backup/web-dav/coordinator.ts @@ -110,7 +110,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator]: { + gist: { + authInfo: string + } + obsidian_local_rest_api: { endpointInfo: string } } diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index c885e2528..3089cc510 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -56,7 +56,7 @@ "changeLog": "Änderungsprotokoll" }, "fr": { - "allFunction": "Toutes les fonctions", + "allFunction": "Page back-end", "guidePage": "Manuel d'utilisateur", "sourceCode": "Code Source", "changeLog": "Historique des changements" diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index 051711d71..082f6f5a1 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -156,7 +156,7 @@ "date": "Дата", "host": "URL-адрес сайта", "focus": "Время просмотра", - "time": "Количество посещений", + "time": "Посещения", "operation": { "add2Whitelist": "Белый список", "removeFromWhitelist": "Включить", diff --git a/src/i18n/message/popup/footer-resource.json b/src/i18n/message/popup/footer-resource.json index 6572dabd7..acf769321 100644 --- a/src/i18n/message/popup/footer-resource.json +++ b/src/i18n/message/popup/footer-resource.json @@ -51,7 +51,7 @@ "updateVersion": "Actualisable", "updateVersionInfo": "Dernière version : {version}", "updateVersionInfo4Firefox": "Mettez à niveau vers là {version} dans la page de gestion, about:addons, s'il vous plaît", - "rate": "Donnez votre avis" + "rate": "Avis" }, "ru": { "updateVersion": "Обновляемый", diff --git a/src/util/array.ts b/src/util/array.ts index 6ed9d1c7a..076677d5f 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -15,12 +15,12 @@ */ export function groupBy( arr: T[], - keyFunc: (e: T) => string | number, + keyFunc: (e: T, idx: number) => string | number, downstream: (grouped: T[], key: string) => R ): { [key: string]: R } { const groupedMap: { [key: string]: T[] } = {} - arr.forEach(e => { - const key = keyFunc(e) + arr.forEach((e, idx) => { + const key = keyFunc(e, idx) if (key === undefined || key === null) { return } diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 66e3b4e8a..82d3ab7ee 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -22,8 +22,9 @@ declare namespace timer.backup { interface CoordinatorContext { cid: string + cname: string + type: timer.backup.Type auth?: Auth - login?: LoginInfo ext?: TypeExt cache: Cache handleCacheChanged: () => Promise @@ -72,6 +73,8 @@ declare namespace timer.backup { | 'obsidian_local_rest_api' // @since 2.4.5 | 'web_dav' + // @since 3.0.0 + | 'quantified_resume' type AuthType = | 'token'