diff --git a/registry/lib/components/utils/import-series/logic.ts b/registry/lib/components/utils/import-series/logic.ts index ac4786738..cf9f4cc6b 100644 --- a/registry/lib/components/utils/import-series/logic.ts +++ b/registry/lib/components/utils/import-series/logic.ts @@ -19,7 +19,7 @@ const importSeries = async (sid, uid, csrf) => { // 创建收藏夹,获取新收藏夹id let favId = 0 - while (true) { + for (;;) { const response = await fetch('https://api.bilibili.com/x/v3/fav/folder/add', { method: 'POST', credentials: 'include', @@ -43,7 +43,7 @@ const importSeries = async (sid, uid, csrf) => { for (let i = 0; i < seriesVideos.length; i++) { // 做个延迟,防止太快而遭服务器拒绝 await delay(500) - while (true) { + for (;;) { const response = await fetch('https://api.bilibili.com/x/v3/fav/resource/deal', { method: 'POST', credentials: 'include', @@ -72,7 +72,7 @@ const importSeries = async (sid, uid, csrf) => { } const importCollection = async (sid, csrf) => { - while (true) { + for (;;) { const response = await fetch('https://api.bilibili.com/x/v3/fav/season/fav', { method: 'POST', credentials: 'include', diff --git a/registry/lib/components/video/player/extend-speed/component.ts b/registry/lib/components/video/player/extend-speed/component.ts index d4bc9c82c..adf92751a 100644 --- a/registry/lib/components/video/player/extend-speed/component.ts +++ b/registry/lib/components/video/player/extend-speed/component.ts @@ -444,6 +444,7 @@ export class ExtendSpeedComponent extends EntrySpeedComponent { setTimeout(() => this.forceUpdateStyle(value)) } + // eslint-disable-next-line class-methods-use-this protected readonly filterNativeSpeed = () => ({ subscribe, next }: PublishContext) => { diff --git a/src/components/bisector/DialogContent.vue b/src/components/bisector/DialogContent.vue new file mode 100644 index 000000000..c5957999c --- /dev/null +++ b/src/components/bisector/DialogContent.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/src/components/bisector/DialogTitle.vue b/src/components/bisector/DialogTitle.vue new file mode 100644 index 000000000..2ae4acad4 --- /dev/null +++ b/src/components/bisector/DialogTitle.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/src/components/bisector/ResultToastContent.vue b/src/components/bisector/ResultToastContent.vue new file mode 100644 index 000000000..09b3fc143 --- /dev/null +++ b/src/components/bisector/ResultToastContent.vue @@ -0,0 +1,54 @@ + + + diff --git a/src/components/bisector/api.ts b/src/components/bisector/api.ts new file mode 100644 index 000000000..866c62952 --- /dev/null +++ b/src/components/bisector/api.ts @@ -0,0 +1,198 @@ +import { DialogInstance, showDialog } from '@/core/dialog' +import type { Settings } from '@/core/settings/types' +import { Toast } from '@/core/toast' +import { getRandomId, mountVueComponent, delay } from '@/core/utils' +import { useScopedConsole } from '@/core/utils/log' +import type { RecordValue } from '../types' +import type { BisectNext, BisectReturn } from './bisect' +import { bisect } from './bisect' +import { BisectorOptions } from './options' +import ResultToastContent from './ResultToastContent.vue' + +type UserComponent = RecordValue + +let bisectorOptions: BisectorOptions +let scopedConsole: ReturnType +let bisectorGenerator: Generator, BisectReturn> +let groupedComponents: Awaited> +let dialog: DialogInstance + +export const setOptions = (options: BisectorOptions) => { + if (!bisectorOptions) { + bisectorOptions = options + } else { + Object.assign(bisectorOptions, options) + } +} + +export const setConsole = (console: ReturnType) => { + scopedConsole = console +} + +const classifyComponents = async () => { + const { settings } = await import('@/core/settings') + + const { userComponents } = settings + const configurableUserComponents = lodash.pickBy( + userComponents, + v => v.metadata.configurable || true, + ) + const { targetComponents, keepDisabledComponents, keepEnabledComponents } = lodash.transform( + configurableUserComponents, + (result, v, k) => { + if (bisectorOptions.keepEnabledComponents?.includes(k)) { + result.keepEnabledComponents.push(v) + return + } + if (bisectorOptions.keepDisabledComponents?.includes(k)) { + result.keepDisabledComponents.push(v) + return + } + result.targetComponents.push(v) + }, + { + targetComponents: [] as UserComponent[], + keepDisabledComponents: [] as UserComponent[], + keepEnabledComponents: [] as UserComponent[], + }, + ) + + return { + userComponents, + configurableUserComponents, + targetComponents, + keepDisabledComponents, + keepEnabledComponents, + } +} + +const setComponentsEnabled = (components: UserComponent[], enabled: boolean) => + components.forEach(component => (component.settings.enabled = enabled)) + +const getComponentNames = (components: UserComponent[]) => + components + .map(component => `${component.metadata.displayName}(${component.metadata.name})`) + .join(', ') || '无' + +export const isRecover = () => !lodash.isEmpty(bisectorOptions.bisectInitialState) + +export const stop = async () => { + scopedConsole?.log('stop - 准备停止组件二等分') + dialog?.close() + const { configurableUserComponents } = await classifyComponents() + const unmatchedComponents: UserComponent[] = [] + for (const [componentName, componentSettings] of Object.entries(configurableUserComponents)) { + const originalStatus = bisectorOptions.originalComponentEnableState?.[componentName] + if (originalStatus == null) { + unmatchedComponents.push(componentSettings) + continue + } + componentSettings.settings.enabled = originalStatus + } + if (unmatchedComponents.length) { + const msg = `部分组件未能还原状态:${getComponentNames(unmatchedComponents)}` + scopedConsole?.warn(`stop - ${msg}`) + Toast.error(msg, '组件二等分') + await delay(3e3) + } + scopedConsole?.log('stop - 清理状态') + bisectorGenerator = null + bisectorOptions.bisectInitialState = {} + bisectorOptions.originalComponentEnableState = {} + scopedConsole?.log('stop - 重载页面') + location.reload() +} + +export const next = async (seeingBad?: boolean, autoReload?: boolean) => { + dialog?.close() + scopedConsole?.log( + `next - 当前工作状态:${ + // eslint-disable-next-line no-nested-ternary + seeingBad == null ? '未知' : seeingBad ? '异常' : '正常' + }`, + ) + const { done, value } = bisectorGenerator.next(seeingBad) + if (done) { + const { low, high } = value + bisectorOptions.bisectInitialState = { low, high } + const elementId = `bisector-result-toast-content-${getRandomId()}` + Toast.info(/* html */ `
`, '组件二等分结果') + await delay() + const vm = mountVueComponent<{ userComponent: UserComponent }>( + ResultToastContent, + `#${elementId}`, + ) + vm.userComponent = value.target + vm.$on('restore', () => { + stop() + }) + } else { + const { slice, low, high } = value as BisectNext + const needEnabled = slice + const needDisabled = lodash.difference(groupedComponents.targetComponents, slice) + bisectorOptions.bisectInitialState = { low, high } + scopedConsole?.log(`next - 关闭组件:${getComponentNames(needDisabled)}`) + scopedConsole?.log(`next - 开启组件:${getComponentNames(needEnabled)}`) + setComponentsEnabled(needDisabled, false) + setComponentsEnabled(needEnabled, true) + if (autoReload) { + scopedConsole?.log('next - 重载页面') + location.reload() + } + } + return { done, value } +} + +export const recover = async () => { + scopedConsole?.log('recover - 准备恢复组件二等分') + groupedComponents = await classifyComponents() + const { targetComponents, keepDisabledComponents, keepEnabledComponents } = groupedComponents + setComponentsEnabled(keepEnabledComponents, true) + setComponentsEnabled(keepDisabledComponents, false) + scopedConsole?.log(`recover - 保持关闭组件:${getComponentNames(keepDisabledComponents)}`) + scopedConsole?.log(`recover - 保持开启组件:${getComponentNames(keepEnabledComponents)}`) + scopedConsole?.log(`recover - 全部目标组件:${getComponentNames(targetComponents)}`) + bisectorGenerator = bisect(targetComponents, bisectorOptions.bisectInitialState) + const { done, value } = await next() + if (!done) { + const { rouge } = value as BisectNext + dialog = showDialog({ + title: () => import('./DialogTitle.vue'), + content: () => import('./DialogContent.vue'), + contentProps: { + rouge, + onGood: () => next(false, true), + onBad: () => next(true, true), + onAbort: () => stop(), + }, + }) + } +} + +export const start = async () => { + if (isRecover()) { + await recover() + return + } + scopedConsole?.log('start - 准备开始组件二等分') + groupedComponents = await classifyComponents() + const { + configurableUserComponents, + targetComponents, + keepDisabledComponents, + keepEnabledComponents, + } = groupedComponents + scopedConsole?.log('start - 保存组件初始启用状态') + bisectorOptions.originalComponentEnableState = lodash.mapValues( + configurableUserComponents, + v => v.settings.enabled, + ) + setComponentsEnabled(targetComponents, true) + setComponentsEnabled(keepEnabledComponents, true) + setComponentsEnabled(keepDisabledComponents, false) + scopedConsole?.log(`start - 保持关闭组件:${getComponentNames(keepDisabledComponents)}`) + scopedConsole?.log(`start - 保持开启组件:${getComponentNames(keepEnabledComponents)}`) + scopedConsole?.log(`start - 启用全部目标组件:${getComponentNames(targetComponents)}`) + bisectorGenerator = bisect(targetComponents, bisectorOptions.bisectInitialState) + await next(undefined, true) +} diff --git a/src/components/bisector/bisect.ts b/src/components/bisector/bisect.ts new file mode 100644 index 000000000..c434b57f2 --- /dev/null +++ b/src/components/bisector/bisect.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-bitwise */ + +export interface BisectNext { + low: number + high: number + mid: number + slice: O[] + rouge: number +} + +export interface BisectReturn extends BisectNext { + target: O +} + +export interface InitialState { + low?: number + high?: number +} + +export function* bisectLeft( + data: readonly O[], + initialState?: InitialState, +): Generator, BisectReturn> { + let low = initialState?.low ?? 0 + let high = initialState?.high ?? data.length + let mid: number + + while (low + 1 < high) { + mid = (low + high) >>> 1 + + const seeingBad = yield { + low, + high, + mid, + slice: data.slice(low, mid), + rouge: Math.trunc(Math.log2(high - low)), + } + + if (seeingBad) { + high = mid + } else { + low = mid + } + } + + return { + low, + high, + mid, + slice: data.slice(low, mid), + rouge: Math.trunc(Math.log2(high - low)), + target: data[low], + } +} + +export function* bisectRight( + data: readonly O[], + initialState?: InitialState, +): Generator, BisectReturn> { + let low = initialState?.low ?? 0 + let high = initialState?.high ?? data.length + let mid = (low + high) >>> 1 + + while (low + 1 < high) { + mid = (low + high) >>> 1 + + const seeingBad = yield { + low, + high, + mid, + slice: data.slice(mid, high), + rouge: Math.trunc(Math.log2(high - low)), + } + + if (seeingBad) { + low = mid + } else { + high = mid + } + } + + return { + low, + high, + mid, + slice: data.slice(low, mid), + rouge: Math.trunc(Math.log2(high - low)), + target: data[low], + } +} + +export const bisect = bisectLeft diff --git a/src/components/bisector/index.ts b/src/components/bisector/index.ts new file mode 100644 index 000000000..f9b140564 --- /dev/null +++ b/src/components/bisector/index.ts @@ -0,0 +1,45 @@ +import { defineComponentMetadata } from '@/components/define' +import { bisectorOptionsMetadata } from './options' +import { LifeCycleEventTypes } from '@/core/life-cycle' +import { componentsTags } from '@/components/types' +import * as bisector from './api' +import { useScopedConsole } from '@/core/utils/log' +import type { LaunchBarActionProvider } from '../launch-bar/launch-bar-action' + +export const component = defineComponentMetadata({ + name: 'bisector', + displayName: '组件二等分', + tags: [componentsTags.general, componentsTags.utils], + hidden: true, + configurable: false, + entry: async ({ settings: { options } }) => { + bisector.setOptions(options) + bisector.setConsole(useScopedConsole('组件二等分')) + unsafeWindow.addEventListener(LifeCycleEventTypes.ComponentsLoaded, () => { + if (bisector.isRecover()) { + bisector.recover() + } + }) + }, + options: bisectorOptionsMetadata, + plugin: { + displayName: '组件二等分 - 功能扩展', + setup: ({ addData }) => { + addData('launchBar.actions', (providers: LaunchBarActionProvider[]) => { + providers.push({ + name: 'bisector-start', + getActions: async () => [ + { + name: '开始 / 继续组件二等分', + description: 'Start/Continue component bisection', + icon: 'mdi-view-split-horizontal', + action: async () => { + await bisector.start() + }, + }, + ], + }) + }) + }, + }, +}) diff --git a/src/components/bisector/options.ts b/src/components/bisector/options.ts new file mode 100644 index 000000000..d96d8342c --- /dev/null +++ b/src/components/bisector/options.ts @@ -0,0 +1,31 @@ +import { defineOptionsMetadata, OptionsOfMetadata } from '@/components/define' +import { getComponentSettings } from '@/core/settings' +import type { InitialState } from './bisect' + +export const bisectorOptionsMetadata = defineOptionsMetadata({ + // 原始的组件启用状态 + originalComponentEnableState: { + defaultValue: {} as Record, + hidden: true, + }, + // 保持禁用状态的组件内部名称数组 + keepDisabledComponents: { + defaultValue: [] as string[], + hidden: true, + }, + // 保持启用状态的组件内部名称数组 + keepEnabledComponents: { + defaultValue: [] as string[], + hidden: true, + }, + // bisect 生成器的初始状态 + bisectInitialState: { + defaultValue: {} as Partial, + hidden: true, + }, +}) + +export const getBisectorOptions = () => + getComponentSettings>('bisector').options + +export type BisectorOptions = ReturnType diff --git a/src/components/built-in-components.ts b/src/components/built-in-components.ts index e9a0b98da..1dd47ed85 100644 --- a/src/components/built-in-components.ts +++ b/src/components/built-in-components.ts @@ -4,6 +4,7 @@ import { component as LaunchBar } from './launch-bar' import { component as I18n } from './i18n' import { component as AutoUpdate } from './auto-update' import { component as NotifyNewVersion } from './notify-new-version' +import { component as Bisector } from './bisector' export const getBuiltInComponents = (): ComponentMetadata[] => [ SettingsPanel, @@ -11,6 +12,7 @@ export const getBuiltInComponents = (): ComponentMetadata[] => [ I18n, AutoUpdate, NotifyNewVersion, + Bisector, ] export const isBuiltInComponent = (name: string) => diff --git a/src/components/i18n/dom-translator.ts b/src/components/i18n/dom-translator.ts index 57d8c5675..9b95f4ec8 100644 --- a/src/components/i18n/dom-translator.ts +++ b/src/components/i18n/dom-translator.ts @@ -12,11 +12,15 @@ export class Translator { static map: Map static regex: [RegExp, string][] + // eslint-disable-next-line class-methods-use-this protected accepts = (node: Node) => node.nodeType === Node.ELEMENT_NODE + // eslint-disable-next-line class-methods-use-this protected getValue = (node: Node) => node.nodeValue + // eslint-disable-next-line class-methods-use-this protected setValue = (node: Node, value: string) => { node.nodeValue = value } + // eslint-disable-next-line class-methods-use-this protected getElement = (node: Node) => node as Element translate(node: Node) { let value = this.getValue(node) @@ -99,17 +103,23 @@ export class Translator { } } export class TextNodeTranslator extends Translator { + // eslint-disable-next-line class-methods-use-this accepts = (node: Node) => node.nodeType === Node.TEXT_NODE + // eslint-disable-next-line class-methods-use-this getElement = (node: Node) => node.parentElement } export class TitleTranslator extends Translator { + // eslint-disable-next-line class-methods-use-this getValue = (node: Node) => (node as Element).getAttribute('title') + // eslint-disable-next-line class-methods-use-this setValue = (node: Node, value: string) => { ;(node as Element).setAttribute('title', value) } } export class PlaceholderTranslator extends Translator { + // eslint-disable-next-line class-methods-use-this getValue = (node: Node) => (node as Element).getAttribute('placeholder') + // eslint-disable-next-line class-methods-use-this setValue = (node: Node, value: string) => { ;(node as Element).setAttribute('placeholder', value) } diff --git a/src/components/types.ts b/src/components/types.ts index c3b7c411a..38261f968 100644 --- a/src/components/types.ts +++ b/src/components/types.ts @@ -216,3 +216,6 @@ export interface ComponentMetadata /** 用户组件的非函数基本信息, 用于直接保存为 JSON */ export type UserComponentMetadata = Omit + +/** 推断 Record 的 Value 类型 */ +export type RecordValue = R extends Record ? V : never diff --git a/src/components/user-component.ts b/src/components/user-component.ts index b29b1ceac..357aef233 100644 --- a/src/components/user-component.ts +++ b/src/components/user-component.ts @@ -1,6 +1,8 @@ import { componentToSettings } from '@/core/settings' import { isBuiltInComponent } from './built-in-components' import { ComponentMetadata, componentsMap } from './component' +import * as bisector from './bisector/api' +import { BisectorOptions } from './bisector/options' /** * 安装自定义组件 @@ -132,3 +134,16 @@ export const toggleComponent = async (nameOrDisplayName: string) => { const { displayName } = userComponent.metadata return `已${enabled ? '开启' : '关闭'}组件'${displayName}', 可能需要刷新后才能生效` } + +/** + * 二等分自定义组件的开关状态 + * + * @param options 二等分选项 + * @returns + */ +export const bisectComponent = async (options?: BisectorOptions) => { + if (options) { + bisector.setOptions(options) + } + return bisector +} diff --git a/src/core/dialog/index.ts b/src/core/dialog/index.ts index b7c160a6f..8c21794a1 100644 --- a/src/core/dialog/index.ts +++ b/src/core/dialog/index.ts @@ -10,7 +10,7 @@ export interface DialogInputs { } export interface DialogInstance extends Required { open: boolean - close: Promise + close(): Promise closeListeners: (() => void)[] } export const showDialog = (inputs: DialogInputs) => { diff --git a/src/core/external-input/load-feature-code.ts b/src/core/external-input/load-feature-code.ts index 7e926330a..0b283d780 100644 --- a/src/core/external-input/load-feature-code.ts +++ b/src/core/external-input/load-feature-code.ts @@ -1,6 +1,9 @@ export class LoadFeatureCodeError extends Error {} -interface CodeSandbox { +/** + * feature 代码运行沙箱 + */ +type CodeSandbox = { /** * 在沙箱中执行代码 * @@ -15,11 +18,11 @@ interface CodeSandbox { } /** - * 获取代码运行沙箱 + * 创建 feature 代码的运行沙箱 * - * @returns 一个函数:接受代码,返回。 + * @returns 一个 `CodeSandbox`。 */ -const getSandbox = lodash.once((): CodeSandbox => { +const createCodeSandbox = (): CodeSandbox => { // 需要被注入到 `sandbox` 中的键值对 const injection = new Map([ // 加固,防止逃逸 @@ -55,8 +58,9 @@ const getSandbox = lodash.once((): CodeSandbox => { return [exported, returned] }, } -}) +} +let staticCodeSandbox: CodeSandbox | undefined /** * 执行 feature (component, plugin, style) 的代码,并尝试获取其导出元数据 * @@ -69,14 +73,15 @@ const getSandbox = lodash.once((): CodeSandbox => { * * 代码默认以非严格模式执行,启用需自行添加 `use strict`。(从本项目中打包的 feature 自带严格模式) * - * 全局对象为脚本管理器提供的 `window`,支持访问 `unsafeWindow`。 + * 代码执行时的全局对象为脚本管理器提供的 `window`。代码中支持访问 `unsafeWindow`。 * * @param code - 被执行的代码 - * @returns 导出的元数据(不检测正确性) + * @returns 导出的元数据(不检测是否为正确的 feature) * @throws {@link LoadFeatureCodeError} * 代码包含语法错误或代码执行时产生了异常 */ export const loadFeatureCode = (code: string): unknown => { - const [exported, returned] = getSandbox().run(code) + staticCodeSandbox || (staticCodeSandbox = createCodeSandbox()) + const [exported, returned] = staticCodeSandbox.run(code) return exported || returned } diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 270e5e04d..babc4e035 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -407,6 +407,7 @@ export class DoubleClickEvent { singleClickHandler: (e: MouseEvent) => void = none private clickedOnce = false + // eslint-disable-next-line class-methods-use-this private readonly stopPropagationHandler = (e: MouseEvent) => { e.stopImmediatePropagation() }