diff --git a/src/app/components/limit/filter.ts b/src/app/components/limit/filter.ts index 24602faa..91edb34e 100644 --- a/src/app/components/limit/filter.ts +++ b/src/app/components/limit/filter.ts @@ -5,17 +5,22 @@ * https://opensource.org/licenses/MIT */ -import { Operation, Plus } from "@element-plus/icons-vue" +import { Operation, Plus, SetUp } from "@element-plus/icons-vue" import { Ref, h, defineComponent, ref } from "vue" import InputFilterItem from "@app/components/common/input-filter-item" import SwitchFilterItem from "@app/components/common/switch-filter-item" import ButtonFilterItem from "@app/components/common/button-filter-item" import { t } from "@app/locale" +import { getAppPageUrl } from "@util/constant/url" +import { OPTION_ROUTE } from "@app/router/constants" +import { createTabAfterCurrent } from "@api/chrome/tab" const urlPlaceholder = t(msg => msg.limit.conditionFilter) const onlyEnabledLabel = t(msg => msg.limit.filterDisabled) const addButtonText = t(msg => msg.button.create) const testButtonText = t(msg => msg.limit.button.test) +const optionButtonText = t(msg => msg.limit.button.option) +const optionPageUrl = getAppPageUrl(false, OPTION_ROUTE, { i: 'dailyLimit' }) const emits = { create: () => true, @@ -60,6 +65,12 @@ const _default = defineComponent({ icon: Operation, onClick: () => ctx.emit('test') }), + h(ButtonFilterItem, { + text: optionButtonText, + icon: SetUp, + type: 'primary', + onClick: () => createTabAfterCurrent(optionPageUrl) + }), h(ButtonFilterItem, { text: addButtonText, type: "success", diff --git a/src/app/components/limit/table/column/common.ts b/src/app/components/limit/table/column/common.ts new file mode 100644 index 00000000..b456c87c --- /dev/null +++ b/src/app/components/limit/table/column/common.ts @@ -0,0 +1,68 @@ +import { t, tN } from "@app/locale" +import { locale } from "@i18n" +import { VerificationPair } from "@service/limit-service/verification/common" +import verificationProcessor from "@service/limit-service/verification/processor" +import { ElMessageBox, ElMessage } from "element-plus" +import { h, VNode } from "vue" + +/** + * Judge wether verification is required + * + * @returns T/F + */ +export function judgeVerificationRequired(item: timer.limit.Item): boolean { + const { waste, time } = item || {} + return !!(waste > time * 1000) +} + +const PROMT_TXT_CSS: Partial = { + userSelect: 'none', +} + +/** + * @returns null if verification not required, + * or promise with resolve invocked only if verification code or password correct + */ +export async function processVerification(option: timer.option.DailyLimitOption): Promise { + const { limitLevel, limitPassword, limitVerifyDifficulty } = option + let answerValue: string + let messageNodes: (VNode | string)[] + let incrorectMessage: string + if (limitLevel === 'password' && limitPassword) { + answerValue = limitPassword + messageNodes = [t(msg => msg.limit.verification.pswInputTip)] + incrorectMessage = t(msg => msg.limit.verification.incorrectPsw) + } else if (limitLevel === 'verification') { + const pair: VerificationPair = verificationProcessor.generate(limitVerifyDifficulty, locale) + const { prompt, promptParam, answer } = pair || {} + answerValue = typeof answer === 'function' ? t(msg => answer(msg.limit.verification)) : answer + incrorectMessage = t(msg => msg.limit.verification.incorrectAnswer) + if (prompt) { + const promptTxt = typeof prompt === 'function' + ? t(msg => prompt(msg.limit.verification), { ...promptParam, answer: answerValue }) + : prompt + messageNodes = tN(msg => msg.limit.verification.inputTip, { prompt: h('b', promptTxt) }) + } else { + messageNodes = tN(msg => msg.limit.verification.inputTip2, { answer: h('b', answerValue) }) + } + } + return messageNodes?.length && answerValue + ? new Promise(resolve => + ElMessageBox({ + boxType: 'prompt', + type: 'warning', + title: '', + message: h('div', { style: PROMT_TXT_CSS }, messageNodes), + showInput: true, + showCancelButton: true, + showClose: true, + }).then(data => { + const { value } = data + if (value === answerValue) { + return resolve() + } + ElMessage.error(incrorectMessage) + }) + ) + : null +} diff --git a/src/app/components/limit/table/column/delay.ts b/src/app/components/limit/table/column/delay.ts index 616aa280..49418b2d 100644 --- a/src/app/components/limit/table/column/delay.ts +++ b/src/app/components/limit/table/column/delay.ts @@ -9,10 +9,24 @@ import { InfoFilled } from "@element-plus/icons-vue" import { ElIcon, ElSwitch, ElTableColumn, ElTooltip } from "element-plus" import { defineComponent, h } from "vue" import { t } from "@app/locale" +import { judgeVerificationRequired, processVerification } from "./common" +import optionService from "@service/option-service" const label = t(msg => msg.limit.item.delayAllowed) const tooltip = t(msg => msg.limit.item.delayAllowedInfo) +async function handleChange(row: timer.limit.Item, newVal: boolean, callback: () => void) { + let promise: Promise = null + if (newVal && judgeVerificationRequired(row)) { + // Open delay for limited rules, so verification is required + const option = await optionService.getAllOption() + promise = processVerification(option) + } + promise + ? promise.then(callback).catch(() => { }) + : callback() +} + const _default = defineComponent({ name: "LimitDelayColumn", emits: { @@ -26,10 +40,10 @@ const _default = defineComponent({ }, { default: ({ row }: { row: timer.limit.Item }) => h(ElSwitch, { modelValue: row.allowDelay, - onChange(val: boolean) { + onChange: (val: boolean) => handleChange(row, val, () => { row.allowDelay = val ctx.emit("rowChange", row, val) - } + }) }), header: () => h('div', [ label, diff --git a/src/app/components/limit/table/column/enabled.ts b/src/app/components/limit/table/column/enabled.ts index 60b1d0ac..6e690fd7 100644 --- a/src/app/components/limit/table/column/enabled.ts +++ b/src/app/components/limit/table/column/enabled.ts @@ -8,9 +8,23 @@ import { ElSwitch, ElTableColumn } from "element-plus" import { defineComponent, h } from "vue" import { t } from "@app/locale" +import { judgeVerificationRequired, processVerification } from "./common" +import optionService from "@service/option-service" const label = t(msg => msg.limit.item.enabled) +async function handleChange(row: timer.limit.Item, newVal: boolean, callback: () => void) { + let promise: Promise = null + if (!newVal && judgeVerificationRequired(row)) { + // Disable limited rules, so verification is required + const option = await optionService.getAllOption() + promise = processVerification(option) + } + promise + ? promise.then(callback).catch(() => { }) + : callback() +} + const _default = defineComponent({ name: "LimitEnabledColumn", emits: { @@ -25,10 +39,10 @@ const _default = defineComponent({ }, { default: ({ row }: { row: timer.limit.Item }) => h(ElSwitch, { modelValue: row.enabled, - onChange(val: boolean) { + onChange: (val: boolean) => handleChange(row, val, () => { row.enabled = val ctx.emit("rowChange", row, val) - } + }) }) }) } diff --git a/src/app/components/limit/table/column/operation.ts b/src/app/components/limit/table/column/operation.ts index e951c6bd..d34a9712 100644 --- a/src/app/components/limit/table/column/operation.ts +++ b/src/app/components/limit/table/column/operation.ts @@ -9,12 +9,40 @@ import { Delete, Edit } from "@element-plus/icons-vue" import { ElButton, ElMessageBox, ElTableColumn } from "element-plus" import { defineComponent, h } from "vue" import { t } from "@app/locale" +import optionService from "@service/option-service" +import { judgeVerificationRequired, processVerification } from "./common" const label = t(msg => msg.limit.item.operation) const deleteButtonText = t(msg => msg.button.delete) const modifyButtonText = t(msg => msg.button.modify) + +async function handleDelete(row: timer.limit.Item, callback: () => void) { + let promise = undefined + if (judgeVerificationRequired(row)) { + const option = await optionService.getAllOption() as timer.option.DailyLimitOption + promise = processVerification(option) + } + if (!promise) { + const message = t(msg => msg.limit.message.deleteConfirm, { cond: row.cond }) + promise = ElMessageBox.confirm(message, { type: 'warning' }) + } + promise.then(callback).catch(() => { /** Do nothing */ }) +} + +async function handleModify(row: timer.limit.Item, callback: () => void) { + let promise: Promise = undefined + if (judgeVerificationRequired(row)) { + const option = await optionService.getAllOption() as timer.option.DailyLimitOption + promise = processVerification(option) + promise + ? promise.then(callback).catch(() => { }) + : callback() + } else { + callback() + } +} + const _default = defineComponent({ - name: "LimitOperationColumn", emits: { rowDelete: (_row: timer.limit.Item, _cond: string) => true, rowModify: (_row: timer.limit.Item) => true, @@ -31,19 +59,13 @@ const _default = defineComponent({ type: 'danger', size: 'small', icon: Delete, - onClick() { - const { cond } = row - const message = t(msg => msg.limit.message.deleteConfirm, { cond }) - ElMessageBox.confirm(message, { type: 'warning' }) - .then(() => ctx.emit("rowDelete", row, cond)) - .catch(() => { /** Do nothing */ }) - } + onClick: () => handleDelete(row, () => ctx.emit("rowDelete", row, row.cond)) }, () => deleteButtonText), h(ElButton, { type: 'primary', size: 'small', icon: Edit, - onClick: () => ctx.emit('rowModify', row), + onClick: () => handleModify(row, () => ctx.emit('rowModify', row)), }, () => modifyButtonText) ] }) diff --git a/src/app/components/limit/table/index.ts b/src/app/components/limit/table/index.ts index 07f4bd18..ff55803a 100644 --- a/src/app/components/limit/table/index.ts +++ b/src/app/components/limit/table/index.ts @@ -15,7 +15,6 @@ import LimitEnabledColumn from "./column/enabled" import LimitOperationColumn from "./column/operation" const _default = defineComponent({ - name: "LimitTable", props: { data: Array as PropType }, diff --git a/src/app/components/option/components/appearance/index.ts b/src/app/components/option/components/appearance/index.ts index 6096dabe..a9b46811 100644 --- a/src/app/components/option/components/appearance/index.ts +++ b/src/app/components/option/components/appearance/index.ts @@ -80,24 +80,6 @@ const locale = (option: UnwrapRef) => h(ElSelect, ) }) -const ALL_LIMIT_FILTER_TYPE: timer.limit.FilterType[] = [ - 'translucent', - 'groundGlass', -] - -const limitFilterTypeSelect = (option: timer.option.AppearanceOption) => h(ElSelect, { - modelValue: option.limitMarkFilter, - size: 'small', - onChange: (val: timer.limit.FilterType) => { - option.limitMarkFilter = val - optionService.setAppearanceOption(unref(option)) - } -}, { - default: () => ALL_LIMIT_FILTER_TYPE.map(item => - h(ElOption, { value: item, label: t(msg => msg.option.appearance.limitFilterType[item]) }) - ) -}) - function copy(target: timer.option.AppearanceOption, source: timer.option.AppearanceOption) { target.displayWhitelistMenu = source.displayWhitelistMenu target.displayBadgeText = source.displayBadgeText @@ -109,69 +91,59 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear target.limitMarkFilter = source.limitMarkFilter } -const _default = defineComponent({ - name: "AppearanceOptionContainer", - setup(_props, ctx) { - const option: UnwrapRef = reactive(defaultAppearance()) - optionService.getAllOption().then(currentVal => copy(option, currentVal)) - ctx.expose({ - async reset() { - copy(option, defaultAppearance()) - await optionService.setAppearanceOption(unref(option)) - toggle(await optionService.isDarkMode(option)) - } - }) - return () => h('div', [ - renderOptionItem({ - input: h(DarkModeInput, { - modelValue: option.darkMode, - startSecond: option.darkModeTimeStart, - endSecond: option.darkModeTimeEnd, - onChange: async (darkMode, range) => { - option.darkMode = darkMode - option.darkModeTimeStart = range?.[0] - option.darkModeTimeEnd = range?.[1] - await optionService.setAppearanceOption(unref(option)) - toggle(await optionService.isDarkMode()) - } - }) - }, - msg => msg.appearance.darkMode.label, - t(msg => msg.option.appearance.darkMode.options.default)), - h(ElDivider), - renderOptionItem({ - input: locale(option) - }, - msg => msg.appearance.locale.label, - t(msg => msg.option.appearance.locale.default) - ), - h(ElDivider), - renderOptionItem({ - input: displayWhitelist(option), - whitelist: tagText(msg => msg.option.appearance.whitelistItem), - contextMenu: tagText(msg => msg.option.appearance.contextMenu) - }, msg => msg.appearance.displayWhitelist, t(msg => msg.option.yes)), - h(ElDivider), - renderOptionItem({ - input: displayBadgeText(option), - timeInfo: tagText(msg => msg.option.appearance.badgeTextContent), - icon: tagText(msg => msg.option.appearance.icon) - }, msg => msg.appearance.displayBadgeText, t(msg => msg.option.yes)), - h(ElDivider), - renderOptionItem({ - input: printInConsole(option), - console: tagText(msg => msg.option.appearance.printInConsole.console), - info: tagText(msg => msg.option.appearance.printInConsole.info) - }, msg => msg.appearance.printInConsole.label, t(msg => msg.option.yes)), - h(ElDivider), - renderOptionItem({ - input: limitFilterTypeSelect(option) - }, - msg => msg.appearance.limitFilterType.label, - t(msg => msg.option.appearance.limitFilterType[defaultAppearance().limitMarkFilter]) - ) - ]) - } +const _default = defineComponent((_props, ctx) => { + const option: UnwrapRef = reactive(defaultAppearance()) + optionService.getAllOption().then(currentVal => copy(option, currentVal)) + ctx.expose({ + async reset() { + copy(option, defaultAppearance()) + await optionService.setAppearanceOption(unref(option)) + toggle(await optionService.isDarkMode(option)) + } + }) + return () => h('div', [ + renderOptionItem({ + input: h(DarkModeInput, { + modelValue: option.darkMode, + startSecond: option.darkModeTimeStart, + endSecond: option.darkModeTimeEnd, + onChange: async (darkMode, range) => { + option.darkMode = darkMode + option.darkModeTimeStart = range?.[0] + option.darkModeTimeEnd = range?.[1] + await optionService.setAppearanceOption(unref(option)) + toggle(await optionService.isDarkMode()) + } + }) + }, + msg => msg.appearance.darkMode.label, + t(msg => msg.option.appearance.darkMode.options.default)), + h(ElDivider), + renderOptionItem({ + input: locale(option) + }, + msg => msg.appearance.locale.label, + t(msg => msg.option.appearance.locale.default) + ), + h(ElDivider), + renderOptionItem({ + input: displayWhitelist(option), + whitelist: tagText(msg => msg.option.appearance.whitelistItem), + contextMenu: tagText(msg => msg.option.appearance.contextMenu) + }, msg => msg.appearance.displayWhitelist, t(msg => msg.option.yes)), + h(ElDivider), + renderOptionItem({ + input: displayBadgeText(option), + timeInfo: tagText(msg => msg.option.appearance.badgeTextContent), + icon: tagText(msg => msg.option.appearance.icon) + }, msg => msg.appearance.displayBadgeText, t(msg => msg.option.yes)), + h(ElDivider), + renderOptionItem({ + input: printInConsole(option), + console: tagText(msg => msg.option.appearance.printInConsole.console), + info: tagText(msg => msg.option.appearance.printInConsole.info) + }, msg => msg.appearance.printInConsole.label, t(msg => msg.option.yes)), + ]) }) export default _default \ No newline at end of file diff --git a/src/app/components/option/components/backup/footer.ts b/src/app/components/option/components/backup/footer.ts index 24b300a1..fee152c5 100644 --- a/src/app/components/option/components/backup/footer.ts +++ b/src/app/components/option/components/backup/footer.ts @@ -4,12 +4,12 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { PropType, Ref, watch } from "vue" +import type { PropType, Ref } from "vue" import { t } from "@app/locale" import { UploadFilled } from "@element-plus/icons-vue" import { ElButton, ElLoading, ElMessage, ElText } from "element-plus" -import { defineComponent, h, ref } from "vue" +import { defineComponent, h, ref, watch } from "vue" import metaService from "@service/meta-service" import processor from "@src/common/backup/processor" import { formatTime } from "@util/time" diff --git a/src/app/components/option/components/backup/index.ts b/src/app/components/option/components/backup/index.ts index bb64c1fc..ec16f0c6 100644 --- a/src/app/components/option/components/backup/index.ts +++ b/src/app/components/option/components/backup/index.ts @@ -81,106 +81,103 @@ const authInput = (auth: Ref, handleInput: Function, handleTest: Functio const DEFAULT = defaultBackup() -const _default = defineComponent({ - name: "BackupOptionContainer", - setup(_props, ctx) { - const type: Ref = ref(DEFAULT.backupType) - const auth: Ref = ref('') - const clientName: Ref = ref(DEFAULT.clientName) - const autoBackUp: Ref = ref(DEFAULT.autoBackUp) - const autoBackUpInterval: Ref = ref(DEFAULT.autoBackUpInterval) - - optionService.getAllOption().then(currentVal => { - clientName.value = currentVal.clientName - type.value = currentVal.backupType - if (type.value) { - auth.value = currentVal.backupAuths?.[type.value] - } - autoBackUp.value = currentVal.autoBackUp - autoBackUpInterval.value = currentVal.autoBackUpInterval - }) - - function handleChange() { - const backupAuths = {} - backupAuths[type.value] = auth.value - const newOption: timer.option.BackupOption = { - backupType: type.value, - backupAuths, - clientName: clientName.value || DEFAULT.clientName, - autoBackUp: autoBackUp.value, - autoBackUpInterval: autoBackUpInterval.value, - } - optionService.setBackupOption(newOption) +const _default = defineComponent((_props, ctx) => { + const type: Ref = ref(DEFAULT.backupType) + const auth: Ref = ref('') + const clientName: Ref = ref(DEFAULT.clientName) + const autoBackUp: Ref = ref(DEFAULT.autoBackUp) + const autoBackUpInterval: Ref = ref(DEFAULT.autoBackUpInterval) + + optionService.getAllOption().then(currentVal => { + clientName.value = currentVal.clientName + type.value = currentVal.backupType + if (type.value) { + auth.value = currentVal.backupAuths?.[type.value] } - - async function handleTest() { - const loading = ElLoading.service({ - text: "Please wait...." - }) - const errorMsg = await processor.test(type.value, auth.value) - loading.close() - if (!errorMsg) { - ElMessage.success("Valid!") - } else { - ElMessage.error(errorMsg) - } + autoBackUp.value = currentVal.autoBackUp + autoBackUpInterval.value = currentVal.autoBackUpInterval + }) + + function handleChange() { + const backupAuths = {} + backupAuths[type.value] = auth.value + const newOption: timer.option.BackupOption = { + backupType: type.value, + backupAuths, + clientName: clientName.value || DEFAULT.clientName, + autoBackUp: autoBackUp.value, + autoBackUpInterval: autoBackUpInterval.value, } + optionService.setBackupOption(newOption) + } - ctx.expose({ - async reset() { - // Only reset type and auto flag - type.value = DEFAULT.backupType - autoBackUp.value = DEFAULT.autoBackUp - handleChange() - } + async function handleTest() { + const loading = ElLoading.service({ + text: "Please wait...." }) + const errorMsg = await processor.test(type.value, auth.value) + loading.close() + if (!errorMsg) { + ElMessage.success("Valid!") + } else { + ElMessage.error(errorMsg) + } + } - return () => { - const nodes = [ - h(ElAlert, { - closable: false, - type: "warning", - description: t(msg => msg.option.backup.alert, { email: AUTHOR_EMAIL }) - }), - h(ElDivider), - renderOptionItem({ - input: typeSelect(type, handleChange) - }, - msg => msg.backup.type, - t(TYPE_NAMES[DEFAULT.backupType]) - ) - ] - type.value !== 'none' && nodes.push( - h(ElDivider), - renderOptionItem({ - input: h(BackUpAutoInput, { - autoBackup: autoBackUp.value, - interval: autoBackUpInterval.value, - onChange(newAutoBackUp, newInterval) { - autoBackUp.value = newAutoBackUp - autoBackUpInterval.value = newInterval - handleChange() - } - }) - }, _msg => '{input}', t(msg => msg.option.no)), - h(ElDivider), - renderOptionItem({ - input: authInput(auth, handleChange, handleTest), - info: tooltip(msg => msg.option.backup.meta[type.value]?.authInfo) - }, - _msg => AUTH_LABELS[type.value], - ), - h(ElDivider), - renderOptionItem({ - input: clientNameInput(clientName, handleChange) - }, - msg => msg.backup.client - ), - h(ElDivider), - h(Footer, { type: type.value }), - ) - return h('div', nodes) + ctx.expose({ + async reset() { + // Only reset type and auto flag + type.value = DEFAULT.backupType + autoBackUp.value = DEFAULT.autoBackUp + handleChange() } + }) + + return () => { + const nodes = [ + h(ElAlert, { + closable: false, + type: "warning", + description: t(msg => msg.option.backup.alert, { email: AUTHOR_EMAIL }) + }), + h(ElDivider), + renderOptionItem({ + input: typeSelect(type, handleChange) + }, + msg => msg.backup.type, + t(TYPE_NAMES[DEFAULT.backupType]) + ) + ] + type.value !== 'none' && nodes.push( + h(ElDivider), + renderOptionItem({ + input: h(BackUpAutoInput, { + autoBackup: autoBackUp.value, + interval: autoBackUpInterval.value, + onChange(newAutoBackUp, newInterval) { + autoBackUp.value = newAutoBackUp + autoBackUpInterval.value = newInterval + handleChange() + } + }) + }, _msg => '{input}', t(msg => msg.option.no)), + h(ElDivider), + renderOptionItem({ + input: authInput(auth, handleChange, handleTest), + info: tooltip(msg => msg.option.backup.meta[type.value]?.authInfo) + }, + _msg => AUTH_LABELS[type.value], + ), + h(ElDivider), + renderOptionItem({ + input: clientNameInput(clientName, handleChange) + }, + msg => msg.backup.client + ), + h(ElDivider), + h(Footer, { type: type.value }), + ) + return h('div', nodes) } }) diff --git a/src/app/components/option/components/daily-limit/daily-limit.sass b/src/app/components/option/components/daily-limit/daily-limit.sass new file mode 100644 index 00000000..f31c2c53 --- /dev/null +++ b/src/app/components/option/components/daily-limit/daily-limit.sass @@ -0,0 +1,6 @@ +// Fallback with EN +.option-daily-limit-level-select>.select-trigger + width: 330px + +.option-daily-limit-level-select.zh_CN>.select-trigger + width: 210px diff --git a/src/app/components/option/components/daily-limit/index.ts b/src/app/components/option/components/daily-limit/index.ts new file mode 100644 index 00000000..a5448f2d --- /dev/null +++ b/src/app/components/option/components/daily-limit/index.ts @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { locale } from "@i18n" +import optionService from "@service/option-service" +import { defaultDailyLimit } from "@util/constant/option" +import { ElDivider, ElInput, ElOption, ElSelect } from "element-plus" +import { defineComponent, reactive, unref, UnwrapRef, h } from "vue" +import { renderOptionItem } from "../../common" +import "./daily-limit.sass" + +const ALL_LIMIT_FILTER_TYPE: timer.limit.FilterType[] = [ + 'translucent', + 'groundGlass', +] + +const ALL_LEVEL: timer.limit.RestrictionLevel[] = [ + 'nothing', + 'verification', + 'password', +] + +const ALL_DIFF: timer.limit.VerificationDifficulty[] = [ + 'easy', + 'hard', + 'disgusting', +] + +const filterSelect = (option: timer.option.DailyLimitOption) => h(ElSelect, { + modelValue: option.limitFilter, + size: 'small', + onChange: (val: timer.limit.FilterType) => { + option.limitFilter = val + optionService.setDailyLimitOption(unref(option)) + } +}, () => ALL_LIMIT_FILTER_TYPE.map(item => h(ElOption, { value: item, label: t(msg => msg.option.dailyLimit.filter[item]) }))) + +const levelSelect = (option: timer.option.DailyLimitOption) => h(ElSelect, { + modelValue: option.limitLevel, + size: 'small', + class: `option-daily-limit-level-select ${locale}`, + onChange: (val: timer.limit.RestrictionLevel) => { + option.limitLevel = val + optionService.setDailyLimitOption(unref(option)) + } +}, () => ALL_LEVEL.map(item => h(ElOption, { value: item, label: t(msg => msg.option.dailyLimit.level[item]) }))) + +const pswInput = (option: timer.option.DailyLimitOption) => h(ElInput, { + modelValue: option.limitPassword, + size: 'small', + type: 'password', + showPassword: true, + style: { width: '200px' }, + onInput: (val: string) => { + option.limitPassword = val?.trim() + optionService.setDailyLimitOption(unref(option)) + } +}) + +const veriDiffSelect = (option: timer.option.DailyLimitOption) => h(ElSelect, { + modelValue: option.limitVerifyDifficulty, + size: 'small', + onChange: (val: timer.limit.VerificationDifficulty) => { + option.limitVerifyDifficulty = val + optionService.setDailyLimitOption(unref(option)) + } +}, () => ALL_DIFF.map(item => h(ElOption, { value: item, label: t(msg => msg.option.dailyLimit.level.verificationDifficulty[item]) }))) + +function copy(target: timer.option.DailyLimitOption, source: timer.option.DailyLimitOption) { + target.limitFilter = source.limitFilter + target.limitLevel = source.limitLevel + target.limitPassword = source.limitPassword + target.limitVerifyDifficulty = source.limitVerifyDifficulty +} + +function reset(target: timer.option.DailyLimitOption) { + const defaultValue = defaultDailyLimit() + // Not to reset limitPassword + delete defaultValue.limitPassword + // Not to reset difficulty + delete defaultValue.limitVerifyDifficulty + Object.entries(defaultValue).forEach(([key, val]) => target[key] = val) +} + +const _default = defineComponent((_, ctx) => { + const option: UnwrapRef = reactive(defaultDailyLimit()) + optionService.getAllOption().then(currentVal => { + copy(option, currentVal) + }) + ctx.expose({ + reset: () => reset(option) + }) + return () => { + const nodes = [ + renderOptionItem({ + input: filterSelect(option) + }, + msg => msg.dailyLimit.filter.label, + t(msg => msg.option.dailyLimit.filter[defaultDailyLimit().limitFilter]) + ), + h(ElDivider), + renderOptionItem({ + input: levelSelect(option) + }, + msg => msg.dailyLimit.level.label, + t(msg => msg.option.dailyLimit.level[defaultDailyLimit().limitLevel]) + ), + ] + const { limitLevel } = option + limitLevel === 'password' && nodes.push( + h(ElDivider), + renderOptionItem({ + input: pswInput(option), + }, msg => msg.dailyLimit.level.passwordLabel) + ) + limitLevel === 'verification' && nodes.push( + h(ElDivider), + renderOptionItem({ + input: veriDiffSelect(option), + }, + msg => msg.dailyLimit.level.verificationLabel, + t(msg => msg.option.dailyLimit.level[defaultDailyLimit().limitVerifyDifficulty]) + ) + ) + return nodes + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/option/index.ts b/src/app/components/option/index.ts index 675326ae..207ec108 100644 --- a/src/app/components/option/index.ts +++ b/src/app/components/option/index.ts @@ -14,6 +14,7 @@ import Popup from "./components/popup" import Appearance from "./components/appearance" import Statistics from "./components/statistics" import Backup from './components/backup' +import DailyLimit from './components/daily-limit' import './style' import { ElIcon, ElMessage, ElTabPane, ElTabs } from "element-plus" import { t } from "@app/locale" @@ -22,7 +23,7 @@ import { useRoute, useRouter } from "vue-router" const resetButtonName = "reset" const initialParamName = "i" -const allCategories = ["appearance", "statistics", "popup", 'backup'] as const +const allCategories = ["appearance", "statistics", "popup", 'dailyLimit', 'backup'] as const type _Category = typeof allCategories[number] function initWithQuery(tab: Ref<_Category>) { @@ -78,6 +79,7 @@ const _default = defineComponent({ statistics: ref(), popup: ref(), backup: ref(), + dailyLimit: ref(), } const router = useRouter() return () => h(ContentContainer, () => h(ElTabs, { @@ -106,7 +108,14 @@ const _default = defineComponent({ }, () => h(Popup, { ref: paneRefMap.popup })), - // backup + // Limit + h(ElTabPane, { + label: t(msg => msg.menu.limit), + name: "dailyLimit" as _Category + }, () => h(DailyLimit, { + ref: paneRefMap.dailyLimit + })), + // Backup h(ElTabPane, { label: t(msg => msg.option.backup.title), name: "backup" as _Category diff --git a/src/content-script/locale.ts b/src/content-script/locale.ts index 2aa3a530..8771f4da 100644 --- a/src/content-script/locale.ts +++ b/src/content-script/locale.ts @@ -10,6 +10,6 @@ import type { ContentScriptMessage } from "@i18n/message/common/content-script" import { t as t_ } from "@i18n" import messages from "@i18n/message/common/content-script" -export function t(key: I18nKey): string { - return t_(messages, { key }) +export function t(key: I18nKey, param?: any): string { + return t_(messages, { key, param }) } \ No newline at end of file diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 086113dc..2aaae505 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -49,117 +49,41 @@ export type AppMessage = { button: ButtonMessage } +const CHILD_MESSAGES: { [key in keyof AppMessage]: Messages } = { + dataManage: dataManageMessages, + item: itemMessages, + mergeCommon: mergeCommonMessages, + report: reportMessages, + whitelist: whitelistMessages, + mergeRule: mergeRuleMessages, + option: optionMessages, + analysis: analysisMessages, + menu: menuMessages, + habit: habitMessages, + limit: limitMessages, + siteManage: siteManageManages, + operation: operationMessages, + confirm: confirmMessages, + dashboard: dashboardMessages, + calendar: calendarMessages, + timeFormat: timeFormatMessages, + duration: popupDurationMessages, + helpUs: helpUsMessages, + button: buttonMessages, +} + +function appMessageOf(locale: timer.Locale): AppMessage { + const entries: [string, any][] = Object.entries(CHILD_MESSAGES).map(([key, val]) => ([key, val[locale]])) + const result = Object.fromEntries(entries) as AppMessage + return result +} + const _default: Messages = { - zh_CN: { - dataManage: dataManageMessages.zh_CN, - item: itemMessages.zh_CN, - mergeCommon: mergeCommonMessages.zh_CN, - report: reportMessages.zh_CN, - whitelist: whitelistMessages.zh_CN, - mergeRule: mergeRuleMessages.zh_CN, - option: optionMessages.zh_CN, - analysis: analysisMessages.zh_CN, - menu: menuMessages.zh_CN, - habit: habitMessages.zh_CN, - limit: limitMessages.zh_CN, - siteManage: siteManageManages.zh_CN, - operation: operationMessages.zh_CN, - confirm: confirmMessages.zh_CN, - dashboard: dashboardMessages.zh_CN, - calendar: calendarMessages.zh_CN, - timeFormat: timeFormatMessages.zh_CN, - duration: popupDurationMessages.zh_CN, - helpUs: helpUsMessages.zh_CN, - button: buttonMessages.zh_CN, - }, - zh_TW: { - dataManage: dataManageMessages.zh_TW, - item: itemMessages.zh_TW, - mergeCommon: mergeCommonMessages.zh_TW, - report: reportMessages.zh_TW, - whitelist: whitelistMessages.zh_TW, - mergeRule: mergeRuleMessages.zh_TW, - option: optionMessages.zh_TW, - analysis: analysisMessages.zh_TW, - menu: menuMessages.zh_TW, - habit: habitMessages.zh_TW, - limit: limitMessages.zh_TW, - siteManage: siteManageManages.zh_TW, - operation: operationMessages.zh_TW, - confirm: confirmMessages.zh_TW, - dashboard: dashboardMessages.zh_TW, - calendar: calendarMessages.zh_TW, - timeFormat: timeFormatMessages.zh_TW, - duration: popupDurationMessages.zh_TW, - helpUs: helpUsMessages.zh_TW, - button: buttonMessages.zh_TW, - }, - en: { - dataManage: dataManageMessages.en, - item: itemMessages.en, - mergeCommon: mergeCommonMessages.en, - report: reportMessages.en, - whitelist: whitelistMessages.en, - mergeRule: mergeRuleMessages.en, - option: optionMessages.en, - analysis: analysisMessages.en, - menu: menuMessages.en, - habit: habitMessages.en, - limit: limitMessages.en, - siteManage: siteManageManages.en, - operation: operationMessages.en, - confirm: confirmMessages.en, - dashboard: dashboardMessages.en, - calendar: calendarMessages.en, - timeFormat: timeFormatMessages.en, - duration: popupDurationMessages.en, - helpUs: helpUsMessages.en, - button: buttonMessages.en, - }, - ja: { - dataManage: dataManageMessages.ja, - item: itemMessages.ja, - mergeCommon: mergeCommonMessages.ja, - report: reportMessages.ja, - whitelist: whitelistMessages.ja, - mergeRule: mergeRuleMessages.ja, - option: optionMessages.ja, - analysis: analysisMessages.ja, - menu: menuMessages.ja, - habit: habitMessages.ja, - limit: limitMessages.ja, - siteManage: siteManageManages.ja, - operation: operationMessages.ja, - confirm: confirmMessages.ja, - dashboard: dashboardMessages.ja, - calendar: calendarMessages.ja, - timeFormat: timeFormatMessages.ja, - duration: popupDurationMessages.ja, - helpUs: helpUsMessages.ja, - button: buttonMessages.ja, - }, - pt_PT: { - dataManage: dataManageMessages.pt_PT, - item: itemMessages.pt_PT, - mergeCommon: mergeCommonMessages.pt_PT, - report: reportMessages.pt_PT, - whitelist: whitelistMessages.pt_PT, - mergeRule: mergeRuleMessages.pt_PT, - option: optionMessages.pt_PT, - analysis: analysisMessages.pt_PT, - menu: menuMessages.pt_PT, - habit: habitMessages.pt_PT, - limit: limitMessages.pt_PT, - siteManage: siteManageManages.pt_PT, - operation: operationMessages.pt_PT, - confirm: confirmMessages.pt_PT, - dashboard: dashboardMessages.pt_PT, - calendar: calendarMessages.pt_PT, - timeFormat: timeFormatMessages.pt_PT, - duration: popupDurationMessages.pt_PT, - helpUs: helpUsMessages.pt_PT, - button: buttonMessages.pt_PT, - }, + zh_CN: appMessageOf('zh_CN'), + zh_TW: appMessageOf('zh_TW'), + en: appMessageOf('en'), + ja: appMessageOf('ja'), + pt_PT: appMessageOf('pt_PT'), } export default _default \ No newline at end of file diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 4af1ed5b..ad3ae682 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -12,7 +12,8 @@ "operation": "操作" }, "button": { - "test": "网址测试" + "test": "网址测试", + "option": "全局设置" }, "addTitle": "新增限制", "useWildcard": "是否使用通配符", @@ -28,7 +29,16 @@ "noRuleMatched": "该网址未命中任何规则", "rulesMatched": "该网址命中以下规则:" }, - "urlPlaceholder": "请直接粘贴网址 ➡️" + "urlPlaceholder": "请直接粘贴网址 ➡️", + "verification": { + "inputTip": "该规则已超时,如要表更,请输入以下问题的答案:{prompt}", + "inputTip2": "该规则已超时,如要表更,请在下列输入框中原样输入:{answer}", + "pswInputTip": "该规则已超时,如要表更,所以请在下列输入框中输入您的解锁密码", + "incorrectPsw": "密码错误", + "incorrectAnswer": "回答错误", + "pi": "圆周率 π 的小数部分第 {startIndex} 位到第 {endIndex} 位的共 {digitCount} 位数字", + "confession": "一寸光一寸金,寸金难买寸光阴" + } }, "zh_TW": { "conditionFilter": "輸入網址,然後回車", @@ -74,7 +84,8 @@ "operation": "Operations" }, "button": { - "test": "Test URL" + "test": "Test URL", + "option": "Options" }, "addTitle": "New", "useWildcard": "Whether to use wildcard", @@ -90,7 +101,16 @@ "noRuleMatched": "The URL does not hit any rules", "rulesMatched": "The URL hits the following rules:" }, - "urlPlaceholder": "Please paste the URL directly ➡️" + "urlPlaceholder": "Please paste the URL directly ➡️", + "verification": { + "inputTip": "This rule has already been triggered. To modify it, please enter the answer to the following prompt: {prompt}", + "inputTip2": "This rule has already been triggered. To modify it, please enter it as it is: {answer}", + "pswInputTip": "This rule has already been triggered. To modify it, please enter your unlock password", + "incorrectPsw": "Incorrect password", + "incorrectAnswer": "Incorrect answer", + "pi": "{digitCount} digits from {startIndex} to {endIndex} of the decimal part of π", + "confession": "Time is fleeting" + } }, "ja": { "conditionFilter": "URL", diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index fab37bdf..fb165e0a 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -24,6 +24,7 @@ export type LimitMessage = { } button: { test: string + option: string } message: { noUrl: string @@ -37,6 +38,15 @@ export type LimitMessage = { noRuleMatched: string rulesMatched: string } + verification: { + inputTip: string + inputTip2: string + pswInputTip: string + incorrectPsw: string + incorrectAnswer: string + pi: string + confession: string + } } const _default: Messages = resource diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 036125a4..a6bf678b 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -39,11 +39,6 @@ "off": "始终关闭", "timed": "定时开启" } - }, - "limitFilterType": { - "label": "每日时限的背景风格 {input}", - "translucent": "半透明", - "groundGlass": "毛玻璃" } }, "statistics": { @@ -58,6 +53,26 @@ "siteName": "网站的名称", "siteNameUsage": "数据只存放在本地,将代替域名用于展示,增加辨识度。当然您可以自定义每个网站的名称" }, + "dailyLimit": { + "filter": { + "label": "模态页面的背景风格 {input}", + "translucent": "半透明", + "groundGlass": "毛玻璃" + }, + "level": { + "label": "受限时如何解锁 {input}", + "nothing": "允许在管理页面直接解锁", + "password": "必须输入密码解锁", + "verification": "必须输入验证码解锁", + "passwordLabel": "解锁密码 {input}", + "verificationLabel": "验证码的难度 {input}", + "verificationDifficulty": { + "easy": "简单", + "hard": "困难", + "disgusting": "折磨" + } + } + }, "backup": { "title": "数据备份", "type": "远端类型 {input}", @@ -123,11 +138,6 @@ "off": "始終關閉", "timed": "定時開啟" } - }, - "limitFilterType": { - "label": "每日時限的背景風格 {input}", - "translucent": "半透明", - "groundGlass": "毛玻璃" } }, "statistics": { @@ -142,6 +152,13 @@ "siteName": "網站的名稱", "siteNameUsage": "數據隻存放在本地,將代替域名用於展示,增加辨識度。當然您可以自定義每個網站的名稱" }, + "dailyLimit": { + "filter": { + "label": "模態頁面的背景風格 {input}", + "translucent": "半透明", + "groundGlass": "毛玻璃" + } + }, "backup": { "title": "數據備份", "type": "雲端類型 {input}", @@ -207,11 +224,6 @@ "off": "Always off", "timed": "Timed on" } - }, - "limitFilterType": { - "label": "Background style for daily time limit {input}", - "translucent": "Translucent", - "groundGlass": "Ground Glass" } }, "statistics": { @@ -226,6 +238,27 @@ "siteName": "the site name", "siteNameUsage": "The data is only stored locally and will be displayed instead of the URL to increase the recognition.Of course, you can also customize the name of each site." }, + "dailyLimit": { + "title": "Daily Limit", + "filter": { + "label": "The background style of the modal page {input}", + "translucent": "Translucent", + "groundGlass": "Ground Glass" + }, + "level": { + "label": "How to unlock while restricted {input}", + "nothing": "Allow direct unlocking on the admin page", + "password": "Must enter password to unlock", + "verification": "Must enter verification code to unlock", + "passwordLabel": "Password to unlock {input}", + "verificationLabel": "The difficulty of verification code {input}", + "verificationDifficulty": { + "easy": "Easy", + "hard": "Hard", + "disgusting": "Disgusting" + } + } + }, "backup": { "title": "Data Backup", "type": "Remote type {input}", @@ -291,11 +324,6 @@ "off": "常にオフ", "timed": "時限スタート" } - }, - "limitFilterType": { - "label": "毎日の時間制限の背景スタイル {input}", - "translucent": "半透明", - "groundGlass": "すりガラス" } }, "statistics": { @@ -310,6 +338,13 @@ "siteName": "サイト名", "siteNameUsage": "データはローカルにのみ存在し、認識を高めるためにホストの代わりに表示に使用されます。もちろん、各Webサイトの名前をカスタマイズできます。" }, + "dailyLimit": { + "filter": { + "label": "モーダルページの背景スタイル {input}", + "translucent": "半透明", + "groundGlass": "すりガラス" + } + }, "backup": { "title": "データバックアップ", "type": "バックアップ方法 {input}", @@ -377,11 +412,6 @@ "off": "Sempre desativado", "timed": "Cronometrando" } - }, - "limitFilterType": { - "label": "Estilo de fundo para limite de tempo diário {input}", - "translucent": "Translúcido", - "groundGlass": "Vidro no Solo" } }, "statistics": { @@ -396,6 +426,13 @@ "siteName": "o nome do site", "siteNameUsage": "Os dados são armazenados apenas localmente e serão exibidos em vez da URL para aumentar o reconhecimento. F, também pode personalizar o nome de cada site." }, + "dailyLimit": { + "filter": { + "label": "O estilo de fundo da página modal {input}", + "translucent": "Translúcido", + "groundGlass": "Vidro no Solo" + } + }, "backup": { "title": "Backup de Dados", "type": "Tipo remoto {input}", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 05c370c6..871ad717 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -45,9 +45,6 @@ export type OptionMessage = { label: string options: Record } - limitFilterType: Record & { - label: string - } } statistics: { title: string @@ -61,6 +58,23 @@ export type OptionMessage = { siteNameUsage: string siteName: string } + dailyLimit: { + filter: { + [filterType in timer.limit.FilterType]: string + } & { + label: string + } + level: { + [level in timer.limit.RestrictionLevel]: string + } & { + label: string + passwordLabel: string + verificationLabel: string + verificationDifficulty: { + [diff in timer.limit.VerificationDifficulty]: string + } + } + } backup: { title: string type: string diff --git a/src/i18n/message/common/button.ts b/src/i18n/message/common/button.ts index caea7af1..0ff343de 100644 --- a/src/i18n/message/common/button.ts +++ b/src/i18n/message/common/button.ts @@ -20,4 +20,6 @@ export type ButtonMessage = { dont: string } -export default resource as Messages \ No newline at end of file +const _default: Messages = resource + +export default _default \ No newline at end of file diff --git a/src/service/limit-service/index.ts b/src/service/limit-service/index.ts new file mode 100644 index 00000000..af031262 --- /dev/null +++ b/src/service/limit-service/index.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { DATE_FORMAT } from "@db/common/constant" +import LimitDatabase from "@db/limit-database" +import TimeLimitItem from "@entity/time-limit-item" +import { formatTime } from "@util/time" +import whitelistHolder from '../components/whitelist-holder' + +const storage = chrome.storage.local +const db: LimitDatabase = new LimitDatabase(storage) + +export type QueryParam = { + filterDisabled: boolean + url: string +} + +async function select(cond?: QueryParam): Promise { + const { filterDisabled, url } = cond ? cond : { filterDisabled: undefined, url: undefined } + const today = formatTime(new Date(), DATE_FORMAT) + return (await db.all()) + .filter(item => filterDisabled ? item.enabled : true) + .map(({ cond, time, enabled, wasteTime, latestDate, allowDelay }) => TimeLimitItem.builder() + .cond(cond) + .time(time) + .enabled(enabled) + .waste(latestDate === today ? wasteTime : 0) + .allowDelay(allowDelay) + .build() + ) + // If use url, then test it + .filter(item => url ? item.matches(url) : true) +} + +/** + * Fired if the item is removed or disabled + * + * @param item + */ +async function handleLimitChanged() { + const allItems: TimeLimitItem[] = await select({ filterDisabled: false, url: undefined }) + const tabs = await listTabs() + tabs.forEach(tab => { + const limitedItems = allItems.filter(item => item.matches(tab.url) && item.enabled && item.hasLimited()) + sendMsg2Tab(tab?.id, 'limitChanged', limitedItems) + .catch(err => console.log(err.message)) + }) +} + +async function updateEnabled(item: timer.limit.Item): Promise { + const { cond, time, enabled, allowDelay } = item + const limit: timer.limit.Rule = { cond, time, enabled, allowDelay } + await db.save(limit, true) + await handleLimitChanged() +} + +async function updateDelay(item: timer.limit.Item) { + await db.updateDelay(item.cond, item.allowDelay) + await handleLimitChanged() +} + +async function remove(item: timer.limit.Item): Promise { + await db.remove(item.cond) + await handleLimitChanged() +} + +async function getLimited(url: string): Promise { + const list: TimeLimitItem[] = (await select()) + .filter(item => item.enabled) + .filter(item => item.matches(url)) + .filter(item => item.hasLimited()) + return list +} + +/** + * Add time + * @param url url + * @param focusTime time, milliseconds + * @returns the rules is limit cause of this operation + */ +async function addFocusTime(url: string, focusTime: number) { + const allEnabled: TimeLimitItem[] = await select({ filterDisabled: true, url }) + const toUpdate: { [cond: string]: number } = {} + const result: TimeLimitItem[] = [] + allEnabled.forEach(item => { + const limitBefore = item.hasLimited() + toUpdate[item.cond] = item.waste += focusTime + const limitAfter = item.hasLimited() + if (!limitBefore && limitAfter) { + result.push(item) + } + }) + await db.updateWaste(formatTime(new Date, DATE_FORMAT), toUpdate) + return result +} + +async function moreMinutes(url: string, rules?: TimeLimitItem[]): Promise { + if (rules === undefined || rules === null) { + rules = (await select({ url: url, filterDisabled: true })) + .filter(item => item.hasLimited() && item.allowDelay) + } + const date = formatTime(new Date(), DATE_FORMAT) + const toUpdate: { [cond: string]: number } = {} + rules.forEach(rule => { + const { cond, waste } = rule + const updatedWaste = (waste || 0) - 5 * 60 * 1000 + rule.waste = toUpdate[cond] = updatedWaste < 0 ? 0 : updatedWaste + }) + await db.updateWaste(date, toUpdate) + return rules +} + +class LimitService { + moreMinutes = moreMinutes + getLimited = getLimited + updateEnabled = updateEnabled + updateDelay = updateDelay + select = select + remove = remove + /** + * @returns The rules limited cause of this operation + */ + async addFocusTime(host: string, url: string, focusTime: number): Promise { + if (whitelistHolder.notContains(host)) { + return addFocusTime(url, focusTime) + } else { + return [] + } + } +} + +export default new LimitService() diff --git a/src/service/limit-service/verification/common.ts b/src/service/limit-service/verification/common.ts new file mode 100644 index 00000000..fbc8d02c --- /dev/null +++ b/src/service/limit-service/verification/common.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { I18nKey } from "@i18n" +import { LimitMessage } from "@i18n/message/app/limit" + +type LimitVerificationMessage = LimitMessage['verification'] + +export type VerificationContext = { + difficulty: timer.limit.VerificationDifficulty + locale: timer.Locale +} + +export type VerificationPair = { + prompt?: I18nKey | string + promptParam?: any + answer: I18nKey | string +} + +/** + * Verification code generator + */ +export interface VerificationGenerator { + /** + * Whether to support + */ + supports(context: VerificationContext): boolean + + /** + * Render the prompt + */ + generate(context: VerificationContext): VerificationPair +} \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/confession.ts b/src/service/limit-service/verification/generator/confession.ts new file mode 100644 index 00000000..be4414a9 --- /dev/null +++ b/src/service/limit-service/verification/generator/confession.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { VerificationContext, VerificationGenerator, VerificationPair } from "../common" + +/** + * Generator of confession + */ +class ConfessionGenerator implements VerificationGenerator { + supports(context: VerificationContext): boolean { + return context.difficulty === 'easy' + } + generate(_: VerificationContext): VerificationPair { + return { + answer: msg => msg.confession, + } + } +} + +export default ConfessionGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/index.ts b/src/service/limit-service/verification/generator/index.ts new file mode 100644 index 00000000..49f2ed8e --- /dev/null +++ b/src/service/limit-service/verification/generator/index.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { VerificationGenerator } from "../common" + +import ConfessionGenerator from "./confession" +import PiGenerator from "./pi" +import UglyGenerator from "./ugly" +import UncommonChinese from "./uncommon-chinese" + +export const ALL_GENERATORS: VerificationGenerator[] = [ + new PiGenerator(), + new ConfessionGenerator(), + new UglyGenerator(), + new UncommonChinese(), +] \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/pi.ts b/src/service/limit-service/verification/generator/pi.ts new file mode 100644 index 00000000..16c5dc9c --- /dev/null +++ b/src/service/limit-service/verification/generator/pi.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { randomIntBetween } from "@util/number" +import { VerificationContext, VerificationGenerator, VerificationPair } from "../common" + +const MIN_START_IDX = 10 +const MAX_START_IDX = 25 +const MIN_LEN = 5 +const MAX_LEN = 15 + +const DIGIT_PART_PI = '14159265358979323846264338327950288419716939937510' + + '58209749445923078164062862089986280348253421170679' + +/** + * Generator of pi + */ +class PiGenerator implements VerificationGenerator { + generate(_: VerificationContext): VerificationPair { + const startIndex = randomIntBetween(MIN_START_IDX, MAX_START_IDX) + const digitCount = randomIntBetween(MIN_LEN, MAX_LEN) + const endIndex = startIndex + digitCount - 1 + const answer = DIGIT_PART_PI.substring(startIndex - 1, endIndex) + return { + answer, + prompt: msg => msg.pi, + promptParam: { + startIndex, + endIndex, + digitCount, + } + } + } + + supports(context: VerificationContext): boolean { + return context.difficulty === 'disgusting' + } +} + +export default PiGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/ugly.ts b/src/service/limit-service/verification/generator/ugly.ts new file mode 100644 index 00000000..46cdcb21 --- /dev/null +++ b/src/service/limit-service/verification/generator/ugly.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { randomIntBetween } from "@util/number" +import { VerificationContext, VerificationGenerator, VerificationPair } from "../common" + +class UglyGenerator implements VerificationGenerator { + supports(context: VerificationContext): boolean { + return context.difficulty === 'hard' + } + + generate(_: VerificationContext): VerificationPair { + const min = 1 << 6 + const max = 1 << 8 + const random = randomIntBetween(min, max) + return { + answer: random?.toString(2) + } + } +} + +export default UglyGenerator \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/uncommon-chinese.ts b/src/service/limit-service/verification/generator/uncommon-chinese.ts new file mode 100644 index 00000000..c3d42e6a --- /dev/null +++ b/src/service/limit-service/verification/generator/uncommon-chinese.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { randomIntBetween } from "@util/number" +import { VerificationContext, VerificationGenerator, VerificationPair } from "../common" + +const UNCOMMON_WORDS = '龘靐齉齾爩鱻麤龗灪吁龖厵滟爨癵籱饢驫鲡鹂鸾麣纞虋讟钃骊郁鸜麷鞻韽韾响顟顠饙饙騳騱饐' +const LENGTH = UNCOMMON_WORDS.length + +class UncommonChinese implements VerificationGenerator { + supports(context: VerificationContext): boolean { + return context.difficulty === 'disgusting' && context.locale === 'zh_CN' + } + + generate(_: VerificationContext): VerificationPair { + let answer = '' + while (answer.length < 3) { + const idx = randomIntBetween(0, LENGTH) + const ch = UNCOMMON_WORDS[idx] + if (!answer.includes(ch)) { + answer += ch + } + } + return { + answer + } + } +} + +export default UncommonChinese \ No newline at end of file diff --git a/src/service/limit-service/verification/processor.ts b/src/service/limit-service/verification/processor.ts new file mode 100644 index 00000000..860261b5 --- /dev/null +++ b/src/service/limit-service/verification/processor.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { VerificationContext, VerificationGenerator, VerificationPair } from "./common" +import { ALL_GENERATORS } from "./generator" + +class VerificationProcessor { + generators: VerificationGenerator[] + + constructor() { + this.generators = ALL_GENERATORS + } + + generate(difficulty: timer.limit.VerificationDifficulty, locale: timer.Locale): VerificationPair { + const context: VerificationContext = { difficulty, locale } + const supported = this.generators.filter(g => g.supports(context)) + const len = supported?.length + if (!len) { + return null + } + let generator = supported[0] + if (len > 1) { + const idx = Math.floor(Math.random() * supported.length) + generator = supported[idx] + } + return generator.generate(context) + } +} + +export default new VerificationProcessor() \ No newline at end of file diff --git a/src/service/option-service.ts b/src/service/option-service.ts index 067d908c..911693d0 100644 --- a/src/service/option-service.ts +++ b/src/service/option-service.ts @@ -11,6 +11,7 @@ import { defaultPopup, defaultStatistics, defaultBackup, + defaultDailyLimit, } from "@util/constant/option" const db = new OptionDatabase(chrome.storage.local) @@ -19,6 +20,7 @@ const defaultOption = () => ({ ...defaultAppearance(), ...defaultPopup(), ...defaultStatistics(), + ...defaultDailyLimit(), ...defaultBackup(), }) @@ -41,6 +43,11 @@ async function setStatisticsOption(option: timer.option.StatisticsOption): Promi await setOption(option) } +async function setDailyLimitOption(option: timer.option.DailyLimitOption): Promise { + // Rewrite password + await setOption(option) +} + async function setBackupOption(option: Partial): Promise { // Rewrite auths const existOption = await getAllOption() @@ -92,6 +99,10 @@ class OptionService { setPopupOption = setPopupOption setAppearanceOption = setAppearanceOption setStatisticsOption = setStatisticsOption + /** + * @since 1.9.0 + */ + setDailyLimitOption = setDailyLimitOption /** * @since 1.2.0 */ diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index ba4d7b17..5265b81d 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -34,7 +34,6 @@ export function defaultAppearance(): timer.option.AppearanceOption { darkModeTimeStart: 64800, // 6*60*60 darkModeTimeEnd: 21600, - limitMarkFilter: 'translucent', } } @@ -46,6 +45,15 @@ export function defaultStatistics(): timer.option.StatisticsOption { } } +export function defaultDailyLimit(): timer.option.DailyLimitOption { + return { + limitLevel: 'nothing', + limitFilter: 'translucent', + limitPassword: '', + limitVerifyDifficulty: 'easy', + } +} + export function defaultBackup(): timer.option.BackupOption { return { backupType: 'none', @@ -62,5 +70,6 @@ export function defaultOption(): timer.option.AllOption { ...defaultAppearance(), ...defaultStatistics(), ...defaultBackup(), + ...defaultDailyLimit(), } } \ No newline at end of file diff --git a/src/util/number.ts b/src/util/number.ts index ca5d02f3..d8d67888 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -23,3 +23,10 @@ export function tryParseInteger(str: string): [boolean, number | string] { const isInteger: boolean = !isNaN(num) && num.toString().length === str.length return [isInteger, isInteger ? num : str] } + +/** + * Generate random integer between {@param lowerInclusive} and {@param upperExclusive} + */ +export function randomIntBetween(lowerInclusive: number, upperExclusive: number): number { + return Math.floor(Math.random() * (upperExclusive - lowerInclusive)) + lowerInclusive +} \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts index 3d5b79d2..bf4f5027 100644 --- a/types/timer/limit.d.ts +++ b/types/timer/limit.d.ts @@ -44,4 +44,24 @@ declare namespace timer.limit { | 'translucent' // ground glass filter | 'groundGlass' + /** + * @since 1.9.0 + */ + type RestrictionLevel = + // No additional action required to lock + | 'nothing' + // Password required to lock or modify restricted rule + | 'password' + // Verification code input requird to lock or modify restricted rule + | 'verification' + /** + * @since 1.9.0 + */ + type VerificationDifficulty = + // Easy + | 'easy' + // Need some operations + | 'hard' + // Disgusting + | 'disgusting' } diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 5e78e955..7330ca9b 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -103,8 +103,9 @@ declare namespace timer.option { /** * The filter of limit mark * @since 1.3.2 + * @deprecated moved to DailyLimitOption @since 1.9.0 */ - limitMarkFilter: limit.FilterType + limitMarkFilter?: limit.FilterType } type StatisticsOption = { @@ -125,6 +126,25 @@ declare namespace timer.option { countLocalFiles: boolean } + type DailyLimitOption = { + /** + * restriction level + */ + limitLevel: limit.RestrictionLevel + /** + * The filter of limit mark + */ + limitFilter: limit.FilterType + /** + * The password to unlock + */ + limitPassword: string + /** + * The difficulty of verification + */ + limitVerifyDifficulty: limit.VerificationDifficulty + } + /** * The options of backup * @@ -153,7 +173,7 @@ declare namespace timer.option { autoBackUpInterval: number } - type AllOption = PopupOption & AppearanceOption & StatisticsOption & BackupOption + type AllOption = PopupOption & AppearanceOption & StatisticsOption & DailyLimitOption & BackupOption /** * @since 0.8.0 */